superlocalmemory 3.4.19 → 3.4.21

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 (170) hide show
  1. package/CHANGELOG.md +24 -0
  2. package/README.md +42 -34
  3. package/bin/slm +11 -0
  4. package/bin/slm.bat +12 -0
  5. package/package.json +4 -3
  6. package/pyproject.toml +3 -2
  7. package/scripts/build-slm-hook.ps1 +40 -0
  8. package/scripts/build-slm-hook.sh +45 -0
  9. package/scripts/build_entry.py +452 -0
  10. package/scripts/ci/stage5b_gate.sh +50 -0
  11. package/scripts/postinstall/validation.js +187 -0
  12. package/scripts/postinstall-interactive.js +756 -0
  13. package/scripts/postinstall_binary.js +287 -0
  14. package/scripts/release_manifest.py +273 -0
  15. package/scripts/slm-hook.spec +56 -0
  16. package/skills/slm-build-graph/SKILL.md +423 -0
  17. package/skills/slm-list-recent/SKILL.md +348 -0
  18. package/skills/slm-recall/SKILL.md +343 -0
  19. package/skills/slm-remember/SKILL.md +194 -0
  20. package/skills/slm-show-patterns/SKILL.md +224 -0
  21. package/skills/slm-status/SKILL.md +363 -0
  22. package/skills/slm-switch-profile/SKILL.md +442 -0
  23. package/src/superlocalmemory/cli/commands.py +219 -79
  24. package/src/superlocalmemory/cli/context_commands.py +192 -0
  25. package/src/superlocalmemory/cli/daemon.py +15 -1
  26. package/src/superlocalmemory/cli/db_migrate.py +80 -0
  27. package/src/superlocalmemory/cli/escape_hatch.py +220 -0
  28. package/src/superlocalmemory/cli/main.py +72 -1
  29. package/src/superlocalmemory/core/context_cache.py +397 -0
  30. package/src/superlocalmemory/core/engine.py +38 -2
  31. package/src/superlocalmemory/core/engine_wiring.py +1 -1
  32. package/src/superlocalmemory/core/ram_lock.py +111 -0
  33. package/src/superlocalmemory/core/recall_pipeline.py +433 -3
  34. package/src/superlocalmemory/core/recall_worker.py +8 -3
  35. package/src/superlocalmemory/core/security_primitives.py +635 -0
  36. package/src/superlocalmemory/core/shadow_router.py +319 -0
  37. package/src/superlocalmemory/core/slm_disabled.py +87 -0
  38. package/src/superlocalmemory/core/slmignore.py +125 -0
  39. package/src/superlocalmemory/core/topic_signature.py +143 -0
  40. package/src/superlocalmemory/core/worker_pool.py +14 -3
  41. package/src/superlocalmemory/encoding/cognitive_consolidator.py +2 -2
  42. package/src/superlocalmemory/evolution/budget.py +321 -0
  43. package/src/superlocalmemory/evolution/llm_dispatch.py +508 -0
  44. package/src/superlocalmemory/evolution/skill_evolver.py +144 -94
  45. package/src/superlocalmemory/hooks/_outcome_common.py +506 -0
  46. package/src/superlocalmemory/hooks/adapter_base.py +317 -0
  47. package/src/superlocalmemory/hooks/antigravity_adapter.py +192 -0
  48. package/src/superlocalmemory/hooks/claude_code_hooks.py +33 -1
  49. package/src/superlocalmemory/hooks/context_payload.py +312 -0
  50. package/src/superlocalmemory/hooks/copilot_adapter.py +154 -0
  51. package/src/superlocalmemory/hooks/cross_platform_connector.py +90 -0
  52. package/src/superlocalmemory/hooks/cursor_adapter.py +195 -0
  53. package/src/superlocalmemory/hooks/hook_handlers.py +109 -8
  54. package/src/superlocalmemory/hooks/ide_connector.py +25 -2
  55. package/src/superlocalmemory/hooks/post_tool_async_hook.py +165 -0
  56. package/src/superlocalmemory/hooks/post_tool_outcome_hook.py +223 -0
  57. package/src/superlocalmemory/hooks/prewarm_auth.py +170 -0
  58. package/src/superlocalmemory/hooks/session_registry.py +186 -0
  59. package/src/superlocalmemory/hooks/stop_outcome_hook.py +134 -0
  60. package/src/superlocalmemory/hooks/sync_loop.py +114 -0
  61. package/src/superlocalmemory/hooks/user_prompt_hook.py +128 -0
  62. package/src/superlocalmemory/hooks/user_prompt_rehash_hook.py +202 -0
  63. package/src/superlocalmemory/infra/backup.py +3 -3
  64. package/src/superlocalmemory/infra/cloud_backup.py +2 -2
  65. package/src/superlocalmemory/infra/event_bus.py +2 -2
  66. package/src/superlocalmemory/infra/webhook_dispatcher.py +3 -3
  67. package/src/superlocalmemory/learning/arm_catalog.py +99 -0
  68. package/src/superlocalmemory/learning/bandit.py +526 -0
  69. package/src/superlocalmemory/learning/bandit_cache.py +133 -0
  70. package/src/superlocalmemory/learning/behavioral.py +53 -1
  71. package/src/superlocalmemory/learning/consolidation_cycle.py +381 -0
  72. package/src/superlocalmemory/learning/consolidation_worker.py +188 -520
  73. package/src/superlocalmemory/learning/database.py +256 -0
  74. package/src/superlocalmemory/learning/dedup_hnsw.py +413 -0
  75. package/src/superlocalmemory/learning/ensemble.py +300 -0
  76. package/src/superlocalmemory/learning/fact_outcome_joins.py +207 -0
  77. package/src/superlocalmemory/learning/forgetting_scheduler.py +55 -0
  78. package/src/superlocalmemory/learning/hnsw_dedup.py +69 -0
  79. package/src/superlocalmemory/learning/labeler.py +87 -0
  80. package/src/superlocalmemory/learning/legacy_migration.py +277 -0
  81. package/src/superlocalmemory/learning/memory_merge.py +160 -0
  82. package/src/superlocalmemory/learning/model_cache.py +269 -0
  83. package/src/superlocalmemory/learning/model_rollback.py +278 -0
  84. package/src/superlocalmemory/learning/outcome_queue.py +284 -0
  85. package/src/superlocalmemory/learning/pattern_miner.py +415 -0
  86. package/src/superlocalmemory/learning/pattern_miner_constants.py +47 -0
  87. package/src/superlocalmemory/learning/ranker.py +225 -81
  88. package/src/superlocalmemory/learning/ranker_common.py +163 -0
  89. package/src/superlocalmemory/learning/ranker_retrain_legacy.py +202 -0
  90. package/src/superlocalmemory/learning/ranker_retrain_online.py +411 -0
  91. package/src/superlocalmemory/learning/reward.py +777 -0
  92. package/src/superlocalmemory/learning/reward_archive.py +210 -0
  93. package/src/superlocalmemory/learning/reward_boost.py +201 -0
  94. package/src/superlocalmemory/learning/reward_proxy.py +326 -0
  95. package/src/superlocalmemory/learning/shadow_test.py +524 -0
  96. package/src/superlocalmemory/learning/signal_worker.py +270 -0
  97. package/src/superlocalmemory/learning/signals.py +314 -0
  98. package/src/superlocalmemory/learning/trigram_index.py +547 -0
  99. package/src/superlocalmemory/mcp/server.py +5 -5
  100. package/src/superlocalmemory/mcp/tools_context.py +183 -0
  101. package/src/superlocalmemory/mcp/tools_core.py +92 -27
  102. package/src/superlocalmemory/parameterization/soft_prompt_generator.py +13 -0
  103. package/src/superlocalmemory/retrieval/engine.py +52 -0
  104. package/src/superlocalmemory/server/api.py +2 -2
  105. package/src/superlocalmemory/server/bandit_loops.py +140 -0
  106. package/src/superlocalmemory/server/middleware/__init__.py +11 -0
  107. package/src/superlocalmemory/server/middleware/security_headers.py +144 -0
  108. package/src/superlocalmemory/server/routes/backup.py +36 -13
  109. package/src/superlocalmemory/server/routes/behavioral.py +50 -19
  110. package/src/superlocalmemory/server/routes/brain.py +1234 -0
  111. package/src/superlocalmemory/server/routes/data_io.py +4 -4
  112. package/src/superlocalmemory/server/routes/events.py +2 -2
  113. package/src/superlocalmemory/server/routes/helpers.py +1 -1
  114. package/src/superlocalmemory/server/routes/learning.py +192 -7
  115. package/src/superlocalmemory/server/routes/memories.py +189 -1
  116. package/src/superlocalmemory/server/routes/prewarm.py +171 -0
  117. package/src/superlocalmemory/server/routes/profiles.py +3 -3
  118. package/src/superlocalmemory/server/routes/token.py +88 -0
  119. package/src/superlocalmemory/server/routes/ws.py +5 -5
  120. package/src/superlocalmemory/server/security_middleware.py +13 -7
  121. package/src/superlocalmemory/server/ui.py +2 -2
  122. package/src/superlocalmemory/server/unified_daemon.py +335 -3
  123. package/src/superlocalmemory/storage/migration_runner.py +545 -0
  124. package/src/superlocalmemory/storage/migrations/M001_add_signal_features_columns.py +67 -0
  125. package/src/superlocalmemory/storage/migrations/M002_model_state_history.py +132 -0
  126. package/src/superlocalmemory/storage/migrations/M003_migration_log.py +38 -0
  127. package/src/superlocalmemory/storage/migrations/M004_cross_platform_sync_log.py +46 -0
  128. package/src/superlocalmemory/storage/migrations/M005_bandit_tables.py +75 -0
  129. package/src/superlocalmemory/storage/migrations/M006_action_outcomes_reward.py +75 -0
  130. package/src/superlocalmemory/storage/migrations/M007_pending_outcomes.py +63 -0
  131. package/src/superlocalmemory/storage/migrations/M009_model_lineage.py +54 -0
  132. package/src/superlocalmemory/storage/migrations/M010_evolution_config.py +75 -0
  133. package/src/superlocalmemory/storage/migrations/M011_archive_and_merge.py +87 -0
  134. package/src/superlocalmemory/storage/migrations/M012_shadow_observations.py +72 -0
  135. package/src/superlocalmemory/storage/migrations/M013_bi_temporal_columns.py +55 -0
  136. package/src/superlocalmemory/storage/migrations/__init__.py +81 -0
  137. package/src/superlocalmemory/storage/models.py +4 -0
  138. package/src/superlocalmemory/ui/css/brain.css +409 -0
  139. package/src/superlocalmemory/ui/css/legacy-dashboard.css +645 -0
  140. package/src/superlocalmemory/ui/index.html +459 -1345
  141. package/src/superlocalmemory/ui/js/brain.js +1321 -0
  142. package/src/superlocalmemory/ui/js/clusters.js +123 -4
  143. package/src/superlocalmemory/ui/js/init.js +48 -39
  144. package/src/superlocalmemory/ui/js/memories.js +88 -2
  145. package/src/superlocalmemory/ui/js/modal.js +71 -1
  146. package/src/superlocalmemory/ui/js/ng-shell.js +101 -88
  147. package/src/superlocalmemory/ui/js/trust-dashboard.js +168 -25
  148. package/src/superlocalmemory/ui/vendor/bootstrap-icons/bootstrap-icons.css +2018 -0
  149. package/src/superlocalmemory/ui/vendor/bootstrap-icons/fonts/bootstrap-icons.woff +0 -0
  150. package/src/superlocalmemory/ui/vendor/bootstrap-icons/fonts/bootstrap-icons.woff2 +0 -0
  151. package/src/superlocalmemory/ui/vendor/bootstrap.bundle.min.js +7 -0
  152. package/src/superlocalmemory/ui/vendor/bootstrap.min.css +6 -0
  153. package/src/superlocalmemory/ui/vendor/d3.v7.min.js +2 -0
  154. package/src/superlocalmemory/ui/vendor/graphology-library.min.js +2 -0
  155. package/src/superlocalmemory/ui/vendor/graphology.umd.min.js +2 -0
  156. package/src/superlocalmemory/ui/vendor/inter-ui/inter-variable.min.css +8 -0
  157. package/src/superlocalmemory/ui/vendor/inter-ui/variable/InterVariable-Italic.woff2 +0 -0
  158. package/src/superlocalmemory/ui/vendor/inter-ui/variable/InterVariable.woff2 +0 -0
  159. package/src/superlocalmemory/ui/vendor/sigma.min.js +1 -0
  160. package/src/superlocalmemory/ui/js/behavioral.js +0 -447
  161. package/src/superlocalmemory/ui/js/graph-core.js +0 -447
  162. package/src/superlocalmemory/ui/js/graph-interactions.js +0 -351
  163. package/src/superlocalmemory/ui/js/learning.js +0 -435
  164. package/src/superlocalmemory/ui/js/patterns.js +0 -93
  165. package/src/superlocalmemory.egg-info/PKG-INFO +0 -647
  166. package/src/superlocalmemory.egg-info/SOURCES.txt +0 -335
  167. package/src/superlocalmemory.egg-info/dependency_links.txt +0 -1
  168. package/src/superlocalmemory.egg-info/entry_points.txt +0 -2
  169. package/src/superlocalmemory.egg-info/requires.txt +0 -58
  170. package/src/superlocalmemory.egg-info/top_level.txt +0 -1
@@ -0,0 +1,183 @@
1
+ # Copyright (c) 2026 Varun Pratap Bhardwaj / Qualixar
2
+ # Licensed under AGPL-3.0-or-later - see LICENSE file
3
+ # Part of SuperLocalMemory v3.4.21 — LLD-05 §7
4
+
5
+ """MCP proactive-context tool — ``prestage_context``.
6
+
7
+ LLD-05 §7. Exposes a single MCP tool that returns top-K redacted memories
8
+ for a given query. Guardrails:
9
+ - Rate limit 30 calls / minute (token bucket; A11).
10
+ - Every returned text passes through ``redact_secrets`` (A9).
11
+ - JSON response size bound ≤ 16 KB.
12
+ """
13
+
14
+ from __future__ import annotations
15
+
16
+ import json
17
+ import logging
18
+ import time
19
+ from dataclasses import dataclass, field
20
+ from datetime import datetime, timezone
21
+ from threading import Lock
22
+ from typing import Callable
23
+
24
+ from superlocalmemory.core.security_primitives import redact_secrets
25
+
26
+ logger = logging.getLogger(__name__)
27
+
28
+ MAX_CALLS_PER_MINUTE = 30
29
+ MAX_RESPONSE_BYTES = 16 * 1024 # 16 KB
30
+ WINDOW_SECONDS = 60.0
31
+
32
+
33
+ @dataclass
34
+ class _RateLimiter:
35
+ """Simple fixed-window rate limiter. Thread-safe.
36
+
37
+ Keyed by session id. Clock is injectable for deterministic tests.
38
+ """
39
+ max_calls: int = MAX_CALLS_PER_MINUTE
40
+ window: float = WINDOW_SECONDS
41
+ now_fn: Callable[[], float] = time.monotonic
42
+ _buckets: dict[str, tuple[float, int]] = field(default_factory=dict)
43
+ _lock: Lock = field(default_factory=Lock)
44
+
45
+ def allow(self, key: str) -> bool:
46
+ with self._lock:
47
+ now = self.now_fn()
48
+ start, count = self._buckets.get(key, (now, 0))
49
+ if now - start >= self.window:
50
+ # Reset window.
51
+ self._buckets[key] = (now, 1)
52
+ return True
53
+ if count >= self.max_calls:
54
+ return False
55
+ self._buckets[key] = (start, count + 1)
56
+ return True
57
+
58
+ def reset(self) -> None:
59
+ with self._lock:
60
+ self._buckets.clear()
61
+
62
+
63
+ _DEFAULT_LIMITER = _RateLimiter()
64
+
65
+
66
+ # RecallFn for the tool. Callers inject a real recall engine; tests inject fakes.
67
+ PrestageRecallFn = Callable[[str, int, str], list[dict]]
68
+
69
+
70
+ def _iso_now() -> str:
71
+ return datetime.now(timezone.utc).isoformat()
72
+
73
+
74
+ def _cap_memory(memory: dict, *, max_text_bytes: int = 2048) -> dict:
75
+ """Ensure each memory is bounded and redacted."""
76
+ text = memory.get("text", "")
77
+ if not isinstance(text, str):
78
+ text = str(text)
79
+ text = redact_secrets(text)
80
+ if len(text.encode("utf-8")) > max_text_bytes:
81
+ text = text.encode("utf-8")[:max_text_bytes].decode("utf-8", "ignore")
82
+ score = float(memory.get("score", 0.0) or 0.0)
83
+ return {
84
+ "id": str(memory.get("id", "")),
85
+ "text": text,
86
+ "score": score,
87
+ "source": str(memory.get("source", "recall")),
88
+ }
89
+
90
+
91
+ def prestage_context(
92
+ query: str,
93
+ *,
94
+ limit: int = 5,
95
+ profile_id: str = "default",
96
+ session_id: str = "default",
97
+ recall_fn: PrestageRecallFn,
98
+ limiter: _RateLimiter | None = None,
99
+ ) -> dict:
100
+ """Proactive-context tool body.
101
+
102
+ Pure function: takes an injected recall_fn + limiter. The MCP server
103
+ wrapper in the session process wires the real engine and shares a
104
+ single limiter instance.
105
+ """
106
+ _limiter = limiter or _DEFAULT_LIMITER
107
+ if not _limiter.allow(session_id):
108
+ return {
109
+ "error": "rate_limit_exceeded",
110
+ "memories": [],
111
+ "generated_at": _iso_now(),
112
+ "limit": limit,
113
+ "truncated_count": 0,
114
+ }
115
+
116
+ if not isinstance(query, str) or not query.strip():
117
+ return {
118
+ "error": "empty_query",
119
+ "memories": [],
120
+ "generated_at": _iso_now(),
121
+ "limit": limit,
122
+ "truncated_count": 0,
123
+ }
124
+ limit = max(1, min(int(limit), 50))
125
+
126
+ try:
127
+ raw = recall_fn(query, limit, profile_id) or []
128
+ except Exception as exc:
129
+ logger.warning("prestage_context recall failed: %s", exc)
130
+ return {
131
+ "error": "recall_error",
132
+ "memories": [],
133
+ "generated_at": _iso_now(),
134
+ "limit": limit,
135
+ "truncated_count": 0,
136
+ }
137
+
138
+ capped = [_cap_memory(m) for m in raw if isinstance(m, dict)]
139
+ capped = capped[:limit]
140
+
141
+ # Enforce total response size cap (A11/16 KB).
142
+ response = {
143
+ "memories": capped,
144
+ "generated_at": _iso_now(),
145
+ "limit": limit,
146
+ "truncated_count": 0,
147
+ }
148
+ encoded = json.dumps(response).encode("utf-8")
149
+ truncated = 0
150
+ while len(encoded) > MAX_RESPONSE_BYTES and response["memories"]:
151
+ response["memories"].pop()
152
+ truncated += 1
153
+ response["truncated_count"] = truncated
154
+ encoded = json.dumps(response).encode("utf-8")
155
+ return response
156
+
157
+
158
+ def register_prestage_tool(server, recall_fn: PrestageRecallFn,
159
+ *, session_id_fn: Callable[[], str] | None = None
160
+ ) -> None:
161
+ """Register the ``prestage_context`` tool on an MCP server."""
162
+ limiter = _RateLimiter()
163
+
164
+ @server.tool()
165
+ async def prestage_context_tool( # pragma: no cover — MCP wiring
166
+ query: str,
167
+ limit: int = 5,
168
+ profile_id: str = "default",
169
+ ) -> dict:
170
+ """Proactively return top-K memories for a query."""
171
+ session_id = session_id_fn() if session_id_fn else "default"
172
+ return prestage_context(
173
+ query, limit=limit, profile_id=profile_id,
174
+ session_id=session_id, recall_fn=recall_fn, limiter=limiter,
175
+ )
176
+
177
+
178
+ __all__ = (
179
+ "MAX_CALLS_PER_MINUTE",
180
+ "MAX_RESPONSE_BYTES",
181
+ "prestage_context",
182
+ "register_prestage_tool",
183
+ )
@@ -35,45 +35,59 @@ def _emit_event(event_type: str, payload: dict | None = None,
35
35
  pass
36
36
 
37
37
 
38
- def _record_recall_hits(get_engine: Callable, query: str, results: list[dict]) -> None:
39
- """Record implicit feedback + learning signals for each recall.
40
-
41
- Non-blocking, non-critical — failures silently ignored.
42
- Feeds: FeedbackCollector + Co-Retrieval + Confidence Boost.
38
+ def _record_recall_hits(
39
+ get_engine: Callable,
40
+ query: str,
41
+ results: list[dict],
42
+ *,
43
+ query_id: str = "",
44
+ fact_ids_candidates: list[str] | None = None,
45
+ ) -> None:
46
+ """Record honest shown-state signals (LLD-02 §4.9).
47
+
48
+ v3.4.21: No more fake positives. For every candidate we enqueue a
49
+ ``shown`` / ``not_shown`` flip based on whether it was returned in the
50
+ top-K presented to the user. Outcome/reward arrives in v3.4.21 via the
51
+ action-outcomes pipeline.
52
+
53
+ Non-blocking: all work funnels through ``signals.enqueue_shown_flip``
54
+ (module-level queue + background drain). Failures are swallowed —
55
+ signal quality is never load-bearing on recall correctness.
43
56
  """
44
57
  try:
45
58
  from pathlib import Path
59
+ from superlocalmemory.learning.signals import (
60
+ LearningSignals,
61
+ enqueue_shown_flip,
62
+ )
63
+
46
64
  engine = get_engine()
47
65
  pid = engine.profile_id
48
66
  slm_dir = Path.home() / ".superlocalmemory"
49
- fact_ids = [r.get("fact_id", "") for r in results[:10] if r.get("fact_id")]
50
- if not fact_ids:
67
+
68
+ shown_ids = [r.get("fact_id", "") for r in results[:10]
69
+ if r.get("fact_id")]
70
+ candidates = (fact_ids_candidates
71
+ if fact_ids_candidates is not None
72
+ else shown_ids)
73
+ if not candidates:
51
74
  return
52
75
 
53
- # 1. Implicit feedback (recall_hit signals for adaptive learner)
54
- try:
55
- from superlocalmemory.learning.feedback import FeedbackCollector
56
- collector = FeedbackCollector(slm_dir / "learning.db")
57
- collector.record_implicit(
58
- profile_id=pid, query=query,
59
- fact_ids_returned=fact_ids, fact_ids_available=fact_ids,
60
- )
61
- except Exception:
62
- pass
76
+ # Shown-flip enqueue per §4.9. No synthetic positives.
77
+ shown_set = set(shown_ids)
78
+ if query_id:
79
+ for fid in candidates:
80
+ enqueue_shown_flip(query_id, fid, shown=(fid in shown_set))
63
81
 
64
- # 2. Co-retrieval signals (strengthen implicit graph edges)
82
+ # Legacy zero-cost signals — unchanged (co-retrieval + confidence).
65
83
  try:
66
- from superlocalmemory.learning.signals import LearningSignals
67
84
  signals = LearningSignals(slm_dir / "learning.db")
68
- signals.record_co_retrieval(pid, fact_ids)
85
+ signals.record_co_retrieval(pid, shown_ids)
69
86
  except Exception:
70
87
  pass
71
-
72
- # 3. Confidence boost (accessed facts get +0.02, cap 1.0)
73
88
  try:
74
- from superlocalmemory.learning.signals import LearningSignals
75
89
  mem_db = str(slm_dir / "memory.db")
76
- for fid in fact_ids[:5]:
90
+ for fid in shown_ids[:5]:
77
91
  LearningSignals.boost_confidence(mem_db, fid)
78
92
  except Exception:
79
93
  pass
@@ -150,14 +164,65 @@ def register_core_tools(server, get_engine: Callable) -> None:
150
164
  return {"success": False, "error": str(exc)}
151
165
 
152
166
  @server.tool()
153
- async def recall(query: str, limit: int = 10, agent_id: str = "mcp_client") -> dict:
154
- """Search memories by semantic query with 4-channel retrieval, RRF fusion, and reranking."""
167
+ async def recall(
168
+ query: str, limit: int = 10, agent_id: str = "mcp_client",
169
+ session_id: str = "",
170
+ ) -> dict:
171
+ """Search memories by semantic query with 4-channel retrieval, RRF fusion, and reranking.
172
+
173
+ S9-DASH-02: optional ``session_id`` threads through to the
174
+ engine's outcome-queue so PostToolUse / Stop hooks can attach
175
+ engagement signals to this recall. Claude Code should pass its
176
+ ``CLAUDE_SESSION_ID``. Omitting it degrades to "no closed-loop
177
+ learning for this recall" — the recall itself always works.
178
+ """
155
179
  import asyncio
156
180
  try:
157
181
  from superlocalmemory.core.worker_pool import WorkerPool
158
182
  pool = WorkerPool.shared()
183
+ # S9-DASH-10: priority for session_id, so engagement
184
+ # signals land on the right pending_outcome:
185
+ # 1. Explicit ``session_id`` tool-call argument.
186
+ # 2. ``SLM_SESSION_ID`` / ``CLAUDE_SESSION_ID`` env var.
187
+ # 3. Most-recent-active Claude session from the hook
188
+ # registry (last 60s). This catches the common case
189
+ # where Claude Code's hooks ran the UserPromptSubmit
190
+ # hook right before invoking the MCP tool.
191
+ # 4. Stable per-agent fallback ``mcp:<agent_id>`` — the
192
+ # Stop hook will NOT match this, so the reaper
193
+ # settles it at neutral 0.5.
194
+ effective_sid = session_id
195
+ if not effective_sid:
196
+ import os as _os
197
+ effective_sid = (
198
+ _os.environ.get("SLM_SESSION_ID")
199
+ or _os.environ.get("CLAUDE_SESSION_ID")
200
+ or ""
201
+ )
202
+ if not effective_sid:
203
+ try:
204
+ from superlocalmemory.hooks.session_registry import (
205
+ lookup_by_parent,
206
+ most_recent_active,
207
+ )
208
+ # Parent-PID lookup is collision-free across multiple
209
+ # parallel Claude sessions (each MCP server's parent
210
+ # is the IDE that spawned it).
211
+ effective_sid = (
212
+ lookup_by_parent(within_seconds=60)
213
+ or most_recent_active(
214
+ agent_type="claude", within_seconds=60,
215
+ )
216
+ or ""
217
+ )
218
+ except Exception:
219
+ pass
220
+ if not effective_sid:
221
+ effective_sid = f"mcp:{agent_id}"
159
222
  # V3.3.19: Run in thread pool to avoid blocking MCP event loop
160
- result = await asyncio.to_thread(pool.recall, query, limit=limit)
223
+ result = await asyncio.to_thread(
224
+ pool.recall, query, limit=limit, session_id=effective_sid,
225
+ )
161
226
  if result.get("ok"):
162
227
  # Record implicit feedback: every returned result is a recall_hit
163
228
  try:
@@ -123,12 +123,18 @@ class SoftPromptGenerator:
123
123
  self,
124
124
  patterns: list[PatternAssertion],
125
125
  profile_id: str,
126
+ *,
127
+ high_reward_source_ids: set[str] | None = None,
126
128
  ) -> list[SoftPromptTemplate]:
127
129
  """Master generation pipeline: filter, group, render, budget-trim.
128
130
 
129
131
  Args:
130
132
  patterns: Extracted pattern assertions.
131
133
  profile_id: Target profile.
134
+ high_reward_source_ids: Optional v3.4.21 (LLD-12 §6) filter —
135
+ when provided, only patterns whose source_ids intersect
136
+ this set are considered. When None (default), behaviour
137
+ matches pre-v3.4.21 and every pattern flows through.
132
138
 
133
139
  Returns:
134
140
  List of SoftPromptTemplate, ordered by category priority,
@@ -140,6 +146,13 @@ class SoftPromptGenerator:
140
146
  p for p in patterns if p.category.value in enabled
141
147
  ]
142
148
 
149
+ # v3.4.21 (LLD-12 §6): reward-aware filter — opt-in only.
150
+ if high_reward_source_ids is not None:
151
+ filtered = [
152
+ p for p in filtered
153
+ if set(p.source_ids) & high_reward_source_ids
154
+ ]
155
+
143
156
  # Group by category
144
157
  grouped: dict[str, list[PatternAssertion]] = defaultdict(list)
145
158
  for p in filtered:
@@ -705,3 +705,55 @@ class RetrievalEngine:
705
705
  trust_score=raw_trust,
706
706
  ))
707
707
  return results
708
+
709
+
710
+ # ---------------------------------------------------------------------------
711
+ # apply_channel_weights (LLD-03 §5.5 — module-level pure helper)
712
+ # ---------------------------------------------------------------------------
713
+
714
+
715
+ _CHANNEL_KEYS: tuple[str, ...] = (
716
+ "semantic", "bm25", "entity_graph", "temporal",
717
+ )
718
+
719
+
720
+ def apply_channel_weights(
721
+ candidates: list[RetrievalResult],
722
+ weights: dict[str, float] | None,
723
+ ) -> list[RetrievalResult]:
724
+ """Re-score candidates under a bandit-chosen weight bundle.
725
+
726
+ Multiplies each candidate's ``channel_scores[ch]`` by ``weights[ch]``
727
+ and applies ``cross_encoder_bias`` to the final score. Preserves order;
728
+ callers reorder via ensemble_rerank.
729
+
730
+ Returns a NEW list with new ``RetrievalResult`` instances — never mutates
731
+ input. Unknown / missing weights default to 1.0.
732
+
733
+ Safe against ``weights=None`` (returns input unchanged) and empty lists.
734
+ """
735
+ if not candidates or not weights:
736
+ return list(candidates)
737
+
738
+ ce_bias = float(weights.get("cross_encoder_bias", 1.0))
739
+ out: list[RetrievalResult] = []
740
+ for c in candidates:
741
+ original_cs = c.channel_scores or {}
742
+ new_cs: dict[str, float] = dict(original_cs)
743
+ base = 0.0
744
+ for ch in _CHANNEL_KEYS:
745
+ raw = float(original_cs.get(ch, 0.0))
746
+ w = float(weights.get(ch, 1.0))
747
+ scaled = raw * w
748
+ new_cs[ch] = scaled
749
+ base += scaled
750
+ new_score = (base if base > 0.0 else float(c.score)) * ce_bias
751
+ out.append(RetrievalResult(
752
+ fact=c.fact,
753
+ score=new_score,
754
+ channel_scores=new_cs,
755
+ confidence=c.confidence,
756
+ evidence_chain=c.evidence_chain,
757
+ trust_score=c.trust_score,
758
+ ))
759
+ return out
@@ -219,14 +219,14 @@ def create_app() -> FastAPI:
219
219
  @application.get("/health")
220
220
  async def health_check():
221
221
  """Health check."""
222
- from datetime import datetime
222
+ from datetime import datetime, timezone
223
223
  engine = application.state.engine
224
224
  return {
225
225
  "status": "healthy",
226
226
  "version": SLM_VERSION,
227
227
  "engine": "initialized" if engine else "unavailable",
228
228
  "database": "connected" if DB_PATH.exists() else "missing",
229
- "timestamp": datetime.now().isoformat(),
229
+ "timestamp": datetime.now(timezone.utc).isoformat(),
230
230
  }
231
231
 
232
232
  @application.on_event("startup")
@@ -0,0 +1,140 @@
1
+ # Copyright (c) 2026 Varun Pratap Bhardwaj / Qualixar
2
+ # Licensed under AGPL-3.0-or-later - see LICENSE file
3
+ # Part of SuperLocalMemory v3.4.21 — LLD-03 §3.5 + §3.6
4
+
5
+ """Background schedulers for the v3.4.21 contextual bandit.
6
+
7
+ Two asyncio tasks, both registered in the daemon lifespan:
8
+
9
+ 1. Reward-proxy settler — every 60 s (``SLM_BANDIT_REWARD_WINDOW_SEC``),
10
+ calls ``reward_proxy.settle_stale_plays`` for the configured profile(s).
11
+ 2. Retention sweep — every 24 h
12
+ (``SLM_BANDIT_PLAYS_RETENTION_INTERVAL_SEC``), calls
13
+ ``bandit.retention_sweep`` with the configured horizon
14
+ (``SLM_BANDIT_PLAYS_RETENTION_DAYS``, default 7).
15
+
16
+ Both honour ``SLM_BANDIT_DISABLED=1`` (caller checks before scheduling).
17
+ """
18
+
19
+ from __future__ import annotations
20
+
21
+ import asyncio
22
+ import logging
23
+ import os
24
+ from pathlib import Path
25
+ from typing import Any
26
+
27
+ logger = logging.getLogger(__name__)
28
+
29
+ _REWARD_INTERVAL = float(
30
+ os.environ.get("SLM_BANDIT_REWARD_WINDOW_SEC", "60"),
31
+ )
32
+ _RETENTION_INTERVAL = float(
33
+ os.environ.get("SLM_BANDIT_PLAYS_RETENTION_INTERVAL_SEC", "86400"),
34
+ )
35
+ _RETENTION_DAYS = int(
36
+ os.environ.get("SLM_BANDIT_PLAYS_RETENTION_DAYS", "7"),
37
+ )
38
+
39
+
40
+ def _learning_db(config: Any) -> Path:
41
+ if config is not None:
42
+ cand = getattr(config, "learning_db_path", None)
43
+ if cand is not None:
44
+ return Path(cand)
45
+ return Path.home() / ".superlocalmemory" / "learning.db"
46
+
47
+
48
+ def _memory_db(config: Any) -> Path:
49
+ if config is not None:
50
+ cand = getattr(config, "db_path", None)
51
+ if cand is not None:
52
+ return Path(cand)
53
+ return Path.home() / ".superlocalmemory" / "memory.db"
54
+
55
+
56
+ def _profile_id(config: Any) -> str:
57
+ if config is not None:
58
+ pid = getattr(config, "default_profile", None)
59
+ if isinstance(pid, str) and pid:
60
+ return pid
61
+ return "default"
62
+
63
+
64
+ async def _reward_proxy_loop(
65
+ learning_db: Path, memory_db: Path, profile_id: str,
66
+ interval_sec: float,
67
+ ) -> None:
68
+ """Run the proxy settler on a steady interval. Never raises."""
69
+ from superlocalmemory.learning.reward_proxy import settle_stale_plays
70
+
71
+ while True:
72
+ try:
73
+ await asyncio.sleep(interval_sec)
74
+ # The settler is synchronous + fast; run in a thread to avoid
75
+ # blocking the event loop on unusual DB lock stalls.
76
+ n = await asyncio.to_thread(
77
+ settle_stale_plays,
78
+ profile_id, learning_db, memory_db,
79
+ )
80
+ if n:
81
+ logger.debug("bandit.reward_proxy settled=%d", n)
82
+ except asyncio.CancelledError: # pragma: no cover — lifecycle
83
+ raise
84
+ except Exception as exc: # pragma: no cover — defensive
85
+ logger.warning("bandit.reward_proxy loop: %s", exc)
86
+
87
+
88
+ async def _retention_loop(
89
+ learning_db: Path, interval_sec: float, retention_days: int,
90
+ ) -> None:
91
+ """Run retention_sweep on a 24h cadence. Never raises."""
92
+ from superlocalmemory.learning.bandit import retention_sweep
93
+
94
+ while True:
95
+ try:
96
+ await asyncio.sleep(interval_sec)
97
+ deleted = await asyncio.to_thread(
98
+ retention_sweep, learning_db, retention_days,
99
+ )
100
+ logger.info(
101
+ "bandit_plays_retention_sweep tick: deleted=%d", deleted,
102
+ )
103
+ except asyncio.CancelledError: # pragma: no cover — lifecycle
104
+ raise
105
+ except Exception as exc: # pragma: no cover — defensive
106
+ logger.warning("bandit.retention loop: %s", exc)
107
+
108
+
109
+ def schedule_bandit_loops(application: Any, config: Any) -> None:
110
+ """Register both background tasks with the FastAPI app state.
111
+
112
+ Tasks are stored on ``application.state.bandit_tasks`` so the daemon's
113
+ shutdown path can cancel them cleanly (if added).
114
+ """
115
+ learning = _learning_db(config)
116
+ memory = _memory_db(config)
117
+ profile = _profile_id(config)
118
+
119
+ try:
120
+ loop = asyncio.get_event_loop()
121
+ except RuntimeError: # pragma: no cover — defensive
122
+ return
123
+
124
+ tasks = []
125
+ tasks.append(loop.create_task(
126
+ _reward_proxy_loop(learning, memory, profile, _REWARD_INTERVAL),
127
+ ))
128
+ tasks.append(loop.create_task(
129
+ _retention_loop(learning, _RETENTION_INTERVAL, _RETENTION_DAYS),
130
+ ))
131
+ if hasattr(application, "state"):
132
+ application.state.bandit_tasks = tasks
133
+ logger.info(
134
+ "bandit loops scheduled: reward=%.0fs, retention=%.0fs, "
135
+ "retention_days=%d",
136
+ _REWARD_INTERVAL, _RETENTION_INTERVAL, _RETENTION_DAYS,
137
+ )
138
+
139
+
140
+ __all__ = ("schedule_bandit_loops",)
@@ -0,0 +1,11 @@
1
+ # Copyright (c) 2026 Varun Pratap Bhardwaj / Qualixar
2
+ # Licensed under AGPL-3.0-or-later - see LICENSE file
3
+ # Part of SuperLocalMemory v3.4.21 — LLD-04 §4.2
4
+
5
+ """FastAPI middleware — strict security headers for the Brain UI (LLD-04 v2).
6
+
7
+ The existing ``server/security_middleware.py`` is kept for legacy routes
8
+ that still rely on permissive CSP (``'unsafe-inline'`` + CDNs). The
9
+ middleware in this subpackage enforces the v3.4.21 policy: no inline
10
+ scripts / styles, no nonces, no CDN sources.
11
+ """