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.
- package/.claude-plugin/marketplace.json +17 -0
- package/README.md +187 -741
- package/package.json +12 -5
- package/plugin/.claude-plugin/plugin.json +20 -0
- package/plugin/.mcp.json +12 -0
- package/plugin/CLAUDE.md +43 -0
- package/plugin/_GENERATED.md +6 -0
- package/plugin/agents/slm-memory-advisor.md +43 -0
- package/plugin/agents/slm-optimize-advisor.md +38 -0
- package/plugin/hooks/hooks.json +14 -0
- package/plugin/requirements.txt +1 -0
- package/plugin/scripts/ensure-venv.bat +122 -0
- package/plugin/scripts/ensure-venv.sh +105 -0
- package/plugin/scripts/slm-launch +15 -0
- package/plugin/scripts/slm-launch.bat +17 -0
- package/plugin/settings.json +16 -0
- package/plugin/skills/slm-cache/SKILL.md +140 -0
- package/plugin/skills/slm-compress/SKILL.md +143 -0
- package/plugin/skills/slm-graph/SKILL.md +300 -0
- package/plugin/skills/slm-recall/SKILL.md +196 -0
- package/plugin/skills/slm-remember/SKILL.md +182 -0
- package/plugin/skills/slm-session/SKILL.md +207 -0
- package/plugin/skills/slm-status/SKILL.md +149 -0
- package/plugin-src/.mcp.json +12 -0
- package/plugin-src/agents/slm-memory-advisor.md +43 -0
- package/plugin-src/agents/slm-optimize-advisor.md +38 -0
- package/plugin-src/commands/slm-optimize.md +22 -0
- package/plugin-src/commands/slm-recall.md +16 -0
- package/plugin-src/commands/slm-remember.md +16 -0
- package/plugin-src/commands/slm-status.md +15 -0
- package/plugin-src/hooks/.gitkeep +0 -0
- package/plugin-src/hooks/hooks.json +14 -0
- package/plugin-src/manifest.json +25 -0
- package/plugin-src/requirements.txt +1 -0
- package/plugin-src/rules/AGENTS.md +90 -0
- package/plugin-src/rules/CLAUDE.md.fragment +43 -0
- package/plugin-src/scripts/ensure-venv.bat +122 -0
- package/plugin-src/scripts/ensure-venv.sh +105 -0
- package/plugin-src/scripts/slm-launch +15 -0
- package/plugin-src/scripts/slm-launch.bat +17 -0
- package/plugin-src/settings.json +16 -0
- package/plugin-src/skills/slm-cache/SKILL.md +140 -0
- package/plugin-src/skills/slm-compress/SKILL.md +143 -0
- package/plugin-src/skills/slm-graph/SKILL.md +300 -0
- package/plugin-src/skills/slm-recall/SKILL.md +196 -0
- package/plugin-src/skills/slm-remember/SKILL.md +182 -0
- package/plugin-src/skills/slm-session/SKILL.md +207 -0
- package/plugin-src/skills/slm-status/SKILL.md +149 -0
- package/pyproject.toml +6 -2
- package/scripts/__tests__/build-plugin.test.mjs +613 -0
- package/scripts/_savings_math.py +270 -0
- package/scripts/build-plugin.js +742 -0
- package/scripts/dogfood_savings.py +490 -0
- package/scripts/install-skills.ps1 +4 -334
- package/scripts/install-skills.sh +4 -435
- package/scripts/postinstall-interactive.js +0 -27
- package/scripts/postinstall.js +21 -2
- package/src/superlocalmemory/__init__.py +1 -1
- package/src/superlocalmemory/cli/_lazy_init.py +115 -0
- package/src/superlocalmemory/cli/commands.py +348 -39
- package/src/superlocalmemory/cli/main.py +47 -4
- package/src/superlocalmemory/cli/setup_wizard.py +20 -6
- package/src/superlocalmemory/core/config.py +79 -9
- package/src/superlocalmemory/core/embeddings.py +10 -5
- package/src/superlocalmemory/core/engine.py +2 -2
- package/src/superlocalmemory/hooks/claude_code_hooks.py +27 -3
- package/src/superlocalmemory/hooks/portable_kit.py +506 -0
- package/src/superlocalmemory/infra/cloud_backup.py +99 -23
- package/src/superlocalmemory/mcp/cli_fallback.py +602 -0
- package/src/superlocalmemory/mcp/server.py +75 -4
- package/src/superlocalmemory/mcp/tools_code_graph.py +3 -3
- package/src/superlocalmemory/mcp/tools_core.py +12 -4
- package/src/superlocalmemory/optimize/cache/boundary_store.py +25 -6
- package/src/superlocalmemory/optimize/cache/centroid_store.py +27 -4
- package/src/superlocalmemory/optimize/cache/manager.py +92 -6
- package/src/superlocalmemory/optimize/cache/semantic.py +20 -1
- package/src/superlocalmemory/optimize/compress/ccr.py +12 -0
- package/src/superlocalmemory/optimize/compress/router.py +46 -13
- package/src/superlocalmemory/optimize/config/schema.py +6 -0
- package/src/superlocalmemory/optimize/proxy/_helpers.py +111 -8
- package/src/superlocalmemory/optimize/proxy/anthropic_surface.py +14 -4
- package/src/superlocalmemory/optimize/proxy/gemini_surface.py +23 -6
- package/src/superlocalmemory/optimize/proxy/openai_surface.py +10 -4
- package/src/superlocalmemory/optimize/proxy/server.py +11 -0
- package/src/superlocalmemory/optimize/proxy/vertex_surface.py +246 -0
- package/src/superlocalmemory/optimize/storage/db.py +30 -0
- package/src/superlocalmemory/server/recall_serializer.py +3 -1
- package/src/superlocalmemory/server/unified_daemon.py +24 -6
- package/src/superlocalmemory/ui/css/legacy-dashboard.css +18 -0
- package/src/superlocalmemory/ui/css/neural-glass.css +5 -0
- package/src/superlocalmemory/ui/index.html +2 -2
- package/src/superlocalmemory/ui/js/core.js +98 -0
- package/src/superlocalmemory/ui/js/dashboard.js +8 -1
- package/src/superlocalmemory/ui/js/ide-status.js +16 -3
- package/src/superlocalmemory/ui/js/math-health.js +15 -3
- package/src/superlocalmemory/ui/js/optimize.js +18 -2
- package/src/superlocalmemory/ui/js/trust-dashboard.js +10 -1
- package/src/superlocalmemory.egg-info/PKG-INFO +189 -742
- package/src/superlocalmemory.egg-info/SOURCES.txt +6 -9
- package/src/superlocalmemory.egg-info/requires.txt +1 -0
- package/ide/skills/slm-build-graph/SKILL.md +0 -423
- package/ide/skills/slm-list-recent/SKILL.md +0 -348
- package/ide/skills/slm-recall/SKILL.md +0 -326
- package/ide/skills/slm-remember/SKILL.md +0 -194
- package/ide/skills/slm-show-patterns/SKILL.md +0 -224
- package/ide/skills/slm-status/SKILL.md +0 -363
- package/ide/skills/slm-switch-profile/SKILL.md +0 -442
- package/skills/slm-build-graph/SKILL.md +0 -423
- package/skills/slm-list-recent/SKILL.md +0 -348
- package/skills/slm-optimize/README.md +0 -55
- package/skills/slm-optimize/SKILL.md +0 -139
- package/skills/slm-recall/SKILL.md +0 -343
- package/skills/slm-remember/SKILL.md +0 -194
- package/skills/slm-show-patterns/SKILL.md +0 -224
- package/skills/slm-status/SKILL.md +0 -363
- package/skills/slm-switch-profile/SKILL.md +0 -442
- package/src/superlocalmemory/cli/doctor_cmd.py +0 -152
- package/src/superlocalmemory/skills/slm-build-graph/SKILL.md +0 -423
- package/src/superlocalmemory/skills/slm-list-recent/SKILL.md +0 -348
- package/src/superlocalmemory/skills/slm-recall/SKILL.md +0 -343
- package/src/superlocalmemory/skills/slm-remember/SKILL.md +0 -194
- package/src/superlocalmemory/skills/slm-show-patterns/SKILL.md +0 -224
- package/src/superlocalmemory/skills/slm-status/SKILL.md +0 -363
- 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 `
|
|
8
|
-
|
|
9
|
-
error if graph not built)
|
|
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 =
|
|
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":
|
|
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.
|
|
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
|
-
|
|
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
|
-
#
|
|
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) ->
|
|
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
|
-
|
|
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
|
-
|
|
56
|
-
self.
|
|
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() # tenant → vec
|
|
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(
|
|
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=
|
|
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(
|
|
313
|
-
|
|
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=
|
|
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(
|
|
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
|
-
#
|
|
295
|
-
|
|
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
|
-
|
|
302
|
-
|
|
303
|
-
|
|
304
|
-
|
|
305
|
-
|
|
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
|
-
|
|
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,
|