superlocalmemory 3.6.14 → 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/CHANGELOG.md +28 -0
- package/README.md +5 -2
- package/package.json +1 -1
- package/plugin/.claude-plugin/plugin.json +1 -1
- package/plugin/CLAUDE.md +5 -4
- package/plugin/agents/slm-memory-advisor.md +5 -4
- package/plugin/agents/slm-optimize-advisor.md +1 -1
- package/plugin/requirements.txt +1 -1
- package/plugin/skills/slm-cache/SKILL.md +1 -1
- package/plugin/skills/slm-compress/SKILL.md +1 -1
- package/plugin/skills/slm-graph/SKILL.md +1 -1
- package/plugin/skills/slm-recall/SKILL.md +10 -2
- package/plugin/skills/slm-remember/SKILL.md +14 -2
- package/plugin/skills/slm-session/SKILL.md +1 -1
- package/plugin/skills/slm-status/SKILL.md +1 -1
- package/plugin-src/agents/slm-memory-advisor.md +5 -4
- package/plugin-src/agents/slm-optimize-advisor.md +1 -1
- package/plugin-src/commands/slm-optimize.md +1 -1
- package/plugin-src/commands/slm-recall.md +1 -1
- package/plugin-src/commands/slm-remember.md +2 -2
- package/plugin-src/commands/slm-status.md +1 -1
- package/plugin-src/manifest.json +1 -1
- package/plugin-src/requirements.txt +1 -1
- package/plugin-src/rules/AGENTS.md +5 -4
- package/plugin-src/rules/CLAUDE.md.fragment +5 -4
- package/plugin-src/skills/slm-cache/SKILL.md +1 -1
- package/plugin-src/skills/slm-compress/SKILL.md +1 -1
- package/plugin-src/skills/slm-graph/SKILL.md +1 -1
- package/plugin-src/skills/slm-recall/SKILL.md +10 -2
- package/plugin-src/skills/slm-remember/SKILL.md +14 -2
- package/plugin-src/skills/slm-session/SKILL.md +1 -1
- package/plugin-src/skills/slm-status/SKILL.md +1 -1
- package/pyproject.toml +1 -1
- package/scripts/build-plugin.js +1 -1
- package/src/superlocalmemory/__init__.py +1 -1
- package/src/superlocalmemory/cli/commands.py +91 -2
- package/src/superlocalmemory/cli/main.py +45 -0
- package/src/superlocalmemory/cli/setup_wizard.py +27 -0
- package/src/superlocalmemory/core/backend_orchestrator.py +12 -8
- package/src/superlocalmemory/core/config.py +115 -0
- package/src/superlocalmemory/core/engine.py +74 -3
- 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/session_registry.py +8 -4
- package/src/superlocalmemory/mcp/_daemon_proxy.py +12 -2
- package/src/superlocalmemory/mcp/_pool_adapter.py +15 -6
- package/src/superlocalmemory/mcp/tools_core.py +25 -0
- package/src/superlocalmemory/mcp/tools_v3.py +6 -1
- package/src/superlocalmemory/mcp/tools_v33.py +8 -4
- 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/unified_daemon.py +132 -10
- 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.egg-info/PKG-INFO +6 -3
- 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
|
-
|
|
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:
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
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
|
-
|
|
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
|
-
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
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
|
-
|
|
294
|
+
# choose_pool().recall uses blocking urllib; run off the event loop
|
|
295
|
+
# so recall_trace doesn't stall the MCP server for other tools.
|
|
296
|
+
raw = await asyncio.to_thread(
|
|
297
|
+
lambda: choose_pool().recall(query=query, limit=limit)
|
|
298
|
+
)
|
|
294
299
|
items = raw.get("results", []) if isinstance(raw, dict) else []
|
|
295
300
|
results = []
|
|
296
301
|
for item in items[:limit]:
|
|
@@ -222,8 +222,10 @@ def register_v33_tools(server, get_engine: Callable) -> None:
|
|
|
222
222
|
# v3.4.26: prefer the daemon's /consolidate/cognitive endpoint
|
|
223
223
|
# so the heavy CognitiveConsolidator import stays out of the
|
|
224
224
|
# MCP process. Fall back to local import only if no daemon.
|
|
225
|
-
|
|
226
|
-
|
|
225
|
+
import asyncio
|
|
226
|
+
# blocking urllib (60s timeout) — keep it off the MCP event loop.
|
|
227
|
+
daemon_result = await asyncio.to_thread(
|
|
228
|
+
_try_daemon_post, "/consolidate/cognitive", {"profile_id": pid},
|
|
227
229
|
)
|
|
228
230
|
if daemon_result is not None:
|
|
229
231
|
_emit_event("ccq.consolidation_complete", {
|
|
@@ -434,8 +436,10 @@ def register_v33_tools(server, get_engine: Callable) -> None:
|
|
|
434
436
|
# v3.4.26: prefer the daemon so ForgettingScheduler /
|
|
435
437
|
# ConsolidationWorker / EbbinghausCurve don't load inside
|
|
436
438
|
# the MCP process.
|
|
437
|
-
|
|
438
|
-
|
|
439
|
+
import asyncio
|
|
440
|
+
# blocking urllib — keep it off the MCP event loop.
|
|
441
|
+
daemon_result = await asyncio.to_thread(
|
|
442
|
+
_try_daemon_post, "/maintenance/run", {"profile_id": pid},
|
|
439
443
|
)
|
|
440
444
|
if daemon_result is not None:
|
|
441
445
|
daemon_result.setdefault("success", True)
|
|
@@ -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:
|