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
|
@@ -0,0 +1,246 @@
|
|
|
1
|
+
"""vertex_surface.py — Vertex AI passthrough proxy surface (WP-11).
|
|
2
|
+
|
|
3
|
+
Transparent passthrough: forward Authorization bearer untouched (AC-2),
|
|
4
|
+
cache by body content never by token (SEC), no SSE — always single JSON.
|
|
5
|
+
|
|
6
|
+
Key design decisions (per LLD §5 STAGE-5 RESOLUTIONS):
|
|
7
|
+
CRIT-1: Route by FastAPI PATH /v1/projects/{vertex_path:path}, NOT hostname.
|
|
8
|
+
Upstream host is reconstructed from /locations/{region}/ in the path.
|
|
9
|
+
CRIT-2: Vertex bodies have no model/messages/system (model in PATH, prompts
|
|
10
|
+
under 'contents'). The provider=='vertex' branch in CacheManager.build_key
|
|
11
|
+
extracts these correctly — preventing all-requests-same-key collision.
|
|
12
|
+
D2: Pin upstream host to https://{location}-aiplatform.googleapis.com from path.
|
|
13
|
+
IGNORE providers.vertex.base_url — honoring it is an SSRF surface.
|
|
14
|
+
SECURITY (AC-3): bearer token structurally excluded from cache key, value,
|
|
15
|
+
logs, and stored ProxyRequest.headers (redacted via _redact_headers).
|
|
16
|
+
|
|
17
|
+
WP-11a (gemini-native fix) lives in _helpers.py:_GEMINI_NATIVE_FORWARD_HEADERS.
|
|
18
|
+
"""
|
|
19
|
+
|
|
20
|
+
from __future__ import annotations
|
|
21
|
+
|
|
22
|
+
import json
|
|
23
|
+
import logging
|
|
24
|
+
import re
|
|
25
|
+
|
|
26
|
+
from fastapi.requests import Request
|
|
27
|
+
from fastapi.responses import Response
|
|
28
|
+
|
|
29
|
+
from superlocalmemory.optimize.proxy._helpers import (
|
|
30
|
+
_VERTEX_FORWARD_HEADERS,
|
|
31
|
+
_derive_tenant_id,
|
|
32
|
+
_fail_open_forward,
|
|
33
|
+
_filter_response_headers,
|
|
34
|
+
_redact_headers,
|
|
35
|
+
_safe_cache_check,
|
|
36
|
+
_safe_cache_hit_callbacks,
|
|
37
|
+
_safe_cache_store,
|
|
38
|
+
capture_passthrough_forward,
|
|
39
|
+
)
|
|
40
|
+
from superlocalmemory.optimize.proxy.capture import capture_enabled
|
|
41
|
+
from superlocalmemory.optimize.proxy.lifecycle import ProviderResponse, ProxyRequest
|
|
42
|
+
|
|
43
|
+
logger = logging.getLogger("slm.optimize.proxy.vertex")
|
|
44
|
+
|
|
45
|
+
# ---------------------------------------------------------------------------
|
|
46
|
+
# SSRF guard — LLD §5
|
|
47
|
+
# ---------------------------------------------------------------------------
|
|
48
|
+
|
|
49
|
+
# Accepts ONLY:
|
|
50
|
+
# {project}/locations/{location}/publishers/google/models/{model}:{method}
|
|
51
|
+
# Rejects:
|
|
52
|
+
# - ../ traversal (no dots allowed in segments via character classes)
|
|
53
|
+
# - unknown methods (countTokens, EXEC, etc.)
|
|
54
|
+
# - overlong segments
|
|
55
|
+
_VERTEX_PATH_RE = re.compile(
|
|
56
|
+
r"^(?P<project>[a-zA-Z0-9_\-]{1,63})"
|
|
57
|
+
r"/locations/(?P<location>[a-z0-9\-]{1,40})"
|
|
58
|
+
r"/publishers/google/models/(?P<model>[a-zA-Z0-9._\-]{1,128})"
|
|
59
|
+
r":(?P<method>generateContent|streamGenerateContent)$"
|
|
60
|
+
)
|
|
61
|
+
|
|
62
|
+
|
|
63
|
+
def _validate_vertex_path(path: str) -> bool:
|
|
64
|
+
"""Return True iff path matches the Vertex AI path pattern (SSRF guard).
|
|
65
|
+
|
|
66
|
+
Rejects ../, traversal characters, unknown methods, and overlong segments.
|
|
67
|
+
"""
|
|
68
|
+
return bool(_VERTEX_PATH_RE.match(path))
|
|
69
|
+
|
|
70
|
+
|
|
71
|
+
def _parse_vertex_path(
|
|
72
|
+
path: str,
|
|
73
|
+
) -> tuple[str, str, str, str] | None:
|
|
74
|
+
"""Parse a validated Vertex path into (project, location, model, method).
|
|
75
|
+
|
|
76
|
+
Returns None on parse failure (caller should return 400).
|
|
77
|
+
"""
|
|
78
|
+
m = _VERTEX_PATH_RE.match(path)
|
|
79
|
+
if m is None:
|
|
80
|
+
return None
|
|
81
|
+
return (
|
|
82
|
+
m.group("project"),
|
|
83
|
+
m.group("location"),
|
|
84
|
+
m.group("model"),
|
|
85
|
+
m.group("method"),
|
|
86
|
+
)
|
|
87
|
+
|
|
88
|
+
|
|
89
|
+
def _build_vertex_upstream_url(location: str, full_path: str) -> str:
|
|
90
|
+
"""Build the Vertex upstream URL.
|
|
91
|
+
|
|
92
|
+
D2 (LOCKED): pin host to https://{location}-aiplatform.googleapis.com
|
|
93
|
+
from the path — IGNORE providers.vertex.base_url (honoring it = SSRF).
|
|
94
|
+
"""
|
|
95
|
+
host = f"https://{location}-aiplatform.googleapis.com"
|
|
96
|
+
return f"{host}{full_path}"
|
|
97
|
+
|
|
98
|
+
|
|
99
|
+
# ---------------------------------------------------------------------------
|
|
100
|
+
# Route handler
|
|
101
|
+
# ---------------------------------------------------------------------------
|
|
102
|
+
|
|
103
|
+
async def handle_vertex_generative(
|
|
104
|
+
proxy: object,
|
|
105
|
+
request: Request,
|
|
106
|
+
vertex_path: str,
|
|
107
|
+
) -> Response:
|
|
108
|
+
"""Handle POST /v1/projects/{vertex_path:path}.
|
|
109
|
+
|
|
110
|
+
Full pipeline: validate → parse → cache check → POST upstream → cache store.
|
|
111
|
+
Bearer token is NEVER in the cache key (structurally — build_key reads only body).
|
|
112
|
+
Always returns a single JSON Response — no SSE/StreamingResponse (LLD §1).
|
|
113
|
+
Fail-open on unexpected exceptions.
|
|
114
|
+
"""
|
|
115
|
+
request_id = await proxy.next_request_id()
|
|
116
|
+
|
|
117
|
+
# ── SSRF guard — validate path BEFORE any upstream contact (AC-3 / LLD §4) ──
|
|
118
|
+
if not _validate_vertex_path(vertex_path):
|
|
119
|
+
logger.warning(
|
|
120
|
+
"[%s] handle_vertex_generative: rejected invalid path=%r (SSRF guard)",
|
|
121
|
+
request_id, vertex_path,
|
|
122
|
+
)
|
|
123
|
+
return Response(
|
|
124
|
+
content=b'{"error":{"code":400,"message":"Invalid Vertex path",'
|
|
125
|
+
b'"status":"INVALID_ARGUMENT"}}',
|
|
126
|
+
status_code=400,
|
|
127
|
+
media_type="application/json",
|
|
128
|
+
)
|
|
129
|
+
|
|
130
|
+
parsed = _parse_vertex_path(vertex_path)
|
|
131
|
+
if parsed is None:
|
|
132
|
+
# Should not reach here after _validate_vertex_path, but be defensive.
|
|
133
|
+
logger.error("[%s] handle_vertex_generative: parse failed after validation", request_id)
|
|
134
|
+
return Response(
|
|
135
|
+
content=b'{"error":{"code":400,"message":"Path parse error",'
|
|
136
|
+
b'"status":"INVALID_ARGUMENT"}}',
|
|
137
|
+
status_code=400,
|
|
138
|
+
media_type="application/json",
|
|
139
|
+
)
|
|
140
|
+
|
|
141
|
+
_project, location, _model, _method = parsed
|
|
142
|
+
upstream_url = _build_vertex_upstream_url(location, str(request.url.path))
|
|
143
|
+
|
|
144
|
+
try:
|
|
145
|
+
body_bytes = await request.body()
|
|
146
|
+
|
|
147
|
+
try:
|
|
148
|
+
body = json.loads(body_bytes) if body_bytes else {}
|
|
149
|
+
except json.JSONDecodeError:
|
|
150
|
+
body = {}
|
|
151
|
+
|
|
152
|
+
# v3.6.10 shadow-capture: pure passthrough + corpus record.
|
|
153
|
+
if capture_enabled():
|
|
154
|
+
return await capture_passthrough_forward(
|
|
155
|
+
proxy, request,
|
|
156
|
+
provider="vertex",
|
|
157
|
+
upstream_url=upstream_url,
|
|
158
|
+
allowed_headers=_VERTEX_FORWARD_HEADERS,
|
|
159
|
+
request_id=request_id,
|
|
160
|
+
model_hint=_model,
|
|
161
|
+
sse_parser=None,
|
|
162
|
+
is_stream=False,
|
|
163
|
+
)
|
|
164
|
+
|
|
165
|
+
# SECURITY (AC-3): headers stored in ProxyRequest are redacted.
|
|
166
|
+
# Bearer token is structurally excluded from the cache key because
|
|
167
|
+
# build_key reads only body-derived fields (key_builder.py:90-105).
|
|
168
|
+
#
|
|
169
|
+
# SECURITY (WP-D): derive tenant BEFORE _redact_headers strips the
|
|
170
|
+
# bearer token. Vertex uses Authorization bearer.
|
|
171
|
+
_raw_key = request.headers.get("authorization")
|
|
172
|
+
_tenant_id = _derive_tenant_id("vertex", _raw_key)
|
|
173
|
+
|
|
174
|
+
ctx = ProxyRequest(
|
|
175
|
+
provider="vertex",
|
|
176
|
+
method="POST",
|
|
177
|
+
path=str(request.url.path),
|
|
178
|
+
headers=_redact_headers(dict(request.headers)), # token → [REDACTED]
|
|
179
|
+
body=body,
|
|
180
|
+
body_bytes=body_bytes,
|
|
181
|
+
request_id=request_id,
|
|
182
|
+
stream=False,
|
|
183
|
+
has_tools=False,
|
|
184
|
+
)
|
|
185
|
+
|
|
186
|
+
# Build forward headers — Authorization byte-identical (AC-2).
|
|
187
|
+
fwd_headers = {
|
|
188
|
+
k: v for k, v in request.headers.items()
|
|
189
|
+
if k.lower() in _VERTEX_FORWARD_HEADERS
|
|
190
|
+
}
|
|
191
|
+
|
|
192
|
+
# ── Cache check ────────────────────────────────────────────────────
|
|
193
|
+
cache_result = None
|
|
194
|
+
if proxy.hooks.cache:
|
|
195
|
+
cache_result = await _safe_cache_check(proxy.hooks, ctx, tenant_id=_tenant_id)
|
|
196
|
+
if cache_result and cache_result.hit and cache_result.data:
|
|
197
|
+
logger.debug(
|
|
198
|
+
"[%s] Vertex cache HIT key=%s",
|
|
199
|
+
request_id, cache_result.cache_key,
|
|
200
|
+
)
|
|
201
|
+
await _safe_cache_hit_callbacks(
|
|
202
|
+
proxy.hooks, ctx, cache_result.data, tokens_saved=0
|
|
203
|
+
)
|
|
204
|
+
return Response(
|
|
205
|
+
content=cache_result.data,
|
|
206
|
+
status_code=200,
|
|
207
|
+
media_type="application/json",
|
|
208
|
+
)
|
|
209
|
+
|
|
210
|
+
fwd_headers["content-length"] = str(len(body_bytes))
|
|
211
|
+
|
|
212
|
+
# ── POST upstream (single JSON — no SSE) ──────────────────────────
|
|
213
|
+
upstream_resp = await proxy.http_client.post(
|
|
214
|
+
upstream_url, content=body_bytes, headers=fwd_headers,
|
|
215
|
+
)
|
|
216
|
+
resp_bytes = upstream_resp.content
|
|
217
|
+
|
|
218
|
+
# ── Cache store on 200 only ────────────────────────────────────────
|
|
219
|
+
if (
|
|
220
|
+
upstream_resp.status_code == 200
|
|
221
|
+
and proxy.hooks.cache
|
|
222
|
+
and cache_result is not None
|
|
223
|
+
and cache_result.cache_key
|
|
224
|
+
):
|
|
225
|
+
prov_resp = ProviderResponse(
|
|
226
|
+
modified=False,
|
|
227
|
+
body={},
|
|
228
|
+
body_bytes=resp_bytes,
|
|
229
|
+
tokens_before=0,
|
|
230
|
+
tokens_after=0,
|
|
231
|
+
strategy="none",
|
|
232
|
+
)
|
|
233
|
+
await _safe_cache_store(proxy.hooks, ctx, prov_resp, tenant_id=_tenant_id)
|
|
234
|
+
|
|
235
|
+
return Response(
|
|
236
|
+
content=resp_bytes,
|
|
237
|
+
status_code=upstream_resp.status_code,
|
|
238
|
+
media_type="application/json",
|
|
239
|
+
headers=_filter_response_headers(dict(upstream_resp.headers)),
|
|
240
|
+
)
|
|
241
|
+
|
|
242
|
+
except Exception as exc:
|
|
243
|
+
logger.error(
|
|
244
|
+
"[%s] handle_vertex_generative exc=%r — fail-open", request_id, exc
|
|
245
|
+
)
|
|
246
|
+
return await _fail_open_forward(proxy, request, upstream_url)
|
|
@@ -947,6 +947,36 @@ class CacheDB:
|
|
|
947
947
|
except sqlite3.Error as exc:
|
|
948
948
|
logger.warning("CacheDB.ccr_update_compressed failed: %s", exc)
|
|
949
949
|
|
|
950
|
+
def ccr_delete(self, ccr_id: str) -> None:
|
|
951
|
+
"""Delete a CCR row by ccr_id. Idempotent — warns on sqlite error, never raises.
|
|
952
|
+
|
|
953
|
+
WP-10 D6: defensive infra + sweep parity. Deleting a non-existent row is a no-op.
|
|
954
|
+
"""
|
|
955
|
+
try:
|
|
956
|
+
self._db.execute(
|
|
957
|
+
"DELETE FROM llmcache_ccr_originals WHERE ccr_id = ?",
|
|
958
|
+
(ccr_id,),
|
|
959
|
+
)
|
|
960
|
+
except sqlite3.Error as exc:
|
|
961
|
+
logger.warning("CacheDB.ccr_delete failed (non-fatal): %s", exc)
|
|
962
|
+
|
|
963
|
+
def ccr_count(self) -> int:
|
|
964
|
+
"""Return UNFILTERED count of rows in llmcache_ccr_originals.
|
|
965
|
+
|
|
966
|
+
WP-10 CRIT-2: Do NOT reuse TTL-filtered count at :646. A fresh no-expiry row
|
|
967
|
+
has ttl_expires=None, so the TTL filter returns 0 and D6 orphan tests would
|
|
968
|
+
falsely pass. This unfiltered count is test infrastructure only.
|
|
969
|
+
"""
|
|
970
|
+
try:
|
|
971
|
+
rows = self._db.execute(
|
|
972
|
+
"SELECT COUNT(*) AS n FROM llmcache_ccr_originals",
|
|
973
|
+
(),
|
|
974
|
+
)
|
|
975
|
+
return int(dict(rows[0])["n"]) if rows else 0
|
|
976
|
+
except sqlite3.Error as exc:
|
|
977
|
+
logger.warning("CacheDB.ccr_count failed: %s", exc)
|
|
978
|
+
return 0
|
|
979
|
+
|
|
950
980
|
# ---- v2 additions ----
|
|
951
981
|
|
|
952
982
|
def get_all_vectors(self, tenant_id: str) -> list[tuple[str, bytes, str]]:
|
|
@@ -86,9 +86,15 @@ class BM25Channel:
|
|
|
86
86
|
return
|
|
87
87
|
|
|
88
88
|
token_map = self._db.get_all_bm25_tokens(profile_id)
|
|
89
|
+
_inc_global = getattr(self, 'include_global', False)
|
|
90
|
+
_inc_shared = getattr(self, 'include_shared', False)
|
|
89
91
|
if not token_map:
|
|
90
92
|
# Fallback: tokenize facts directly if no pre-stored tokens
|
|
91
|
-
facts = self._db.get_all_facts(
|
|
93
|
+
facts = self._db.get_all_facts(
|
|
94
|
+
profile_id,
|
|
95
|
+
include_global=_inc_global,
|
|
96
|
+
include_shared=_inc_shared,
|
|
97
|
+
)
|
|
92
98
|
for fact in facts:
|
|
93
99
|
if fact.fact_id in self._fact_id_set:
|
|
94
100
|
continue
|
|
@@ -104,7 +110,11 @@ class BM25Channel:
|
|
|
104
110
|
# Load raw texts for phrase matching (V3.3.12)
|
|
105
111
|
fact_content_map = {}
|
|
106
112
|
try:
|
|
107
|
-
facts = self._db.get_all_facts(
|
|
113
|
+
facts = self._db.get_all_facts(
|
|
114
|
+
profile_id,
|
|
115
|
+
include_global=_inc_global,
|
|
116
|
+
include_shared=_inc_shared,
|
|
117
|
+
)
|
|
108
118
|
fact_content_map = {f.fact_id: f.content for f in facts}
|
|
109
119
|
except Exception:
|
|
110
120
|
pass
|
|
@@ -16,6 +16,7 @@ from __future__ import annotations
|
|
|
16
16
|
import logging
|
|
17
17
|
import math
|
|
18
18
|
import re
|
|
19
|
+
import threading
|
|
19
20
|
import time
|
|
20
21
|
from typing import TYPE_CHECKING, Any, Protocol
|
|
21
22
|
|
|
@@ -84,6 +85,12 @@ class RetrievalEngine:
|
|
|
84
85
|
self._profile_channel = profile_channel
|
|
85
86
|
self._bridge = bridge_discovery
|
|
86
87
|
self._trust_scorer = trust_scorer
|
|
88
|
+
# v3.6.15: serialise the per-recall scope-flag set + channel execution.
|
|
89
|
+
# Channel instances are SHARED across concurrent recalls (the daemon runs
|
|
90
|
+
# several in parallel); without this, recall B's flags could overwrite
|
|
91
|
+
# recall A's mid-flight on the shared channels. Uncontended for a single
|
|
92
|
+
# recall (~0 cost); only the channel phase of concurrent recalls serialises.
|
|
93
|
+
self._scope_lock = threading.Lock()
|
|
87
94
|
|
|
88
95
|
# V3.3.4: LRU cache for query embeddings (avoids redundant Ollama API calls)
|
|
89
96
|
# V3.4.40 (2026-05-09): bumped 64 -> 512. Each cached embedding is ~3KB
|
|
@@ -117,9 +124,15 @@ class RetrievalEngine:
|
|
|
117
124
|
mode: Mode = Mode.A, limit: int = 20,
|
|
118
125
|
*,
|
|
119
126
|
extra_disabled_channels: set[str] | None = None,
|
|
127
|
+
include_global: bool = True,
|
|
128
|
+
include_shared: bool = True,
|
|
120
129
|
) -> RecallResponse:
|
|
121
130
|
"""Full retrieval pipeline: strategy -> channels -> RRF -> rerank.
|
|
122
131
|
|
|
132
|
+
Multi-scope: ``include_global`` / ``include_shared`` control which
|
|
133
|
+
scopes participate in retrieval. Both default to True for backward
|
|
134
|
+
compatibility (existing data has scope='personal' — no effect).
|
|
135
|
+
|
|
123
136
|
V3.4.40 (2026-05-09): ``extra_disabled_channels`` allows callers to
|
|
124
137
|
skip specific channels for a single recall (e.g. SpreadingActivation
|
|
125
138
|
for the ``--fast`` CLI flag) without mutating shared config.
|
|
@@ -127,6 +140,12 @@ class RetrievalEngine:
|
|
|
127
140
|
t0 = time.monotonic()
|
|
128
141
|
self._extra_disabled = set(extra_disabled_channels or ())
|
|
129
142
|
|
|
143
|
+
# Multi-scope: scope flags are set on the (shared) channel instances +
|
|
144
|
+
# the channels executed atomically under self._scope_lock — see the
|
|
145
|
+
# `# 3. Run channels` block below. (profile_channel does not read scope.)
|
|
146
|
+
self._include_global = include_global
|
|
147
|
+
self._include_shared = include_shared
|
|
148
|
+
|
|
130
149
|
# v3.5.0 diagnostic: stage timing inside retrieval (SLM_RECALL_TIMING=1).
|
|
131
150
|
import os as _os_e
|
|
132
151
|
import time as _time_e
|
|
@@ -159,8 +178,18 @@ class RetrievalEngine:
|
|
|
159
178
|
# Dynamic top-k for aggregation queries
|
|
160
179
|
effective_limit = 100 if strat.query_type == "aggregation" else limit
|
|
161
180
|
|
|
162
|
-
# 3. Run
|
|
163
|
-
|
|
181
|
+
# 3. Run channels. Set the scope flags on the shared channel instances
|
|
182
|
+
# and execute them under self._scope_lock so a concurrent recall can't
|
|
183
|
+
# interleave its scope visibility onto these channels mid-flight. The
|
|
184
|
+
# worker threads spawned inside _run_channels are joined before the lock
|
|
185
|
+
# releases, so every channel read sees THIS recall's flags.
|
|
186
|
+
with self._scope_lock:
|
|
187
|
+
for ch in (self._semantic, self._bm25, self._entity, self._temporal,
|
|
188
|
+
self._hopfield, self._spreading_activation):
|
|
189
|
+
if ch is not None:
|
|
190
|
+
ch.include_global = include_global
|
|
191
|
+
ch.include_shared = include_shared
|
|
192
|
+
ch_results = self._run_channels(query, profile_id, strat)
|
|
164
193
|
_em("run_channels")
|
|
165
194
|
if profile_hits:
|
|
166
195
|
ch_results["profile"] = profile_hits
|
|
@@ -657,7 +686,11 @@ class RetrievalEngine:
|
|
|
657
686
|
needed = [fr.fact_id for fr in fused]
|
|
658
687
|
if not needed:
|
|
659
688
|
return {}
|
|
660
|
-
facts = self._db.get_facts_by_ids(
|
|
689
|
+
facts = self._db.get_facts_by_ids(
|
|
690
|
+
needed, profile_id,
|
|
691
|
+
include_global=getattr(self, '_include_global', True),
|
|
692
|
+
include_shared=getattr(self, '_include_shared', True),
|
|
693
|
+
)
|
|
661
694
|
return {f.fact_id: f for f in facts}
|
|
662
695
|
|
|
663
696
|
# -- Cross-encoder rerank -----------------------------------------------
|
|
@@ -283,7 +283,7 @@ class EntityGraphChannel:
|
|
|
283
283
|
for fid in self._entity_to_facts.get(eid, ()):
|
|
284
284
|
activation[fid] = max(activation[fid], 1.0)
|
|
285
285
|
else:
|
|
286
|
-
for fact in self._db.get_facts_by_entity(eid, profile_id):
|
|
286
|
+
for fact in self._db.get_facts_by_entity(eid, profile_id, include_global=getattr(self, 'include_global', False), include_shared=getattr(self, 'include_shared', False)):
|
|
287
287
|
activation[fact.fact_id] = max(activation[fact.fact_id], 1.0)
|
|
288
288
|
|
|
289
289
|
# Spreading activation through graph edges (all in-memory O(1) lookups)
|
|
@@ -317,7 +317,7 @@ class EntityGraphChannel:
|
|
|
317
317
|
# NOTE: SQL fallback path does NOT use graph intelligence (P1/P2/P3).
|
|
318
318
|
# Graph intelligence is only available on the in-memory cache path.
|
|
319
319
|
# This fallback exists for mock/test DBs. See Phase 7 LLD H-01.
|
|
320
|
-
for edge in self._db.get_edges_for_node(fid, profile_id):
|
|
320
|
+
for edge in self._db.get_edges_for_node(fid, profile_id, include_global=getattr(self, 'include_global', False), include_shared=getattr(self, 'include_shared', False)):
|
|
321
321
|
neighbor = edge.target_id if edge.source_id == fid else edge.source_id
|
|
322
322
|
propagated = activation[fid] * self._decay
|
|
323
323
|
if propagated >= self._threshold and propagated > activation.get(neighbor, 0.0):
|
|
@@ -342,7 +342,7 @@ class EntityGraphChannel:
|
|
|
342
342
|
new_eids_sql = self._discover_entities(frontier, profile_id, visited_entities)
|
|
343
343
|
for eid in new_eids_sql:
|
|
344
344
|
visited_entities.add(eid)
|
|
345
|
-
for fact in self._db.get_facts_by_entity(eid, profile_id):
|
|
345
|
+
for fact in self._db.get_facts_by_entity(eid, profile_id, include_global=getattr(self, 'include_global', False), include_shared=getattr(self, 'include_shared', False)):
|
|
346
346
|
if hop_decay > activation.get(fact.fact_id, 0.0):
|
|
347
347
|
activation[fact.fact_id] = hop_decay
|
|
348
348
|
next_frontier.add(fact.fact_id)
|
|
@@ -438,7 +438,7 @@ class EntityGraphChannel:
|
|
|
438
438
|
for fid in self._entity_to_facts.get(eid, ()):
|
|
439
439
|
activation[fid] = max(activation[fid], 1.0)
|
|
440
440
|
else:
|
|
441
|
-
for fact in self._db.get_facts_by_entity(eid, profile_id):
|
|
441
|
+
for fact in self._db.get_facts_by_entity(eid, profile_id, include_global=getattr(self, 'include_global', False), include_shared=getattr(self, 'include_shared', False)):
|
|
442
442
|
activation[fact.fact_id] = max(activation[fact.fact_id], 1.0)
|
|
443
443
|
|
|
444
444
|
frontier = set(activation.keys())
|
|
@@ -628,7 +628,7 @@ class EntityGraphChannel:
|
|
|
628
628
|
# Map entity scores to fact scores
|
|
629
629
|
fact_scores: list[tuple[str, float]] = []
|
|
630
630
|
for entity_id, score in scored:
|
|
631
|
-
facts = self._db.get_facts_by_entity(entity_id, profile_id)
|
|
631
|
+
facts = self._db.get_facts_by_entity(entity_id, profile_id, include_global=getattr(self, 'include_global', False), include_shared=getattr(self, 'include_shared', False))
|
|
632
632
|
for fact in facts:
|
|
633
633
|
fact_scores.append((fact.fact_id, score))
|
|
634
634
|
|
|
@@ -248,7 +248,11 @@ class HopfieldChannel:
|
|
|
248
248
|
|
|
249
249
|
# Stage 2: Load candidate facts
|
|
250
250
|
candidate_ids = [fid for fid, _ in knn_results]
|
|
251
|
-
candidates = self._db.get_facts_by_ids(
|
|
251
|
+
candidates = self._db.get_facts_by_ids(
|
|
252
|
+
candidate_ids, profile_id,
|
|
253
|
+
include_global=getattr(self, 'include_global', False),
|
|
254
|
+
include_shared=getattr(self, 'include_shared', False),
|
|
255
|
+
)
|
|
252
256
|
if not candidates:
|
|
253
257
|
return []
|
|
254
258
|
|
|
@@ -304,7 +308,11 @@ class HopfieldChannel:
|
|
|
304
308
|
# Step 2: Load facts (V3.3.12: cap to most recent 5000 to bound memory)
|
|
305
309
|
# memory-bounding-02: push the cap into SQL (LIMIT) so we don't
|
|
306
310
|
# deserialize the whole table just to slice it.
|
|
307
|
-
facts = self._db.get_all_facts(
|
|
311
|
+
facts = self._db.get_all_facts(
|
|
312
|
+
profile_id, limit=5000,
|
|
313
|
+
include_global=getattr(self, 'include_global', False),
|
|
314
|
+
include_shared=getattr(self, 'include_shared', False),
|
|
315
|
+
)
|
|
308
316
|
if not facts:
|
|
309
317
|
return (None, [])
|
|
310
318
|
|
|
@@ -168,7 +168,11 @@ class SemanticChannel:
|
|
|
168
168
|
# Step 2: Load only the candidate facts (NOT all facts)
|
|
169
169
|
candidate_ids = [fid for fid, _ in knn_results]
|
|
170
170
|
knn_scores = {fid: score for fid, score in knn_results}
|
|
171
|
-
facts = self._db.get_facts_by_ids(
|
|
171
|
+
facts = self._db.get_facts_by_ids(
|
|
172
|
+
candidate_ids, profile_id,
|
|
173
|
+
include_global=getattr(self, 'include_global', False),
|
|
174
|
+
include_shared=getattr(self, 'include_shared', False),
|
|
175
|
+
)
|
|
172
176
|
|
|
173
177
|
if not facts:
|
|
174
178
|
return [(fid, score) for fid, score in knn_results[:top_k]]
|
|
@@ -230,7 +234,11 @@ class SemanticChannel:
|
|
|
230
234
|
q_mean = np.array(qm, dtype=np.float32)
|
|
231
235
|
q_var = np.array(qv, dtype=np.float32)
|
|
232
236
|
|
|
233
|
-
facts = self._db.get_all_facts(
|
|
237
|
+
facts = self._db.get_all_facts(
|
|
238
|
+
profile_id,
|
|
239
|
+
include_global=getattr(self, 'include_global', False),
|
|
240
|
+
include_shared=getattr(self, 'include_shared', False),
|
|
241
|
+
)
|
|
234
242
|
|
|
235
243
|
scored: list[tuple[str, float]] = []
|
|
236
244
|
for fact in facts:
|
|
@@ -23,6 +23,8 @@ from __future__ import annotations
|
|
|
23
23
|
import re
|
|
24
24
|
from typing import Any
|
|
25
25
|
|
|
26
|
+
from superlocalmemory.core.config import CANONICAL_RECALL_LIMIT
|
|
27
|
+
|
|
26
28
|
|
|
27
29
|
# ---------------------------------------------------------------------------
|
|
28
30
|
# F-2: Per-fact content clamp
|
|
@@ -161,7 +163,7 @@ def apply_source_content_discipline(
|
|
|
161
163
|
def serialize_recall_response(
|
|
162
164
|
response: Any,
|
|
163
165
|
*,
|
|
164
|
-
limit: int =
|
|
166
|
+
limit: int = CANONICAL_RECALL_LIMIT,
|
|
165
167
|
memory_map: dict[str, str] | None = None,
|
|
166
168
|
per_fact_max: int = 2400,
|
|
167
169
|
total_max: int = 12000,
|