superlocalmemory 3.6.14 → 3.6.16

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 (66) hide show
  1. package/CHANGELOG.md +28 -0
  2. package/README.md +23 -6
  3. package/package.json +1 -1
  4. package/plugin/.claude-plugin/plugin.json +1 -1
  5. package/plugin/CLAUDE.md +5 -4
  6. package/plugin/agents/slm-memory-advisor.md +5 -4
  7. package/plugin/agents/slm-optimize-advisor.md +1 -1
  8. package/plugin/requirements.txt +1 -1
  9. package/plugin/skills/slm-cache/SKILL.md +1 -1
  10. package/plugin/skills/slm-compress/SKILL.md +1 -1
  11. package/plugin/skills/slm-graph/SKILL.md +1 -1
  12. package/plugin/skills/slm-recall/SKILL.md +10 -2
  13. package/plugin/skills/slm-remember/SKILL.md +14 -2
  14. package/plugin/skills/slm-session/SKILL.md +1 -1
  15. package/plugin/skills/slm-status/SKILL.md +1 -1
  16. package/plugin-src/agents/slm-memory-advisor.md +5 -4
  17. package/plugin-src/agents/slm-optimize-advisor.md +1 -1
  18. package/plugin-src/commands/slm-optimize.md +1 -1
  19. package/plugin-src/commands/slm-recall.md +1 -1
  20. package/plugin-src/commands/slm-remember.md +2 -2
  21. package/plugin-src/commands/slm-status.md +1 -1
  22. package/plugin-src/manifest.json +1 -1
  23. package/plugin-src/requirements.txt +1 -1
  24. package/plugin-src/rules/AGENTS.md +5 -4
  25. package/plugin-src/rules/CLAUDE.md.fragment +5 -4
  26. package/plugin-src/skills/slm-cache/SKILL.md +1 -1
  27. package/plugin-src/skills/slm-compress/SKILL.md +1 -1
  28. package/plugin-src/skills/slm-graph/SKILL.md +1 -1
  29. package/plugin-src/skills/slm-recall/SKILL.md +10 -2
  30. package/plugin-src/skills/slm-remember/SKILL.md +14 -2
  31. package/plugin-src/skills/slm-session/SKILL.md +1 -1
  32. package/plugin-src/skills/slm-status/SKILL.md +1 -1
  33. package/pyproject.toml +1 -1
  34. package/scripts/build-plugin.js +1 -1
  35. package/src/superlocalmemory/__init__.py +1 -1
  36. package/src/superlocalmemory/cli/commands.py +91 -2
  37. package/src/superlocalmemory/cli/main.py +45 -0
  38. package/src/superlocalmemory/cli/setup_wizard.py +27 -0
  39. package/src/superlocalmemory/core/backend_orchestrator.py +12 -8
  40. package/src/superlocalmemory/core/config.py +115 -0
  41. package/src/superlocalmemory/core/engine.py +74 -3
  42. package/src/superlocalmemory/core/fact_consolidator.py +20 -3
  43. package/src/superlocalmemory/core/platform_utils.py +8 -0
  44. package/src/superlocalmemory/core/recall_pipeline.py +7 -0
  45. package/src/superlocalmemory/core/recall_worker.py +7 -0
  46. package/src/superlocalmemory/core/store_pipeline.py +23 -1
  47. package/src/superlocalmemory/core/worker_pool.py +14 -2
  48. package/src/superlocalmemory/hooks/session_registry.py +8 -4
  49. package/src/superlocalmemory/mcp/_daemon_proxy.py +12 -2
  50. package/src/superlocalmemory/mcp/_pool_adapter.py +15 -6
  51. package/src/superlocalmemory/mcp/tools_core.py +25 -0
  52. package/src/superlocalmemory/mcp/tools_v3.py +6 -1
  53. package/src/superlocalmemory/mcp/tools_v33.py +8 -4
  54. package/src/superlocalmemory/retrieval/bm25_channel.py +12 -2
  55. package/src/superlocalmemory/retrieval/engine.py +36 -3
  56. package/src/superlocalmemory/retrieval/entity_channel.py +5 -5
  57. package/src/superlocalmemory/retrieval/hopfield_channel.py +10 -2
  58. package/src/superlocalmemory/retrieval/semantic_channel.py +10 -2
  59. package/src/superlocalmemory/server/unified_daemon.py +132 -10
  60. package/src/superlocalmemory/storage/database.py +215 -43
  61. package/src/superlocalmemory/storage/migration_runner.py +17 -1
  62. package/src/superlocalmemory/storage/migrations/M016_add_scope_support.py +120 -0
  63. package/src/superlocalmemory/storage/models.py +10 -0
  64. package/src/superlocalmemory/storage/schema.py +15 -10
  65. package/src/superlocalmemory.egg-info/PKG-INFO +24 -7
  66. package/src/superlocalmemory.egg-info/SOURCES.txt +1 -0
@@ -587,9 +587,14 @@ def run_recall(
587
587
  access_log: Any = None,
588
588
  auto_linker: Any = None,
589
589
  fast: bool = False,
590
+ include_global: bool = False,
591
+ include_shared: bool = False,
590
592
  ) -> RecallResponse:
591
593
  """Recall relevant facts for a query.
592
594
 
595
+ Multi-scope: ``include_global`` / ``include_shared`` control which
596
+ scopes participate in retrieval (passed through to retrieval engine).
597
+
593
598
  Pipeline: retrieval -> agentic sufficiency (if configured) -> post-recall updates.
594
599
 
595
600
  V3.4.40: ``fast=True`` adds spreading_activation to the per-recall
@@ -623,6 +628,8 @@ def run_recall(
623
628
  response = retrieval_engine.recall(
624
629
  query, profile_id, m, limit,
625
630
  extra_disabled_channels=extra_disabled,
631
+ include_global=include_global,
632
+ include_shared=include_shared,
626
633
  )
627
634
  _mark("retrieval(chan+rerank)")
628
635
 
@@ -61,10 +61,15 @@ def _get_engine():
61
61
 
62
62
  def _handle_recall(
63
63
  query: str, limit: int, session_id: str = "", fast: bool = False,
64
+ include_global: bool | None = None, include_shared: bool | None = None,
64
65
  ) -> dict:
65
66
  engine = _get_engine()
67
+ # v3.6.15 multi-scope: None flags let engine.recall resolve the configured
68
+ # default (shared-off). The subprocess loads its own SLMConfig, so the
69
+ # resolution is identical to the in-process / daemon paths.
66
70
  response = engine.recall(
67
71
  query, limit=limit, session_id=session_id or None, fast=bool(fast),
72
+ include_global=include_global, include_shared=include_shared,
68
73
  )
69
74
 
70
75
  # Batch-fetch original memory text for all results
@@ -285,6 +290,8 @@ def _worker_main() -> None:
285
290
  result = _handle_recall(
286
291
  req.get("query", ""), req.get("limit", 10),
287
292
  req.get("session_id", ""), bool(req.get("fast", False)),
293
+ include_global=req.get("include_global"),
294
+ include_shared=req.get("include_shared"),
288
295
  )
289
296
  _respond(result)
290
297
  elif cmd == "store":
@@ -99,6 +99,17 @@ def enrich_fact(
99
99
  langevin_position=langevin_pos,
100
100
  emotional_valence=emotion.valence, emotional_arousal=emotion.arousal,
101
101
  signal_type=signal, created_at=fact.created_at,
102
+ pinned=getattr(fact, 'pinned', False),
103
+ # v3.6.15 multi-scope: scope is a per-MEMORY property — every fact
104
+ # derived from a memory inherits the memory's scope. The record is
105
+ # authoritative; fact-extractor output never carries scope, so reading
106
+ # it off the fact (as before) silently downgraded extracted facts to
107
+ # 'personal' and broke `--scope global` on the common extraction path.
108
+ scope=(getattr(record, 'scope', None)
109
+ or getattr(fact, 'scope', None) or 'personal'),
110
+ shared_with=(getattr(record, 'shared_with', None)
111
+ if getattr(record, 'scope', None) in ('shared', 'global')
112
+ else getattr(fact, 'shared_with', None)),
102
113
  )
103
114
 
104
115
 
@@ -146,6 +157,8 @@ def run_store(
146
157
  role: str = "user",
147
158
  metadata: dict[str, Any] | None = None,
148
159
  *,
160
+ scope: str = "personal",
161
+ shared_with: list[str] | None = None,
149
162
  config: SLMConfig,
150
163
  db: DatabaseManager,
151
164
  embedder: Any,
@@ -169,7 +182,11 @@ def run_store(
169
182
  context_generator: Any = None,
170
183
  consolidation_engine: Any = None,
171
184
  ) -> list[str]:
172
- """Store content and extract structured facts. Returns fact_ids."""
185
+ """Store content and extract structured facts. Returns fact_ids.
186
+
187
+ Multi-scope: ``scope`` sets visibility (personal/shared/global).
188
+ ``shared_with`` is a list of profile_ids for shared scope.
189
+ """
173
190
  # Pre-operation hooks (trust gate, ABAC, rate limiter)
174
191
  hook_ctx = {
175
192
  "operation": "store",
@@ -203,6 +220,7 @@ def run_store(
203
220
  profile_id=profile_id, content=content,
204
221
  session_id=session_id, speaker=speaker, role=role,
205
222
  session_date=parsed_date, metadata=metadata or {},
223
+ scope=scope, shared_with=shared_with,
206
224
  )
207
225
  db.store_memory(record)
208
226
 
@@ -259,6 +277,8 @@ def run_store(
259
277
  observation_date=parsed_date,
260
278
  confidence=0.9,
261
279
  importance=0.5,
280
+ scope=scope,
281
+ shared_with=shared_with,
262
282
  )
263
283
  # Avoid duplicate if extraction already produced the exact same text
264
284
  extracted_texts = {f.content.strip().lower() for f in facts}
@@ -280,6 +300,8 @@ def run_store(
280
300
  observation_date=parsed_date,
281
301
  confidence=0.7,
282
302
  importance=0.3,
303
+ scope=scope,
304
+ shared_with=shared_with,
283
305
  )]
284
306
 
285
307
  if not facts:
@@ -68,6 +68,8 @@ class WorkerPool:
68
68
  def recall(
69
69
  self, query: str, limit: int = 10, session_id: str = "",
70
70
  fast: bool = False,
71
+ include_global: bool | None = None,
72
+ include_shared: bool | None = None,
71
73
  ) -> dict:
72
74
  """Run recall in worker subprocess. Returns result dict.
73
75
 
@@ -75,11 +77,21 @@ class WorkerPool:
75
77
  so the outcome-queue gets a pending_outcomes row for this
76
78
  recall. Without it, hook-based signals have no outcome to
77
79
  attach to.
80
+
81
+ v3.6.15 multi-scope: ``include_global``/``include_shared`` are forwarded
82
+ to the worker (and on to ``engine.recall``). ``None`` is sent verbatim
83
+ so the worker-side engine resolves the configured default — shared
84
+ memory is opt-in.
78
85
  """
79
- return self._send({
86
+ msg = {
80
87
  "cmd": "recall", "query": query, "limit": limit,
81
88
  "session_id": session_id or "", "fast": bool(fast),
82
- })
89
+ }
90
+ if include_global is not None:
91
+ msg["include_global"] = bool(include_global)
92
+ if include_shared is not None:
93
+ msg["include_shared"] = bool(include_shared)
94
+ return self._send(msg)
83
95
 
84
96
  def store(self, content: str, metadata: dict | None = None) -> dict:
85
97
  """Run store in worker subprocess. Returns result dict."""
@@ -28,10 +28,14 @@ lost (reaper finalizes everything at neutral 0.5).
28
28
  MCP uses this as the default when the tool caller omits
29
29
  ``session_id``.
30
30
 
31
- Concurrency: one reader/writer lock (``fcntl.flock``) serialises
32
- updates. Rollover: entries older than 1 hour are pruned on every
33
- write. Fail-soft: every error path returns empty or the passed
34
- default the learning loop must never crash the hot path.
31
+ Concurrency: each write is atomic via write-temp + ``os.replace`` (atomic on
32
+ POSIX/Windows), so a concurrent reader never sees a half-written file
33
+ last-writer-wins. This is best-effort, not lock-serialised: a concurrent
34
+ read-modify-write may lose an interleaved update, which is acceptable because
35
+ the registry only drives session attribution for closed-loop learning, not
36
+ memory correctness. Rollover: entries older than 1 hour are pruned on every
37
+ write. Fail-soft: every error path returns empty or the passed default — the
38
+ learning loop must never crash the hot path.
35
39
 
36
40
  This is not a perfect correlation channel; two Claude sessions
37
41
  typing in the same second can race. For single-user workstations
@@ -46,13 +46,23 @@ class DaemonPoolProxy:
46
46
  def recall(
47
47
  self, query: str, limit: int = 10, session_id: str = "",
48
48
  fast: bool = False,
49
+ include_global: bool | None = None,
50
+ include_shared: bool | None = None,
49
51
  ) -> dict[str, Any]:
50
- params = urllib.parse.urlencode({
52
+ _params: dict[str, Any] = {
51
53
  "q": query,
52
54
  "limit": limit,
53
55
  "session_id": session_id or "",
54
56
  "fast": "true" if fast else "false",
55
- })
57
+ }
58
+ # v3.6.15 multi-scope: only send the scope flags when explicitly set, so
59
+ # an unset value lets the daemon resolve the configured default (shared
60
+ # is opt-in). "None" must NOT become the string "none" on the wire.
61
+ if include_global is not None:
62
+ _params["include_global"] = "true" if include_global else "false"
63
+ if include_shared is not None:
64
+ _params["include_shared"] = "true" if include_shared else "false"
65
+ params = urllib.parse.urlencode(_params)
56
66
  try:
57
67
  with urllib.request.urlopen(
58
68
  self._url(f"/recall?{params}"), timeout=self._timeout,
@@ -80,12 +80,21 @@ def pool_recall(query: str, limit: int = 10, **kwargs: Any) -> PoolRecallRespons
80
80
 
81
81
  Raises :class:`PoolError` on worker death or any non-ok envelope.
82
82
  """
83
- raw = _pool().recall(
84
- query=query,
85
- limit=limit,
86
- session_id=str(kwargs.get("session_id") or ""),
87
- fast=bool(kwargs.get("fast", False)),
88
- )
83
+ # v3.6.15 multi-scope: forward the scope-visibility flags when the caller
84
+ # set them. ``None`` (the default) is passed through so the daemon/engine
85
+ # resolves the configured default — shared memory is opt-in, so omitting
86
+ # them keeps recall scoped to this profile only.
87
+ _recall_kwargs: dict[str, Any] = {
88
+ "query": query,
89
+ "limit": limit,
90
+ "session_id": str(kwargs.get("session_id") or ""),
91
+ "fast": bool(kwargs.get("fast", False)),
92
+ }
93
+ if "include_global" in kwargs:
94
+ _recall_kwargs["include_global"] = kwargs["include_global"]
95
+ if "include_shared" in kwargs:
96
+ _recall_kwargs["include_shared"] = kwargs["include_shared"]
97
+ raw = _pool().recall(**_recall_kwargs)
89
98
  _unwrap_error(raw, "recall")
90
99
  items = raw.get("results", []) if isinstance(raw, dict) else []
91
100
  results = [
@@ -106,11 +106,17 @@ def register_core_tools(server, get_engine: Callable) -> None:
106
106
  content: str, tags: str = "", project: str = "",
107
107
  importance: int = 5, session_id: str = "",
108
108
  agent_id: str = "mcp_client",
109
+ scope: str | None = None,
110
+ shared_with: str = "",
109
111
  ) -> dict:
110
112
  """Store content to memory with intelligent indexing.
111
113
 
112
114
  Extracts atomic facts, resolves entities, builds graph edges,
113
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.
114
120
  """
115
121
  # v3.6.10: resolve "mcp_client" sentinel → URL path (HTTP) or env var (stdio)
116
122
  if agent_id == "mcp_client":
@@ -122,6 +128,8 @@ def register_core_tools(server, get_engine: Callable) -> None:
122
128
  "agent_id": agent_id,
123
129
  "session_id": session_id,
124
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
125
133
  # v3.5.5 WRITE-THROUGH: route through the daemon's /remember, which does
126
134
  # a synchronous verbatim insert (memory is keyword/BM25-recallable the
127
135
  # instant this returns) and enqueues async enrichment. This closes the
@@ -136,6 +144,7 @@ def register_core_tools(server, get_engine: Callable) -> None:
136
144
  if await _asyncio.to_thread(is_daemon_running):
137
145
  resp = await _asyncio.to_thread(daemon_request, "POST", "/remember", {
138
146
  "content": content, "tags": tags, "metadata": meta,
147
+ "scope": scope, "shared_with": _shared_list,
139
148
  })
140
149
  if resp and (resp.get("fact_ids") is not None or resp.get("ok")):
141
150
  fids = resp.get("fact_ids") or []
@@ -151,6 +160,13 @@ def register_core_tools(server, get_engine: Callable) -> None:
151
160
 
152
161
  try:
153
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
154
170
  pending_id = store_pending(content, tags=tags, metadata=meta)
155
171
  return {
156
172
  "success": True,
@@ -168,6 +184,8 @@ def register_core_tools(server, get_engine: Callable) -> None:
168
184
  async def recall(
169
185
  query: str, limit: int = CANONICAL_RECALL_LIMIT, agent_id: str = "mcp_client",
170
186
  session_id: str = "", fast: bool = False,
187
+ include_global: bool | None = None,
188
+ include_shared: bool | None = None,
171
189
  ) -> dict:
172
190
  """Search memories by semantic query with 4-channel retrieval, RRF fusion, and reranking.
173
191
 
@@ -176,6 +194,11 @@ def register_core_tools(server, get_engine: Callable) -> None:
176
194
  engagement signals to this recall. Claude Code should pass its
177
195
  ``CLAUDE_SESSION_ID``. Omitting it degrades to "no closed-loop
178
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.
179
202
  """
180
203
  # v3.6.10: resolve "mcp_client" sentinel → URL path (HTTP) or env var (stdio)
181
204
  if agent_id == "mcp_client":
@@ -230,6 +253,8 @@ def register_core_tools(server, get_engine: Callable) -> None:
230
253
  result = await asyncio.to_thread(
231
254
  pool.recall, query, limit=limit, session_id=effective_sid,
232
255
  fast=bool(fast),
256
+ include_global=include_global,
257
+ include_shared=include_shared,
233
258
  )
234
259
  if result.get("ok"):
235
260
  # Record implicit feedback: every returned result is a recall_hit
@@ -289,8 +289,13 @@ def register_v3_tools(server, get_engine: Callable) -> None:
289
289
  limit: Maximum results (default 10).
290
290
  """
291
291
  try:
292
+ import asyncio
292
293
  from superlocalmemory.mcp._daemon_proxy import choose_pool
293
- raw = choose_pool().recall(query=query, limit=limit)
294
+ # choose_pool().recall uses blocking urllib; run off the event loop
295
+ # so recall_trace doesn't stall the MCP server for other tools.
296
+ raw = await asyncio.to_thread(
297
+ lambda: choose_pool().recall(query=query, limit=limit)
298
+ )
294
299
  items = raw.get("results", []) if isinstance(raw, dict) else []
295
300
  results = []
296
301
  for item in items[:limit]:
@@ -222,8 +222,10 @@ def register_v33_tools(server, get_engine: Callable) -> None:
222
222
  # v3.4.26: prefer the daemon's /consolidate/cognitive endpoint
223
223
  # so the heavy CognitiveConsolidator import stays out of the
224
224
  # MCP process. Fall back to local import only if no daemon.
225
- daemon_result = _try_daemon_post(
226
- "/consolidate/cognitive", {"profile_id": pid},
225
+ import asyncio
226
+ # blocking urllib (60s timeout) — keep it off the MCP event loop.
227
+ daemon_result = await asyncio.to_thread(
228
+ _try_daemon_post, "/consolidate/cognitive", {"profile_id": pid},
227
229
  )
228
230
  if daemon_result is not None:
229
231
  _emit_event("ccq.consolidation_complete", {
@@ -434,8 +436,10 @@ def register_v33_tools(server, get_engine: Callable) -> None:
434
436
  # v3.4.26: prefer the daemon so ForgettingScheduler /
435
437
  # ConsolidationWorker / EbbinghausCurve don't load inside
436
438
  # the MCP process.
437
- daemon_result = _try_daemon_post(
438
- "/maintenance/run", {"profile_id": pid},
439
+ import asyncio
440
+ # blocking urllib — keep it off the MCP event loop.
441
+ daemon_result = await asyncio.to_thread(
442
+ _try_daemon_post, "/maintenance/run", {"profile_id": pid},
439
443
  )
440
444
  if daemon_result is not None:
441
445
  daemon_result.setdefault("success", True)
@@ -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: