superlocalmemory 3.7.8 → 3.8.1

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 (280) hide show
  1. package/.claude-plugin/marketplace.json +1 -1
  2. package/ATTRIBUTION.md +1 -3
  3. package/CHANGELOG.md +129 -0
  4. package/README.md +205 -123
  5. package/package.json +12 -3
  6. package/plugin/.claude-plugin/plugin.json +2 -3
  7. package/plugin/CLAUDE.md +8 -8
  8. package/plugin/agents/slm-governance-advisor.md +80 -0
  9. package/plugin/agents/slm-loop-runner.md +71 -0
  10. package/plugin/agents/slm-memory-advisor.md +10 -5
  11. package/plugin/agents/slm-optimize-advisor.md +9 -3
  12. package/plugin/commands/slm-loop.md +31 -0
  13. package/plugin/hooks/hooks.json +79 -0
  14. package/plugin/requirements.txt +1 -1
  15. package/plugin/scripts/slm-launch +46 -7
  16. package/plugin/settings.json +9 -0
  17. package/plugin/skills/slm-cache/SKILL.md +9 -1
  18. package/plugin/skills/slm-compress/SKILL.md +8 -1
  19. package/plugin/skills/slm-governance/SKILL.md +248 -0
  20. package/plugin/skills/slm-graph/SKILL.md +17 -3
  21. package/plugin/skills/slm-loop/SKILL.md +99 -0
  22. package/plugin/skills/slm-mesh/SKILL.md +282 -0
  23. package/plugin/skills/slm-profile/SKILL.md +148 -0
  24. package/plugin/skills/slm-recall/SKILL.md +46 -10
  25. package/plugin/skills/slm-remember/SKILL.md +48 -1
  26. package/plugin/skills/slm-scope/SKILL.md +176 -0
  27. package/plugin/skills/slm-session/SKILL.md +24 -1
  28. package/plugin/skills/slm-status/SKILL.md +18 -1
  29. package/plugin-src/rules/AGENTS.md +57 -18
  30. package/plugin-src/skills/slm-cache/SKILL.md +9 -1
  31. package/plugin-src/skills/slm-compress/SKILL.md +8 -1
  32. package/plugin-src/skills/slm-graph/SKILL.md +17 -3
  33. package/plugin-src/skills/slm-recall/SKILL.md +46 -10
  34. package/plugin-src/skills/slm-remember/SKILL.md +48 -1
  35. package/plugin-src/skills/slm-session/SKILL.md +24 -1
  36. package/plugin-src/skills/slm-status/SKILL.md +18 -1
  37. package/pyproject.toml +2 -1
  38. package/scripts/postinstall/validation.js +2 -0
  39. package/scripts/postinstall-interactive.js +74 -2
  40. package/src/superlocalmemory/__init__.py +1 -1
  41. package/src/superlocalmemory/access/__init__.py +3 -0
  42. package/src/superlocalmemory/access/rbac.py +477 -0
  43. package/src/superlocalmemory/cli/commands.py +228 -17
  44. package/src/superlocalmemory/cli/compress_cmd.py +17 -7
  45. package/src/superlocalmemory/cli/daemon.py +7 -0
  46. package/src/superlocalmemory/cli/loop_cmd.py +187 -0
  47. package/src/superlocalmemory/cli/main.py +49 -8
  48. package/src/superlocalmemory/cli/mesh_cmd.py +38 -0
  49. package/src/superlocalmemory/cli/optimize_cmd.py +3 -0
  50. package/src/superlocalmemory/cli/pending_store.py +49 -13
  51. package/src/superlocalmemory/cli/proxy_cmd.py +4 -0
  52. package/src/superlocalmemory/cli/scale_engine_cmd.py +6 -0
  53. package/src/superlocalmemory/cli/setup_wizard.py +22 -13
  54. package/src/superlocalmemory/cli/version_banner.py +17 -3
  55. package/src/superlocalmemory/compliance/audit.py +6 -0
  56. package/src/superlocalmemory/compliance/gdpr.py +128 -138
  57. package/src/superlocalmemory/compliance/retention.py +176 -45
  58. package/src/superlocalmemory/core/backend_orchestrator.py +23 -59
  59. package/src/superlocalmemory/core/community_summary.py +267 -0
  60. package/src/superlocalmemory/core/config.py +216 -3
  61. package/src/superlocalmemory/core/consolidation_engine.py +95 -22
  62. package/src/superlocalmemory/core/context_cache.py +61 -18
  63. package/src/superlocalmemory/core/embedding_worker.py +21 -7
  64. package/src/superlocalmemory/core/embeddings.py +131 -46
  65. package/src/superlocalmemory/core/engine.py +41 -22
  66. package/src/superlocalmemory/core/engine_ingestion.py +359 -43
  67. package/src/superlocalmemory/core/engine_wiring.py +13 -0
  68. package/src/superlocalmemory/core/entity_community.py +178 -0
  69. package/src/superlocalmemory/core/graph_analyzer.py +39 -2
  70. package/src/superlocalmemory/core/graph_pruner.py +13 -8
  71. package/src/superlocalmemory/core/ingestion_command.py +134 -25
  72. package/src/superlocalmemory/core/injection.py +12 -7
  73. package/src/superlocalmemory/core/key_expander.py +138 -0
  74. package/src/superlocalmemory/core/maintenance.py +23 -0
  75. package/src/superlocalmemory/core/maintenance_scheduler.py +17 -7
  76. package/src/superlocalmemory/core/modes.py +1 -1
  77. package/src/superlocalmemory/core/mutations.py +2 -2
  78. package/src/superlocalmemory/core/pii.py +105 -0
  79. package/src/superlocalmemory/core/progressive_abstraction.py +208 -0
  80. package/src/superlocalmemory/core/recall_pipeline.py +7 -3
  81. package/src/superlocalmemory/core/recall_worker.py +20 -6
  82. package/src/superlocalmemory/core/scale_engine.py +60 -1
  83. package/src/superlocalmemory/core/security_primitives.py +40 -2
  84. package/src/superlocalmemory/core/store_pipeline.py +186 -29
  85. package/src/superlocalmemory/core/worker_pool.py +21 -6
  86. package/src/superlocalmemory/encoding/entity_reflexion.py +200 -0
  87. package/src/superlocalmemory/encoding/entity_resolver.py +34 -24
  88. package/src/superlocalmemory/encoding/fact_extractor.py +26 -1
  89. package/src/superlocalmemory/encoding/temporal_validator.py +64 -1
  90. package/src/superlocalmemory/evolution/evolution_store.py +122 -45
  91. package/src/superlocalmemory/evolution/llm_dispatch.py +12 -1
  92. package/src/superlocalmemory/evolution/model_selection.py +160 -0
  93. package/src/superlocalmemory/evolution/mutation_generator.py +16 -0
  94. package/src/superlocalmemory/evolution/skill_evolver.py +127 -42
  95. package/src/superlocalmemory/evolution/triggers.py +22 -13
  96. package/src/superlocalmemory/graph/cozo_backend.py +43 -20
  97. package/src/superlocalmemory/hooks/adapter_base.py +5 -1
  98. package/src/superlocalmemory/hooks/auto_recall.py +13 -1
  99. package/src/superlocalmemory/hooks/claude_code_hooks.py +11 -0
  100. package/src/superlocalmemory/hooks/codex_assets.py +64 -5
  101. package/src/superlocalmemory/hooks/hook_daemon.py +20 -3
  102. package/src/superlocalmemory/hooks/hook_handlers.py +6 -1
  103. package/src/superlocalmemory/hooks/memory_protocol.py +54 -0
  104. package/src/superlocalmemory/hooks/portable_kit.py +148 -3
  105. package/src/superlocalmemory/infra/backup.py +12 -1
  106. package/src/superlocalmemory/infra/daemon_identity.py +40 -4
  107. package/src/superlocalmemory/infra/data_root.py +43 -4
  108. package/src/superlocalmemory/infra/event_bus.py +107 -24
  109. package/src/superlocalmemory/infra/rate_limiter.py +93 -0
  110. package/src/superlocalmemory/ingestion/adapter_manager.py +4 -1
  111. package/src/superlocalmemory/ingestion/credentials.py +1 -1
  112. package/src/superlocalmemory/learning/cross_project.py +28 -19
  113. package/src/superlocalmemory/learning/model_rollback.py +3 -0
  114. package/src/superlocalmemory/learning/ranker_retrain_online.py +2 -0
  115. package/src/superlocalmemory/learning/reward.py +50 -0
  116. package/src/superlocalmemory/learning/reward_proxy.py +42 -9
  117. package/src/superlocalmemory/learning/source_quality.py +523 -1
  118. package/src/superlocalmemory/loops/__init__.py +56 -0
  119. package/src/superlocalmemory/loops/budget.py +58 -0
  120. package/src/superlocalmemory/loops/engine.py +164 -0
  121. package/src/superlocalmemory/loops/ledger.py +263 -0
  122. package/src/superlocalmemory/loops/models.py +152 -0
  123. package/src/superlocalmemory/loops/rules.py +52 -0
  124. package/src/superlocalmemory/mcp/_daemon_proxy.py +3 -0
  125. package/src/superlocalmemory/mcp/_pool_adapter.py +2 -0
  126. package/src/superlocalmemory/mcp/profiles.py +103 -0
  127. package/src/superlocalmemory/mcp/server.py +32 -79
  128. package/src/superlocalmemory/mcp/tools_active.py +4 -7
  129. package/src/superlocalmemory/mcp/tools_code_graph.py +51 -5
  130. package/src/superlocalmemory/mcp/tools_core.py +12 -4
  131. package/src/superlocalmemory/mcp/tools_evolution.py +6 -3
  132. package/src/superlocalmemory/mcp/tools_learning.py +2 -2
  133. package/src/superlocalmemory/mcp/tools_loops.py +300 -0
  134. package/src/superlocalmemory/mcp/tools_mesh.py +140 -4
  135. package/src/superlocalmemory/mcp/tools_optimize.py +15 -8
  136. package/src/superlocalmemory/mesh/broker.py +237 -129
  137. package/src/superlocalmemory/mesh/remote_sync.py +50 -8
  138. package/src/superlocalmemory/optimize/NOTICE +1 -6
  139. package/src/superlocalmemory/optimize/adapters/anthropic_adapter.py +1 -4
  140. package/src/superlocalmemory/optimize/adapters/openai_adapter.py +1 -4
  141. package/src/superlocalmemory/optimize/cache/semantic.py +27 -19
  142. package/src/superlocalmemory/optimize/compress/align.py +32 -26
  143. package/src/superlocalmemory/optimize/compress/ccr.py +14 -71
  144. package/src/superlocalmemory/optimize/compress/router.py +105 -22
  145. package/src/superlocalmemory/optimize/config/defaults.py +1 -1
  146. package/src/superlocalmemory/optimize/config/schema.py +87 -4
  147. package/src/superlocalmemory/optimize/metrics/counters.py +13 -4
  148. package/src/superlocalmemory/optimize/metrics/estimator.py +0 -3
  149. package/src/superlocalmemory/optimize/proxy/_helpers.py +31 -4
  150. package/src/superlocalmemory/optimize/storage/db.py +38 -9
  151. package/src/superlocalmemory/optimize/storage/schema.py +10 -0
  152. package/src/superlocalmemory/parameterization/pattern_extractor.py +6 -3
  153. package/src/superlocalmemory/retrieval/agentic.py +1 -1
  154. package/src/superlocalmemory/retrieval/bm25_channel.py +68 -10
  155. package/src/superlocalmemory/retrieval/engine.py +221 -47
  156. package/src/superlocalmemory/retrieval/entity_channel.py +7 -5
  157. package/src/superlocalmemory/retrieval/hopfield_channel.py +9 -2
  158. package/src/superlocalmemory/retrieval/reranker.py +3 -4
  159. package/src/superlocalmemory/retrieval/semantic_channel.py +114 -21
  160. package/src/superlocalmemory/retrieval/spreading_activation.py +11 -2
  161. package/src/superlocalmemory/retrieval/temporal_channel.py +48 -9
  162. package/src/superlocalmemory/retrieval/temporal_frame.py +102 -0
  163. package/src/superlocalmemory/retrieval/temporal_validity_filter.py +135 -0
  164. package/src/superlocalmemory/retrieval/time_window.py +181 -0
  165. package/src/superlocalmemory/server/api.py +4 -4
  166. package/src/superlocalmemory/server/config_file.py +90 -0
  167. package/src/superlocalmemory/server/origin.py +50 -0
  168. package/src/superlocalmemory/server/profile_runtime.py +125 -8
  169. package/src/superlocalmemory/server/rbac_enforce.py +142 -0
  170. package/src/superlocalmemory/server/recall_health.py +24 -3
  171. package/src/superlocalmemory/server/recall_serializer.py +19 -1
  172. package/src/superlocalmemory/server/routes/abstraction.py +115 -0
  173. package/src/superlocalmemory/server/routes/agents.py +128 -38
  174. package/src/superlocalmemory/server/routes/backup.py +317 -70
  175. package/src/superlocalmemory/server/routes/behavioral.py +349 -71
  176. package/src/superlocalmemory/server/routes/brain.py +69 -12
  177. package/src/superlocalmemory/server/routes/chat.py +10 -5
  178. package/src/superlocalmemory/server/routes/compliance.py +171 -21
  179. package/src/superlocalmemory/server/routes/config_api.py +438 -0
  180. package/src/superlocalmemory/server/routes/data_io.py +30 -8
  181. package/src/superlocalmemory/server/routes/entity.py +108 -26
  182. package/src/superlocalmemory/server/routes/events.py +24 -8
  183. package/src/superlocalmemory/server/routes/evolution.py +189 -68
  184. package/src/superlocalmemory/server/routes/helpers.py +16 -1
  185. package/src/superlocalmemory/server/routes/ingest.py +7 -4
  186. package/src/superlocalmemory/server/routes/insights.py +3 -3
  187. package/src/superlocalmemory/server/routes/learning.py +289 -118
  188. package/src/superlocalmemory/server/routes/learning_telemetry.py +153 -0
  189. package/src/superlocalmemory/server/routes/lifecycle.py +59 -8
  190. package/src/superlocalmemory/server/routes/memories.py +182 -57
  191. package/src/superlocalmemory/server/routes/mesh.py +200 -31
  192. package/src/superlocalmemory/server/routes/optimize.py +33 -1
  193. package/src/superlocalmemory/server/routes/prewarm.py +2 -0
  194. package/src/superlocalmemory/server/routes/profiles.py +63 -17
  195. package/src/superlocalmemory/server/routes/ratelimit.py +132 -0
  196. package/src/superlocalmemory/server/routes/rbac.py +367 -0
  197. package/src/superlocalmemory/server/routes/stats.py +103 -158
  198. package/src/superlocalmemory/server/routes/tiers.py +11 -9
  199. package/src/superlocalmemory/server/routes/token.py +3 -13
  200. package/src/superlocalmemory/server/routes/v3_api.py +247 -89
  201. package/src/superlocalmemory/server/routes/ws.py +5 -2
  202. package/src/superlocalmemory/server/security_middleware.py +12 -5
  203. package/src/superlocalmemory/server/ui.py +20 -5
  204. package/src/superlocalmemory/server/unified_daemon.py +827 -72
  205. package/src/superlocalmemory/server/write_identity.py +38 -8
  206. package/src/superlocalmemory/storage/database.py +265 -53
  207. package/src/superlocalmemory/storage/migration_runner.py +132 -1
  208. package/src/superlocalmemory/storage/migrations/M010_evolution_config.py +5 -0
  209. package/src/superlocalmemory/storage/migrations/M021_ingestion_log_profile.py +108 -0
  210. package/src/superlocalmemory/storage/migrations/M022_entity_aliases_profile.py +86 -0
  211. package/src/superlocalmemory/storage/migrations/M023_mesh_profile_isolation.py +194 -0
  212. package/src/superlocalmemory/storage/migrations/M024_rbac_users_roles.py +87 -0
  213. package/src/superlocalmemory/storage/migrations/M025_perf_indexes.py +90 -0
  214. package/src/superlocalmemory/storage/migrations/M026_rbac_memberships_fk.py +136 -0
  215. package/src/superlocalmemory/storage/migrations/M027_transferable_patterns_profile.py +163 -0
  216. package/src/superlocalmemory/storage/migrations/M028_fact_entity_associations.py +270 -0
  217. package/src/superlocalmemory/storage/migrations/M029_behavioral_history_indexes.py +137 -0
  218. package/src/superlocalmemory/storage/migrations/M030_entity_explorer_indexes.py +93 -0
  219. package/src/superlocalmemory/storage/migrations/__init__.py +4 -0
  220. package/src/superlocalmemory/storage/models.py +4 -0
  221. package/src/superlocalmemory/storage/schema.py +136 -1
  222. package/src/superlocalmemory/storage/schema_v32.py +2 -0
  223. package/src/superlocalmemory/storage/schema_v343.py +24 -12
  224. package/src/superlocalmemory/storage/schema_v347.py +4 -0
  225. package/src/superlocalmemory/trust/gate.py +49 -8
  226. package/src/superlocalmemory/ui/assets/slm-icon-white.svg +64 -0
  227. package/src/superlocalmemory/ui/assets/slm-icon.svg +36 -0
  228. package/src/superlocalmemory/ui/css/design-system.css +621 -0
  229. package/src/superlocalmemory/ui/css/neural-glass.css +6 -0
  230. package/src/superlocalmemory/ui/css/od-bridge.css +158 -0
  231. package/src/superlocalmemory/ui/favicon.svg +35 -4
  232. package/src/superlocalmemory/ui/index.html +303 -173
  233. package/src/superlocalmemory/ui/js/brain.js +5 -20
  234. package/src/superlocalmemory/ui/js/core.js +100 -41
  235. package/src/superlocalmemory/ui/js/dashboard.js +403 -65
  236. package/src/superlocalmemory/ui/js/event-delegation.js +102 -0
  237. package/src/superlocalmemory/ui/js/knowledge-graph.js +11 -11
  238. package/src/superlocalmemory/ui/js/math-health.js +1 -1
  239. package/src/superlocalmemory/ui/js/memories.js +15 -4
  240. package/src/superlocalmemory/ui/js/memory-chat.js +7 -7
  241. package/src/superlocalmemory/ui/js/ng-entities.js +6 -8
  242. package/src/superlocalmemory/ui/js/ng-ingestion.js +4 -4
  243. package/src/superlocalmemory/ui/js/ng-mesh.js +4 -9
  244. package/src/superlocalmemory/ui/js/ng-shell.js +8 -8
  245. package/src/superlocalmemory/ui/js/ng-skills.js +54 -2
  246. package/src/superlocalmemory/ui/js/od-agents.js +544 -0
  247. package/src/superlocalmemory/ui/js/od-auth-gate.js +257 -0
  248. package/src/superlocalmemory/ui/js/od-backup.js +871 -0
  249. package/src/superlocalmemory/ui/js/od-brain.js +816 -0
  250. package/src/superlocalmemory/ui/js/od-entities.js +579 -0
  251. package/src/superlocalmemory/ui/js/od-graph.js +600 -0
  252. package/src/superlocalmemory/ui/js/od-health.js +539 -0
  253. package/src/superlocalmemory/ui/js/od-mcp.js +508 -0
  254. package/src/superlocalmemory/ui/js/od-memories.js +929 -0
  255. package/src/superlocalmemory/ui/js/od-mesh.js +553 -0
  256. package/src/superlocalmemory/ui/js/od-operations.js +1250 -0
  257. package/src/superlocalmemory/ui/js/od-optimize.js +787 -0
  258. package/src/superlocalmemory/ui/js/od-settings.js +1107 -0
  259. package/src/superlocalmemory/ui/js/od-shell.js +809 -0
  260. package/src/superlocalmemory/ui/js/od-skills.js +600 -0
  261. package/src/superlocalmemory/ui/js/od-team.js +258 -0
  262. package/src/superlocalmemory/ui/js/profiles.js +159 -46
  263. package/src/superlocalmemory/ui/js/settings.js +17 -3
  264. package/src/superlocalmemory/ui/js/timeline.js +34 -5
  265. package/src/superlocalmemory/ui/js/trust-dashboard.js +2 -2
  266. package/src/superlocalmemory/vector/lancedb_backend.py +8 -6
  267. package/plugin-src/.mcp.json +0 -12
  268. package/plugin-src/agents/slm-memory-advisor.md +0 -44
  269. package/plugin-src/agents/slm-optimize-advisor.md +0 -38
  270. package/plugin-src/hooks/.gitkeep +0 -0
  271. package/plugin-src/hooks/hooks.json +0 -23
  272. package/plugin-src/manifest.json +0 -25
  273. package/plugin-src/requirements.txt +0 -1
  274. package/plugin-src/rules/CLAUDE.md.fragment +0 -44
  275. package/plugin-src/scripts/ensure-venv.bat +0 -122
  276. package/plugin-src/scripts/ensure-venv.sh +0 -105
  277. package/plugin-src/scripts/slm-launch +0 -23
  278. package/plugin-src/scripts/slm-launch.bat +0 -23
  279. package/plugin-src/settings.json +0 -16
  280. package/src/superlocalmemory/learning/behavioral_listener.py +0 -94
@@ -11,6 +11,7 @@ explicit process contract and always wins over persisted configuration.
11
11
  from __future__ import annotations
12
12
 
13
13
  import json
14
+ import logging
14
15
  import os
15
16
  from pathlib import Path
16
17
 
@@ -24,6 +25,8 @@ _DURABLE_IDENTITY_NAMES = frozenset(
24
25
  },
25
26
  )
26
27
 
28
+ logger = logging.getLogger(__name__)
29
+
27
30
 
28
31
  class DataRootConflictError(RuntimeError):
29
32
  """Raised when two state-bearing roots make startup ambiguous."""
@@ -122,17 +125,53 @@ def assert_no_durable_root_conflict(
122
125
  *,
123
126
  home: str | Path | None = None,
124
127
  ) -> None:
125
- """Refuse ambiguous startup when selected and default roots hold state.
126
-
127
- This check never writes, copies, or deletes data. A default root that only
128
- contains the legacy relocation config is safe and does not trigger it.
128
+ """Refuse *ambiguous* startup when two state roots make the live namespace unclear.
129
+
130
+ The root actually selected for this process is always inspected: an
131
+ unreadable selected root fails closed. Beyond that, a conflict is only raised
132
+ when the selection was *implicit* — resolved from the legacy
133
+ ``config.json:base_dir`` relocation hint — and a separately-populated default
134
+ root leaves it genuinely ambiguous which namespace is live.
135
+
136
+ An explicit environment selection (``SLM_DATA_DIR`` / ``SL_MEMORY_PATH`` /
137
+ ``SLM_HOME``) is an unambiguous operator contract: a separately-populated
138
+ default root is then a deliberate multi-root / per-team / second-instance
139
+ layout, not an ambiguity, so startup proceeds. If the explicitly chosen root
140
+ is empty while the old default still holds data, that likely-mistyped path is
141
+ surfaced as a warning rather than a hard block.
142
+
143
+ This check never writes, copies, or deletes data.
129
144
  """
130
145
  home_path = _canonical_path(home if home is not None else Path.home())
131
146
  default_root = _canonical_path(home_path / ".superlocalmemory")
132
147
  selected_root = canonical_data_root(home=home_path)
133
148
  if selected_root == default_root:
134
149
  return
150
+
151
+ # The root about to be used must be inspectable regardless of how it was
152
+ # chosen; an unreadable selected root fails closed inside _durable_markers.
135
153
  selected_markers = _durable_markers(selected_root)
154
+
155
+ if environment_data_root() is not None:
156
+ # Explicit selection wins; a populated default root is a deliberate
157
+ # multi-root layout, not an ambiguity. Only warn on the "empty new root
158
+ # while the old default still holds data" case so a wrong SLM_DATA_DIR
159
+ # stays visible. Inspection of the unused default never blocks startup.
160
+ if not selected_markers:
161
+ try:
162
+ default_has_data = bool(_durable_markers(default_root))
163
+ except DataRootConflictError:
164
+ default_has_data = False
165
+ if default_has_data:
166
+ logger.warning(
167
+ "SLM_DATA_DIR selects an empty state root (%s) while the "
168
+ "default root (%s) still holds data; starting with the empty "
169
+ "root as explicitly requested.",
170
+ selected_root,
171
+ default_root,
172
+ )
173
+ return
174
+
136
175
  default_markers = _durable_markers(default_root)
137
176
  if not selected_markers or not default_markers:
138
177
  return
@@ -102,13 +102,20 @@ class EventBus:
102
102
  logger.info("EventBus initialized: db=%s", self.db_path)
103
103
 
104
104
  def _init_schema(self) -> None:
105
- """Create the memory_events table if it does not exist."""
105
+ """Create the memory_events table if it does not exist.
106
+
107
+ Self-migrates a pre-isolation DB (memory_events without profile_id) so a
108
+ dashboard viewing profile A never sees profile B's events. This table is
109
+ store-owned (created here, not by the migration runner), so the store
110
+ owns its upgrade. Existing rows backfill to the 'default' profile.
111
+ """
106
112
  conn = sqlite3.connect(str(self.db_path))
107
113
  try:
108
114
  cur = conn.cursor()
109
115
  cur.execute("""
110
116
  CREATE TABLE IF NOT EXISTS memory_events (
111
117
  id INTEGER PRIMARY KEY AUTOINCREMENT,
118
+ profile_id TEXT NOT NULL DEFAULT 'default',
112
119
  event_type TEXT NOT NULL,
113
120
  memory_id INTEGER,
114
121
  source_agent TEXT DEFAULT 'user',
@@ -119,13 +126,42 @@ class EventBus:
119
126
  created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP
120
127
  )
121
128
  """)
129
+ existing = {r[1] for r in cur.execute(
130
+ "PRAGMA table_info(memory_events)").fetchall()}
131
+ if "profile_id" not in existing:
132
+ cur.execute(
133
+ "ALTER TABLE memory_events "
134
+ "ADD COLUMN profile_id TEXT NOT NULL DEFAULT 'default'"
135
+ )
122
136
  cur.execute("CREATE INDEX IF NOT EXISTS idx_events_type ON memory_events(event_type)")
123
137
  cur.execute("CREATE INDEX IF NOT EXISTS idx_events_created ON memory_events(created_at)")
124
138
  cur.execute("CREATE INDEX IF NOT EXISTS idx_events_tier ON memory_events(tier)")
139
+ cur.execute("CREATE INDEX IF NOT EXISTS idx_events_profile ON memory_events(profile_id, id)")
125
140
  conn.commit()
126
141
  finally:
127
142
  conn.close()
128
143
 
144
+ @staticmethod
145
+ def _resolve_profile(profile_id: Optional[str]) -> str:
146
+ """Resolve the active profile for an event when not passed explicitly.
147
+
148
+ Uses the request-runtime helper (ContextVar for HTTP, else the
149
+ profiles.json active_profile cache that every switch keeps in sync) so
150
+ MCP-in-daemon and CLI emits are attributed correctly too. Lazy import
151
+ keeps the infra layer free of a hard server dependency; any failure
152
+ falls back to 'default'.
153
+ """
154
+ if profile_id:
155
+ return profile_id
156
+ try:
157
+ from superlocalmemory.server.routes.helpers import get_active_profile
158
+ resolved = get_active_profile()
159
+ if resolved:
160
+ return resolved
161
+ except Exception:
162
+ pass
163
+ return "default"
164
+
129
165
  def emit(
130
166
  self,
131
167
  event_type: str,
@@ -134,8 +170,14 @@ class EventBus:
134
170
  source_agent: str = "user",
135
171
  source_protocol: str = "internal",
136
172
  importance: int = 5,
173
+ profile_id: Optional[str] = None,
137
174
  ) -> Optional[int]:
138
- """Emit an event to all subscribers and persist to database."""
175
+ """Emit an event to all subscribers and persist to database.
176
+
177
+ ``profile_id`` scopes the event to a memory profile. When omitted it is
178
+ resolved from the active profile so a dashboard viewing one profile
179
+ never sees another profile's real-time or historical events.
180
+ """
139
181
  if event_type not in VALID_EVENT_TYPES:
140
182
  raise ValueError(
141
183
  f"Invalid event type: {event_type}. "
@@ -143,6 +185,7 @@ class EventBus:
143
185
  )
144
186
 
145
187
  importance = max(1, min(10, importance))
188
+ profile_id = self._resolve_profile(profile_id)
146
189
 
147
190
  now = datetime.now(timezone.utc).isoformat()
148
191
  with self._counter_lock:
@@ -151,6 +194,7 @@ class EventBus:
151
194
 
152
195
  event: Dict[str, Any] = {
153
196
  "seq": seq,
197
+ "profile_id": profile_id,
154
198
  "event_type": event_type,
155
199
  "memory_id": memory_id,
156
200
  "source_agent": source_agent,
@@ -177,18 +221,24 @@ class EventBus:
177
221
  event_type, event_id, memory_id,
178
222
  )
179
223
 
180
- # Auto-prune heuristic
181
- self._write_count += 1
182
- if (
183
- self._write_count >= 100
184
- or (datetime.now() - self._last_prune).total_seconds() > 86400
185
- ):
224
+ # Auto-prune heuristic. Decide + reset the counter atomically under the
225
+ # lock so concurrent emit() calls cannot both cross the threshold and
226
+ # double-run the prune; run the prune itself OUTSIDE the lock.
227
+ should_prune = False
228
+ with self._counter_lock:
229
+ self._write_count += 1
230
+ if (
231
+ self._write_count >= 100
232
+ or (datetime.now() - self._last_prune).total_seconds() > 86400
233
+ ):
234
+ should_prune = True
235
+ self._write_count = 0
236
+ self._last_prune = datetime.now()
237
+ if should_prune:
186
238
  try:
187
239
  self.prune_events()
188
240
  except Exception:
189
241
  pass
190
- self._write_count = 0
191
- self._last_prune = datetime.now()
192
242
 
193
243
  return event_id
194
244
 
@@ -202,10 +252,12 @@ class EventBus:
202
252
  try:
203
253
  cur = conn.cursor()
204
254
  cur.execute(
205
- "INSERT INTO memory_events (event_type, memory_id, source_agent,"
206
- " source_protocol, payload, importance, tier, created_at)"
207
- " VALUES (?, ?, ?, ?, ?, ?, 'hot', ?)",
208
- (event["event_type"], event.get("memory_id"),
255
+ "INSERT INTO memory_events (profile_id, event_type, memory_id,"
256
+ " source_agent, source_protocol, payload, importance, tier,"
257
+ " created_at)"
258
+ " VALUES (?, ?, ?, ?, ?, ?, ?, 'hot', ?)",
259
+ (event.get("profile_id", "default"), event["event_type"],
260
+ event.get("memory_id"),
209
261
  event["source_agent"], event["source_protocol"],
210
262
  json.dumps(event["payload"]), event["importance"],
211
263
  event["timestamp"]),
@@ -253,9 +305,16 @@ class EventBus:
253
305
  since_id: Optional[int] = None,
254
306
  limit: int = 50,
255
307
  event_type: Optional[str] = None,
308
+ profile_id: Optional[str] = None,
256
309
  ) -> List[dict]:
257
- """Get recent events from the database."""
310
+ """Get recent events from the database, scoped to a profile.
311
+
312
+ ``profile_id`` defaults to the active profile so callers never leak
313
+ another profile's events. Pass ``profile_id="*"`` to bypass scoping
314
+ (internal maintenance/pruning only — never a client-facing path).
315
+ """
258
316
  limit = min(limit, 200)
317
+ scope = profile_id if profile_id == "*" else self._resolve_profile(profile_id)
259
318
 
260
319
  try:
261
320
  conn = sqlite3.connect(str(self.db_path))
@@ -263,10 +322,15 @@ class EventBus:
263
322
  cur = conn.cursor()
264
323
 
265
324
  query = ("SELECT id, event_type, memory_id, source_agent,"
266
- " source_protocol, payload, importance, tier, created_at"
325
+ " source_protocol, payload, importance, tier, created_at,"
326
+ " profile_id"
267
327
  " FROM memory_events WHERE 1=1")
268
328
  params: List[Any] = []
269
329
 
330
+ if scope != "*":
331
+ query += " AND profile_id = ?"
332
+ params.append(scope)
333
+
270
334
  if since_id is not None:
271
335
  query += " AND id > ?"
272
336
  params.append(since_id)
@@ -293,7 +357,7 @@ class EventBus:
293
357
  "id": row[0], "event_type": row[1], "memory_id": row[2],
294
358
  "source_agent": row[3], "source_protocol": row[4],
295
359
  "payload": parsed, "importance": row[6],
296
- "tier": row[7], "timestamp": row[8],
360
+ "tier": row[7], "timestamp": row[8], "profile_id": row[9],
297
361
  })
298
362
  return events
299
363
 
@@ -306,19 +370,32 @@ class EventBus:
306
370
  with self._buffer_lock:
307
371
  return [e for e in self._buffer if e.get("seq", 0) > since_seq]
308
372
 
309
- def get_event_stats(self) -> dict:
310
- """Get event system statistics."""
373
+ def get_event_stats(self, profile_id: Optional[str] = None) -> dict:
374
+ """Get event system statistics, scoped to a profile.
375
+
376
+ ``profile_id`` defaults to the active profile so dashboard event
377
+ counts never blend across profiles.
378
+ """
379
+ scope = self._resolve_profile(profile_id)
311
380
  try:
312
381
  conn = sqlite3.connect(str(self.db_path))
313
382
  try:
314
383
  cur = conn.cursor()
315
384
 
316
- total = cur.execute("SELECT COUNT(*) FROM memory_events").fetchone()[0]
317
- cur.execute("SELECT event_type, COUNT(*) FROM memory_events GROUP BY event_type")
385
+ total = cur.execute(
386
+ "SELECT COUNT(*) FROM memory_events WHERE profile_id = ?",
387
+ (scope,)).fetchone()[0]
388
+ cur.execute(
389
+ "SELECT event_type, COUNT(*) FROM memory_events "
390
+ "WHERE profile_id = ? GROUP BY event_type", (scope,))
318
391
  by_type = dict(cur.fetchall())
319
- cur.execute("SELECT tier, COUNT(*) FROM memory_events GROUP BY tier")
392
+ cur.execute(
393
+ "SELECT tier, COUNT(*) FROM memory_events "
394
+ "WHERE profile_id = ? GROUP BY tier", (scope,))
320
395
  by_tier = dict(cur.fetchall())
321
- cur.execute("SELECT COUNT(*) FROM memory_events WHERE created_at >= datetime('now', '-24 hours')")
396
+ cur.execute(
397
+ "SELECT COUNT(*) FROM memory_events WHERE profile_id = ? "
398
+ "AND created_at >= datetime('now', '-24 hours')", (scope,))
322
399
  last_24h = cur.fetchone()[0]
323
400
  finally:
324
401
  conn.close()
@@ -343,7 +420,13 @@ class EventBus:
343
420
  warm_hours: int = DEFAULT_WARM_HOURS,
344
421
  cold_hours: int = DEFAULT_COLD_HOURS,
345
422
  ) -> dict:
346
- """Apply tiered retention policy to persisted events."""
423
+ """Apply tiered retention policy to persisted events.
424
+
425
+ INTENTIONALLY GLOBAL (all profiles): this is age/tier-based housekeeping
426
+ of the shared event log, not a per-tenant read or retention-policy
427
+ surface. Tenant isolation of event CONTENT is enforced on read via the
428
+ profile_id filter; this sweep only demotes/expires old rows by age.
429
+ """
347
430
  try:
348
431
  conn = sqlite3.connect(str(self.db_path))
349
432
  try:
@@ -118,6 +118,22 @@ class RateLimiter:
118
118
  del self._requests[k]
119
119
  return len(stale)
120
120
 
121
+ def configure(
122
+ self,
123
+ max_requests: int | None = None,
124
+ window_seconds: int | None = None,
125
+ ) -> None:
126
+ """Reconfigure limits at runtime (thread-safe).
127
+
128
+ Recorded request timestamps are preserved; only the ceilings change,
129
+ so a raised limit takes effect immediately for the current window.
130
+ """
131
+ with self._lock:
132
+ if max_requests is not None:
133
+ self.max_requests = max(1, int(max_requests))
134
+ if window_seconds is not None:
135
+ self.window = max(1, int(window_seconds))
136
+
121
137
  def get_stats(self) -> dict:
122
138
  """Return a snapshot of limiter state."""
123
139
  with self._lock:
@@ -133,3 +149,80 @@ class RateLimiter:
133
149
  # ---------------------------------------------------------------------------
134
150
  write_limiter = RateLimiter(max_requests=WRITE_LIMIT, window_seconds=WINDOW_SECONDS)
135
151
  read_limiter = RateLimiter(max_requests=READ_LIMIT, window_seconds=WINDOW_SECONDS)
152
+
153
+
154
+ # ---------------------------------------------------------------------------
155
+ # Runtime-configurable limits (task #47) — dashboard-editable thresholds.
156
+ #
157
+ # The enforcement middleware builds its own limiter instances; it registers
158
+ # them here by role so a single set_limits() call reconfigures every live
159
+ # limiter at once (no restart). Loopback limiters derive from write/read,
160
+ # matching the startup derivation in unified_daemon.
161
+ # ---------------------------------------------------------------------------
162
+
163
+ _MANAGED: List[Tuple[str, "RateLimiter"]] = []
164
+ _MANAGED_LOCK = threading.Lock()
165
+ _CURRENT: Dict[str, int] = {
166
+ "write": WRITE_LIMIT, "read": READ_LIMIT, "window": WINDOW_SECONDS,
167
+ }
168
+
169
+
170
+ def _loopback_write(write: int) -> int:
171
+ return max(300, int(write) * 10)
172
+
173
+
174
+ def _loopback_read(read: int) -> int:
175
+ return max(2000, int(read) * 20)
176
+
177
+
178
+ def register_managed(role: str, limiter: "RateLimiter") -> None:
179
+ """Register an enforcement limiter so set_limits() can reconfigure it.
180
+
181
+ role is one of: 'write', 'read', 'lb_write', 'lb_read'.
182
+ """
183
+ with _MANAGED_LOCK:
184
+ _MANAGED.append((role, limiter))
185
+
186
+
187
+ def reset_managed() -> None:
188
+ """Drop all registered limiters (app teardown / test isolation)."""
189
+ with _MANAGED_LOCK:
190
+ _MANAGED.clear()
191
+
192
+
193
+ def get_limits() -> Dict[str, int]:
194
+ """Return the current effective write/read/window limits."""
195
+ with _MANAGED_LOCK:
196
+ return dict(_CURRENT)
197
+
198
+
199
+ def set_limits(
200
+ write: int | None = None,
201
+ read: int | None = None,
202
+ window: int | None = None,
203
+ ) -> Dict[str, int]:
204
+ """Update limits and reconfigure every registered limiter live.
205
+
206
+ Partial updates are supported (pass only what changes). Loopback
207
+ limiters re-derive from the new write/read. Returns the new effective
208
+ limits. Values are floored at 1.
209
+ """
210
+ with _MANAGED_LOCK:
211
+ if write is not None:
212
+ _CURRENT["write"] = max(1, int(write))
213
+ if read is not None:
214
+ _CURRENT["read"] = max(1, int(read))
215
+ if window is not None:
216
+ _CURRENT["window"] = max(1, int(window))
217
+ w, r, win = _CURRENT["write"], _CURRENT["read"], _CURRENT["window"]
218
+ lb_w, lb_r = _loopback_write(w), _loopback_read(r)
219
+ for role, limiter in _MANAGED:
220
+ if role == "write":
221
+ limiter.configure(max_requests=w, window_seconds=win)
222
+ elif role == "read":
223
+ limiter.configure(max_requests=r, window_seconds=win)
224
+ elif role == "lb_write":
225
+ limiter.configure(max_requests=lb_w, window_seconds=win)
226
+ elif role == "lb_read":
227
+ limiter.configure(max_requests=lb_r, window_seconds=win)
228
+ return dict(_CURRENT)
@@ -175,7 +175,10 @@ def stop_adapter(name: str) -> dict:
175
175
  proc.terminate()
176
176
  proc.wait(timeout=10)
177
177
  except ImportError:
178
- os.kill(pid, 15) # SIGTERM
178
+ # No psutil: best-effort termination. On Windows os.kill(pid, 15) maps to
179
+ # TerminateProcess (a hard kill, not a graceful SIGTERM) — psutil's
180
+ # terminate() does the same there, so behavior is equivalent either way.
181
+ os.kill(pid, 15)
179
182
  except Exception:
180
183
  pass
181
184
 
@@ -42,7 +42,7 @@ def store_credential(service: str, key: str, value: str) -> bool:
42
42
  except Exception:
43
43
  pass
44
44
 
45
- # Fallback: encrypted file with restricted permissions
45
+ # Fallback: restricted-permission file (0600) when no OS keychain is present
46
46
  try:
47
47
  cred_dir = _credential_dir()
48
48
  cred_dir.mkdir(parents=True, exist_ok=True)
@@ -65,6 +65,7 @@ TECH_CATEGORIES: dict[str, list[str]] = {
65
65
  _SCHEMA = """
66
66
  CREATE TABLE IF NOT EXISTS transferable_patterns (
67
67
  id INTEGER PRIMARY KEY AUTOINCREMENT,
68
+ profile_id TEXT NOT NULL DEFAULT 'default',
68
69
  pattern_type TEXT NOT NULL DEFAULT 'preference',
69
70
  key TEXT NOT NULL,
70
71
  value TEXT NOT NULL,
@@ -75,7 +76,7 @@ CREATE TABLE IF NOT EXISTS transferable_patterns (
75
76
  contradictions TEXT DEFAULT '[]',
76
77
  first_seen TEXT,
77
78
  last_seen TEXT,
78
- UNIQUE(pattern_type, key)
79
+ UNIQUE(profile_id, pattern_type, key)
79
80
  );
80
81
 
81
82
  CREATE TABLE IF NOT EXISTS memories (
@@ -115,25 +116,29 @@ class CrossProjectAggregator:
115
116
  # Step 2: Merge with temporal decay
116
117
  merged = self._merge_with_decay(profile_patterns)
117
118
 
118
- # Step 3: Detect contradictions
119
+ # Step 3: Detect contradictions (scoped to the target profile)
119
120
  for key, pattern_data in merged.items():
120
121
  pattern_data["contradictions"] = self._detect_contradictions(
121
- key, pattern_data
122
+ key, pattern_data, target_profile
122
123
  )
123
124
 
124
- # Step 4: Store and return
125
- self._store_patterns(merged)
125
+ # Step 4: Store and return (scoped to the target profile)
126
+ self._store_patterns(merged, target_profile)
126
127
  return [{"key": k, **v} for k, v in merged.items()]
127
128
 
128
- def get_preferences(self, min_confidence: float = 0.6) -> dict[str, dict]:
129
- """Retrieve stored transferable preferences above *min_confidence*."""
129
+ def get_preferences(
130
+ self,
131
+ profile_id: str = "default",
132
+ min_confidence: float = 0.6,
133
+ ) -> dict[str, dict]:
134
+ """Retrieve stored transferable preferences for *profile_id* above *min_confidence*."""
130
135
  conn = sqlite3.connect(str(self._db_path))
131
136
  conn.row_factory = sqlite3.Row
132
137
  try:
133
138
  cur = conn.execute(
134
139
  "SELECT * FROM transferable_patterns "
135
- "WHERE confidence >= ? ORDER BY confidence DESC",
136
- (min_confidence,),
140
+ "WHERE profile_id = ? AND confidence >= ? ORDER BY confidence DESC",
141
+ (profile_id, min_confidence),
137
142
  )
138
143
  result: dict[str, dict] = {}
139
144
  for row in cur.fetchall():
@@ -277,18 +282,18 @@ class CrossProjectAggregator:
277
282
  }
278
283
 
279
284
  def _detect_contradictions(
280
- self, pattern_key: str, pattern_data: dict
285
+ self, pattern_key: str, pattern_data: dict, profile_id: str = "default"
281
286
  ) -> list[str]:
282
287
  contradictions: list[str] = []
283
288
 
284
- # Check stored value vs new value
289
+ # Check stored value vs new value, scoped to this profile.
285
290
  conn = sqlite3.connect(str(self._db_path))
286
291
  conn.row_factory = sqlite3.Row
287
292
  try:
288
293
  cur = conn.execute(
289
294
  "SELECT value, last_seen FROM transferable_patterns "
290
- "WHERE key = ? AND pattern_type = 'preference'",
291
- (pattern_key,),
295
+ "WHERE profile_id = ? AND key = ? AND pattern_type = 'preference'",
296
+ (profile_id, pattern_key),
292
297
  )
293
298
  row = cur.fetchone()
294
299
  if row:
@@ -316,18 +321,21 @@ class CrossProjectAggregator:
316
321
  finally:
317
322
  conn.close()
318
323
 
319
- def _store_patterns(self, merged: dict[str, dict]) -> None:
324
+ def _store_patterns(
325
+ self, merged: dict[str, dict], profile_id: str = "default"
326
+ ) -> None:
327
+ """Persist merged patterns for *profile_id*. Each profile has isolated rows."""
320
328
  conn = sqlite3.connect(str(self._db_path))
321
329
  now = datetime.now(UTC).isoformat()
322
330
  try:
323
331
  for key, data in merged.items():
324
332
  conn.execute(
325
333
  """INSERT INTO transferable_patterns
326
- (pattern_type, key, value, confidence, evidence_count,
327
- profiles_seen, decay_factor, contradictions,
328
- first_seen, last_seen)
329
- VALUES ('preference', ?, ?, ?, ?, ?, ?, ?, ?, ?)
330
- ON CONFLICT(pattern_type, key) DO UPDATE SET
334
+ (profile_id, pattern_type, key, value, confidence,
335
+ evidence_count, profiles_seen, decay_factor,
336
+ contradictions, first_seen, last_seen)
337
+ VALUES (?, 'preference', ?, ?, ?, ?, ?, ?, ?, ?, ?)
338
+ ON CONFLICT(profile_id, pattern_type, key) DO UPDATE SET
331
339
  value = excluded.value,
332
340
  confidence = excluded.confidence,
333
341
  evidence_count = excluded.evidence_count,
@@ -337,6 +345,7 @@ class CrossProjectAggregator:
337
345
  last_seen = excluded.last_seen
338
346
  """,
339
347
  (
348
+ profile_id,
340
349
  key,
341
350
  data["value"],
342
351
  data["confidence"],
@@ -34,6 +34,8 @@ import sqlite3
34
34
  from datetime import datetime, timedelta, timezone
35
35
  from typing import Final
36
36
 
37
+ from superlocalmemory.learning.model_cache import invalidate as invalidate_model_cache
38
+
37
39
  logger = logging.getLogger(__name__)
38
40
 
39
41
 
@@ -235,6 +237,7 @@ class ModelRollback:
235
237
  )
236
238
  return False
237
239
 
240
+ invalidate_model_cache(self._profile_id)
238
241
  logger.warning(
239
242
  "AUTO-ROLLBACK profile=%s reason=%s observations=%d "
240
243
  "baseline_ndcg=%.4f current_ndcg=%.4f",
@@ -33,6 +33,7 @@ from datetime import datetime, timezone
33
33
  from pathlib import Path
34
34
  from typing import Final
35
35
 
36
+ from superlocalmemory.learning.model_cache import invalidate as invalidate_model_cache
36
37
  from superlocalmemory.learning.ranker_common import (
37
38
  _build_training_matrix,
38
39
  _compute_eval_metrics,
@@ -389,6 +390,7 @@ def _promote_candidate(
389
390
  (json.dumps(meta), candidate_id),
390
391
  )
391
392
  conn.commit()
393
+ invalidate_model_cache(profile_id)
392
394
  return True
393
395
  except sqlite3.Error as exc:
394
396
  conn.rollback()
@@ -73,6 +73,24 @@ _DWELL_MAX_MS: Final[int] = 3_600_000 # 1 h
73
73
  _BUSY_TIMEOUT_MS: Final[int] = 50
74
74
 
75
75
 
76
+ def _feed_source_quality(
77
+ memory_db_path: Path,
78
+ rewards: list[tuple[str, str, list[str], float]],
79
+ ) -> None:
80
+ """Fail-soft provenance bridge kept outside the reward transaction."""
81
+ try:
82
+ from superlocalmemory.learning.source_quality import (
83
+ update_source_quality_for_reward_batch,
84
+ )
85
+ update_source_quality_for_reward_batch(
86
+ memory_db_path=memory_db_path,
87
+ learning_db_path=memory_db_path.parent / "learning.db",
88
+ rewards=rewards,
89
+ )
90
+ except Exception as exc: # noqa: BLE001 - reward finalization must survive
91
+ logger.debug("source-quality reward feed failed (non-fatal): %s", exc)
92
+
93
+
76
94
  # ---------------------------------------------------------------------------
77
95
  # Signal contract — names match the manifest A.1 label formula.
78
96
  # ---------------------------------------------------------------------------
@@ -604,6 +622,7 @@ class EngagementRewardModel:
604
622
  # lock-scope — SQLite rows are tied to the connection.
605
623
  _pid = pending["profile_id"]
606
624
  _qid = pending["recall_query_id"]
625
+ _facts_json = str(pending["fact_ids_json"] or "[]")
607
626
  except sqlite3.Error as exc:
608
627
  logger.debug("finalize_outcome SQLite error: %s", exc)
609
628
  return _FALLBACK_REWARD
@@ -633,6 +652,21 @@ class EngagementRewardModel:
633
652
  except Exception as exc: # noqa: BLE001 — defence in depth
634
653
  logger.debug("feed_recall_settled failed (non-fatal): %s", exc)
635
654
 
655
+ try:
656
+ _fact_ids = json.loads(_facts_json)
657
+ if not isinstance(_fact_ids, list):
658
+ _fact_ids = []
659
+ except (TypeError, ValueError, json.JSONDecodeError):
660
+ _fact_ids = []
661
+ _feed_source_quality(
662
+ self._db,
663
+ [(
664
+ str(_pid),
665
+ str(outcome_id),
666
+ [str(fid) for fid in _fact_ids if fid],
667
+ float(reward),
668
+ )],
669
+ )
636
670
  return reward
637
671
 
638
672
  # ------------------------------------------------------------------
@@ -749,6 +783,22 @@ class EngagementRewardModel:
749
783
  except sqlite3.Error:
750
784
  conn.execute("ROLLBACK")
751
785
  raise
786
+ reward_sources = []
787
+ for item in i_chunk:
788
+ try:
789
+ fact_ids = json.loads(str(item[2] or "[]"))
790
+ except (TypeError, ValueError, json.JSONDecodeError):
791
+ fact_ids = []
792
+ reward_sources.append((
793
+ str(item[1]),
794
+ str(item[0]),
795
+ (
796
+ [str(fid) for fid in fact_ids if fid]
797
+ if isinstance(fact_ids, list) else []
798
+ ),
799
+ float(item[4]),
800
+ ))
801
+ _feed_source_quality(self._db, reward_sources)
752
802
  except sqlite3.Error as exc: # pragma: no cover — defensive
753
803
  logger.debug("reap_stale SQLite error: %s", exc)
754
804
  return written