superlocalmemory 3.6.13 → 3.6.15

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (147) hide show
  1. package/.claude-plugin/marketplace.json +17 -0
  2. package/CHANGELOG.md +28 -0
  3. package/README.md +189 -740
  4. package/package.json +12 -5
  5. package/plugin/.claude-plugin/plugin.json +20 -0
  6. package/plugin/.mcp.json +12 -0
  7. package/plugin/CLAUDE.md +44 -0
  8. package/plugin/_GENERATED.md +6 -0
  9. package/plugin/agents/slm-memory-advisor.md +44 -0
  10. package/plugin/agents/slm-optimize-advisor.md +38 -0
  11. package/plugin/hooks/hooks.json +14 -0
  12. package/plugin/requirements.txt +1 -0
  13. package/plugin/scripts/ensure-venv.bat +122 -0
  14. package/plugin/scripts/ensure-venv.sh +105 -0
  15. package/plugin/scripts/slm-launch +15 -0
  16. package/plugin/scripts/slm-launch.bat +17 -0
  17. package/plugin/settings.json +16 -0
  18. package/plugin/skills/slm-cache/SKILL.md +140 -0
  19. package/plugin/skills/slm-compress/SKILL.md +143 -0
  20. package/plugin/skills/slm-graph/SKILL.md +300 -0
  21. package/plugin/skills/slm-recall/SKILL.md +204 -0
  22. package/plugin/skills/slm-remember/SKILL.md +194 -0
  23. package/plugin/skills/slm-session/SKILL.md +207 -0
  24. package/plugin/skills/slm-status/SKILL.md +149 -0
  25. package/plugin-src/.mcp.json +12 -0
  26. package/plugin-src/agents/slm-memory-advisor.md +44 -0
  27. package/plugin-src/agents/slm-optimize-advisor.md +38 -0
  28. package/plugin-src/commands/slm-optimize.md +22 -0
  29. package/plugin-src/commands/slm-recall.md +16 -0
  30. package/plugin-src/commands/slm-remember.md +16 -0
  31. package/plugin-src/commands/slm-status.md +15 -0
  32. package/plugin-src/hooks/.gitkeep +0 -0
  33. package/plugin-src/hooks/hooks.json +14 -0
  34. package/plugin-src/manifest.json +25 -0
  35. package/plugin-src/requirements.txt +1 -0
  36. package/plugin-src/rules/AGENTS.md +91 -0
  37. package/plugin-src/rules/CLAUDE.md.fragment +44 -0
  38. package/plugin-src/scripts/ensure-venv.bat +122 -0
  39. package/plugin-src/scripts/ensure-venv.sh +105 -0
  40. package/plugin-src/scripts/slm-launch +15 -0
  41. package/plugin-src/scripts/slm-launch.bat +17 -0
  42. package/plugin-src/settings.json +16 -0
  43. package/plugin-src/skills/slm-cache/SKILL.md +140 -0
  44. package/plugin-src/skills/slm-compress/SKILL.md +143 -0
  45. package/plugin-src/skills/slm-graph/SKILL.md +300 -0
  46. package/plugin-src/skills/slm-recall/SKILL.md +204 -0
  47. package/plugin-src/skills/slm-remember/SKILL.md +194 -0
  48. package/plugin-src/skills/slm-session/SKILL.md +207 -0
  49. package/plugin-src/skills/slm-status/SKILL.md +149 -0
  50. package/pyproject.toml +6 -2
  51. package/scripts/__tests__/build-plugin.test.mjs +613 -0
  52. package/scripts/_savings_math.py +270 -0
  53. package/scripts/build-plugin.js +742 -0
  54. package/scripts/dogfood_savings.py +490 -0
  55. package/scripts/install-skills.ps1 +4 -334
  56. package/scripts/install-skills.sh +4 -435
  57. package/scripts/postinstall-interactive.js +0 -27
  58. package/scripts/postinstall.js +21 -2
  59. package/src/superlocalmemory/__init__.py +1 -1
  60. package/src/superlocalmemory/cli/_lazy_init.py +115 -0
  61. package/src/superlocalmemory/cli/commands.py +439 -41
  62. package/src/superlocalmemory/cli/main.py +92 -4
  63. package/src/superlocalmemory/cli/setup_wizard.py +47 -6
  64. package/src/superlocalmemory/core/backend_orchestrator.py +12 -8
  65. package/src/superlocalmemory/core/config.py +194 -9
  66. package/src/superlocalmemory/core/embeddings.py +10 -5
  67. package/src/superlocalmemory/core/engine.py +76 -5
  68. package/src/superlocalmemory/core/fact_consolidator.py +20 -3
  69. package/src/superlocalmemory/core/platform_utils.py +8 -0
  70. package/src/superlocalmemory/core/recall_pipeline.py +7 -0
  71. package/src/superlocalmemory/core/recall_worker.py +7 -0
  72. package/src/superlocalmemory/core/store_pipeline.py +23 -1
  73. package/src/superlocalmemory/core/worker_pool.py +14 -2
  74. package/src/superlocalmemory/hooks/claude_code_hooks.py +27 -3
  75. package/src/superlocalmemory/hooks/portable_kit.py +506 -0
  76. package/src/superlocalmemory/hooks/session_registry.py +8 -4
  77. package/src/superlocalmemory/infra/cloud_backup.py +99 -23
  78. package/src/superlocalmemory/mcp/_daemon_proxy.py +12 -2
  79. package/src/superlocalmemory/mcp/_pool_adapter.py +15 -6
  80. package/src/superlocalmemory/mcp/cli_fallback.py +602 -0
  81. package/src/superlocalmemory/mcp/server.py +75 -4
  82. package/src/superlocalmemory/mcp/tools_code_graph.py +3 -3
  83. package/src/superlocalmemory/mcp/tools_core.py +37 -4
  84. package/src/superlocalmemory/mcp/tools_v3.py +6 -1
  85. package/src/superlocalmemory/mcp/tools_v33.py +8 -4
  86. package/src/superlocalmemory/optimize/cache/boundary_store.py +25 -6
  87. package/src/superlocalmemory/optimize/cache/centroid_store.py +27 -4
  88. package/src/superlocalmemory/optimize/cache/manager.py +92 -6
  89. package/src/superlocalmemory/optimize/cache/semantic.py +20 -1
  90. package/src/superlocalmemory/optimize/compress/ccr.py +12 -0
  91. package/src/superlocalmemory/optimize/compress/router.py +46 -13
  92. package/src/superlocalmemory/optimize/config/schema.py +6 -0
  93. package/src/superlocalmemory/optimize/proxy/_helpers.py +111 -8
  94. package/src/superlocalmemory/optimize/proxy/anthropic_surface.py +14 -4
  95. package/src/superlocalmemory/optimize/proxy/gemini_surface.py +23 -6
  96. package/src/superlocalmemory/optimize/proxy/openai_surface.py +10 -4
  97. package/src/superlocalmemory/optimize/proxy/server.py +11 -0
  98. package/src/superlocalmemory/optimize/proxy/vertex_surface.py +246 -0
  99. package/src/superlocalmemory/optimize/storage/db.py +30 -0
  100. package/src/superlocalmemory/retrieval/bm25_channel.py +12 -2
  101. package/src/superlocalmemory/retrieval/engine.py +36 -3
  102. package/src/superlocalmemory/retrieval/entity_channel.py +5 -5
  103. package/src/superlocalmemory/retrieval/hopfield_channel.py +10 -2
  104. package/src/superlocalmemory/retrieval/semantic_channel.py +10 -2
  105. package/src/superlocalmemory/server/recall_serializer.py +3 -1
  106. package/src/superlocalmemory/server/unified_daemon.py +156 -16
  107. package/src/superlocalmemory/storage/database.py +215 -43
  108. package/src/superlocalmemory/storage/migration_runner.py +17 -1
  109. package/src/superlocalmemory/storage/migrations/M016_add_scope_support.py +120 -0
  110. package/src/superlocalmemory/storage/models.py +10 -0
  111. package/src/superlocalmemory/storage/schema.py +15 -10
  112. package/src/superlocalmemory/ui/css/legacy-dashboard.css +18 -0
  113. package/src/superlocalmemory/ui/css/neural-glass.css +5 -0
  114. package/src/superlocalmemory/ui/index.html +2 -2
  115. package/src/superlocalmemory/ui/js/core.js +98 -0
  116. package/src/superlocalmemory/ui/js/dashboard.js +8 -1
  117. package/src/superlocalmemory/ui/js/ide-status.js +16 -3
  118. package/src/superlocalmemory/ui/js/math-health.js +15 -3
  119. package/src/superlocalmemory/ui/js/optimize.js +18 -2
  120. package/src/superlocalmemory/ui/js/trust-dashboard.js +10 -1
  121. package/src/superlocalmemory.egg-info/PKG-INFO +191 -741
  122. package/src/superlocalmemory.egg-info/SOURCES.txt +7 -9
  123. package/src/superlocalmemory.egg-info/requires.txt +1 -0
  124. package/ide/skills/slm-build-graph/SKILL.md +0 -423
  125. package/ide/skills/slm-list-recent/SKILL.md +0 -348
  126. package/ide/skills/slm-recall/SKILL.md +0 -326
  127. package/ide/skills/slm-remember/SKILL.md +0 -194
  128. package/ide/skills/slm-show-patterns/SKILL.md +0 -224
  129. package/ide/skills/slm-status/SKILL.md +0 -363
  130. package/ide/skills/slm-switch-profile/SKILL.md +0 -442
  131. package/skills/slm-build-graph/SKILL.md +0 -423
  132. package/skills/slm-list-recent/SKILL.md +0 -348
  133. package/skills/slm-optimize/README.md +0 -55
  134. package/skills/slm-optimize/SKILL.md +0 -139
  135. package/skills/slm-recall/SKILL.md +0 -343
  136. package/skills/slm-remember/SKILL.md +0 -194
  137. package/skills/slm-show-patterns/SKILL.md +0 -224
  138. package/skills/slm-status/SKILL.md +0 -363
  139. package/skills/slm-switch-profile/SKILL.md +0 -442
  140. package/src/superlocalmemory/cli/doctor_cmd.py +0 -152
  141. package/src/superlocalmemory/skills/slm-build-graph/SKILL.md +0 -423
  142. package/src/superlocalmemory/skills/slm-list-recent/SKILL.md +0 -348
  143. package/src/superlocalmemory/skills/slm-recall/SKILL.md +0 -343
  144. package/src/superlocalmemory/skills/slm-remember/SKILL.md +0 -194
  145. package/src/superlocalmemory/skills/slm-show-patterns/SKILL.md +0 -224
  146. package/src/superlocalmemory/skills/slm-status/SKILL.md +0 -363
  147. package/src/superlocalmemory/skills/slm-switch-profile/SKILL.md +0 -442
@@ -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(profile_id)
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(profile_id)
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 4 channels
163
- ch_results = self._run_channels(query, profile_id, strat)
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(needed, profile_id)
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(candidate_ids, profile_id)
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(profile_id, limit=5000)
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(candidate_ids, profile_id)
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(profile_id)
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 = 10,
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,