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.
- package/.claude-plugin/marketplace.json +17 -0
- package/CHANGELOG.md +28 -0
- package/README.md +189 -740
- package/package.json +12 -5
- package/plugin/.claude-plugin/plugin.json +20 -0
- package/plugin/.mcp.json +12 -0
- package/plugin/CLAUDE.md +44 -0
- package/plugin/_GENERATED.md +6 -0
- package/plugin/agents/slm-memory-advisor.md +44 -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 +204 -0
- package/plugin/skills/slm-remember/SKILL.md +194 -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 +44 -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 +91 -0
- package/plugin-src/rules/CLAUDE.md.fragment +44 -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 +204 -0
- package/plugin-src/skills/slm-remember/SKILL.md +194 -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 +439 -41
- package/src/superlocalmemory/cli/main.py +92 -4
- package/src/superlocalmemory/cli/setup_wizard.py +47 -6
- package/src/superlocalmemory/core/backend_orchestrator.py +12 -8
- package/src/superlocalmemory/core/config.py +194 -9
- package/src/superlocalmemory/core/embeddings.py +10 -5
- package/src/superlocalmemory/core/engine.py +76 -5
- package/src/superlocalmemory/core/fact_consolidator.py +20 -3
- package/src/superlocalmemory/core/platform_utils.py +8 -0
- package/src/superlocalmemory/core/recall_pipeline.py +7 -0
- package/src/superlocalmemory/core/recall_worker.py +7 -0
- package/src/superlocalmemory/core/store_pipeline.py +23 -1
- package/src/superlocalmemory/core/worker_pool.py +14 -2
- package/src/superlocalmemory/hooks/claude_code_hooks.py +27 -3
- package/src/superlocalmemory/hooks/portable_kit.py +506 -0
- package/src/superlocalmemory/hooks/session_registry.py +8 -4
- package/src/superlocalmemory/infra/cloud_backup.py +99 -23
- package/src/superlocalmemory/mcp/_daemon_proxy.py +12 -2
- package/src/superlocalmemory/mcp/_pool_adapter.py +15 -6
- 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 +37 -4
- package/src/superlocalmemory/mcp/tools_v3.py +6 -1
- package/src/superlocalmemory/mcp/tools_v33.py +8 -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/retrieval/bm25_channel.py +12 -2
- package/src/superlocalmemory/retrieval/engine.py +36 -3
- package/src/superlocalmemory/retrieval/entity_channel.py +5 -5
- package/src/superlocalmemory/retrieval/hopfield_channel.py +10 -2
- package/src/superlocalmemory/retrieval/semantic_channel.py +10 -2
- package/src/superlocalmemory/server/recall_serializer.py +3 -1
- package/src/superlocalmemory/server/unified_daemon.py +156 -16
- package/src/superlocalmemory/storage/database.py +215 -43
- package/src/superlocalmemory/storage/migration_runner.py +17 -1
- package/src/superlocalmemory/storage/migrations/M016_add_scope_support.py +120 -0
- package/src/superlocalmemory/storage/models.py +10 -0
- package/src/superlocalmemory/storage/schema.py +15 -10
- 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 +191 -741
- package/src/superlocalmemory.egg-info/SOURCES.txt +7 -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")
|
|
@@ -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 =
|
|
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":
|
|
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
|
-
|
|
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
|
-
|
|
226
|
-
|
|
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
|
-
|
|
438
|
-
|
|
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.
|
|
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":
|