superlocalmemory 3.7.8 → 3.8.0

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 (260) hide show
  1. package/.claude-plugin/marketplace.json +1 -1
  2. package/ATTRIBUTION.md +1 -3
  3. package/CHANGELOG.md +69 -0
  4. package/README.md +199 -29
  5. package/package.json +4 -2
  6. package/plugin/.claude-plugin/plugin.json +2 -2
  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/agents/slm-governance-advisor.md +80 -0
  30. package/plugin-src/agents/slm-loop-runner.md +71 -0
  31. package/plugin-src/agents/slm-memory-advisor.md +10 -5
  32. package/plugin-src/agents/slm-optimize-advisor.md +9 -3
  33. package/plugin-src/commands/slm-loop.md +31 -0
  34. package/plugin-src/hooks/hooks.json +79 -0
  35. package/plugin-src/manifest.json +7 -2
  36. package/plugin-src/requirements.txt +1 -1
  37. package/plugin-src/rules/AGENTS.md +57 -18
  38. package/plugin-src/rules/CLAUDE.md.fragment +8 -8
  39. package/plugin-src/scripts/slm-launch +46 -7
  40. package/plugin-src/settings.json +9 -0
  41. package/plugin-src/skills/slm-cache/SKILL.md +9 -1
  42. package/plugin-src/skills/slm-compress/SKILL.md +8 -1
  43. package/plugin-src/skills/slm-governance/SKILL.md +248 -0
  44. package/plugin-src/skills/slm-graph/SKILL.md +17 -3
  45. package/plugin-src/skills/slm-loop/SKILL.md +99 -0
  46. package/plugin-src/skills/slm-mesh/SKILL.md +282 -0
  47. package/plugin-src/skills/slm-profile/SKILL.md +148 -0
  48. package/plugin-src/skills/slm-recall/SKILL.md +46 -10
  49. package/plugin-src/skills/slm-remember/SKILL.md +48 -1
  50. package/plugin-src/skills/slm-scope/SKILL.md +176 -0
  51. package/plugin-src/skills/slm-session/SKILL.md +24 -1
  52. package/plugin-src/skills/slm-status/SKILL.md +18 -1
  53. package/pyproject.toml +1 -1
  54. package/scripts/postinstall/validation.js +2 -0
  55. package/scripts/postinstall-interactive.js +74 -2
  56. package/src/superlocalmemory/__init__.py +1 -1
  57. package/src/superlocalmemory/access/__init__.py +3 -0
  58. package/src/superlocalmemory/access/rbac.py +477 -0
  59. package/src/superlocalmemory/cli/commands.py +94 -10
  60. package/src/superlocalmemory/cli/compress_cmd.py +17 -7
  61. package/src/superlocalmemory/cli/loop_cmd.py +192 -0
  62. package/src/superlocalmemory/cli/main.py +39 -4
  63. package/src/superlocalmemory/cli/mesh_cmd.py +38 -0
  64. package/src/superlocalmemory/cli/optimize_cmd.py +3 -0
  65. package/src/superlocalmemory/cli/pending_store.py +49 -13
  66. package/src/superlocalmemory/cli/proxy_cmd.py +4 -0
  67. package/src/superlocalmemory/cli/scale_engine_cmd.py +6 -0
  68. package/src/superlocalmemory/cli/setup_wizard.py +22 -13
  69. package/src/superlocalmemory/compliance/audit.py +6 -0
  70. package/src/superlocalmemory/compliance/gdpr.py +128 -138
  71. package/src/superlocalmemory/compliance/retention.py +176 -45
  72. package/src/superlocalmemory/core/backend_orchestrator.py +5 -43
  73. package/src/superlocalmemory/core/community_summary.py +267 -0
  74. package/src/superlocalmemory/core/config.py +216 -3
  75. package/src/superlocalmemory/core/consolidation_engine.py +95 -22
  76. package/src/superlocalmemory/core/context_cache.py +61 -18
  77. package/src/superlocalmemory/core/embedding_worker.py +17 -2
  78. package/src/superlocalmemory/core/embeddings.py +12 -1
  79. package/src/superlocalmemory/core/engine.py +17 -1
  80. package/src/superlocalmemory/core/engine_ingestion.py +29 -0
  81. package/src/superlocalmemory/core/engine_wiring.py +13 -0
  82. package/src/superlocalmemory/core/entity_community.py +178 -0
  83. package/src/superlocalmemory/core/graph_analyzer.py +39 -2
  84. package/src/superlocalmemory/core/graph_pruner.py +13 -8
  85. package/src/superlocalmemory/core/key_expander.py +138 -0
  86. package/src/superlocalmemory/core/maintenance.py +23 -0
  87. package/src/superlocalmemory/core/modes.py +1 -1
  88. package/src/superlocalmemory/core/mutations.py +2 -2
  89. package/src/superlocalmemory/core/pii.py +105 -0
  90. package/src/superlocalmemory/core/progressive_abstraction.py +208 -0
  91. package/src/superlocalmemory/core/recall_pipeline.py +2 -0
  92. package/src/superlocalmemory/core/recall_worker.py +20 -6
  93. package/src/superlocalmemory/core/scale_engine.py +60 -1
  94. package/src/superlocalmemory/core/security_primitives.py +40 -2
  95. package/src/superlocalmemory/core/store_pipeline.py +35 -11
  96. package/src/superlocalmemory/core/worker_pool.py +21 -6
  97. package/src/superlocalmemory/encoding/entity_reflexion.py +200 -0
  98. package/src/superlocalmemory/encoding/entity_resolver.py +34 -24
  99. package/src/superlocalmemory/encoding/fact_extractor.py +26 -1
  100. package/src/superlocalmemory/encoding/temporal_validator.py +64 -1
  101. package/src/superlocalmemory/evolution/evolution_store.py +122 -45
  102. package/src/superlocalmemory/evolution/llm_dispatch.py +12 -1
  103. package/src/superlocalmemory/evolution/model_selection.py +160 -0
  104. package/src/superlocalmemory/evolution/mutation_generator.py +16 -0
  105. package/src/superlocalmemory/evolution/skill_evolver.py +127 -42
  106. package/src/superlocalmemory/evolution/triggers.py +22 -13
  107. package/src/superlocalmemory/graph/cozo_backend.py +43 -20
  108. package/src/superlocalmemory/hooks/adapter_base.py +5 -1
  109. package/src/superlocalmemory/hooks/auto_recall.py +13 -1
  110. package/src/superlocalmemory/hooks/claude_code_hooks.py +11 -0
  111. package/src/superlocalmemory/hooks/codex_assets.py +64 -5
  112. package/src/superlocalmemory/hooks/hook_daemon.py +20 -3
  113. package/src/superlocalmemory/hooks/memory_protocol.py +54 -0
  114. package/src/superlocalmemory/hooks/portable_kit.py +114 -1
  115. package/src/superlocalmemory/infra/backup.py +12 -1
  116. package/src/superlocalmemory/infra/daemon_identity.py +40 -4
  117. package/src/superlocalmemory/infra/data_root.py +43 -4
  118. package/src/superlocalmemory/infra/event_bus.py +107 -24
  119. package/src/superlocalmemory/infra/rate_limiter.py +93 -0
  120. package/src/superlocalmemory/ingestion/adapter_manager.py +4 -1
  121. package/src/superlocalmemory/ingestion/credentials.py +1 -1
  122. package/src/superlocalmemory/learning/cross_project.py +28 -19
  123. package/src/superlocalmemory/learning/reward_proxy.py +42 -9
  124. package/src/superlocalmemory/loops/__init__.py +56 -0
  125. package/src/superlocalmemory/loops/budget.py +58 -0
  126. package/src/superlocalmemory/loops/engine.py +164 -0
  127. package/src/superlocalmemory/loops/ledger.py +243 -0
  128. package/src/superlocalmemory/loops/models.py +152 -0
  129. package/src/superlocalmemory/loops/rules.py +52 -0
  130. package/src/superlocalmemory/mcp/_daemon_proxy.py +3 -0
  131. package/src/superlocalmemory/mcp/_pool_adapter.py +2 -0
  132. package/src/superlocalmemory/mcp/profiles.py +103 -0
  133. package/src/superlocalmemory/mcp/server.py +21 -49
  134. package/src/superlocalmemory/mcp/tools_active.py +4 -7
  135. package/src/superlocalmemory/mcp/tools_code_graph.py +51 -5
  136. package/src/superlocalmemory/mcp/tools_core.py +8 -1
  137. package/src/superlocalmemory/mcp/tools_evolution.py +6 -3
  138. package/src/superlocalmemory/mcp/tools_loops.py +300 -0
  139. package/src/superlocalmemory/mcp/tools_mesh.py +140 -4
  140. package/src/superlocalmemory/mcp/tools_optimize.py +15 -8
  141. package/src/superlocalmemory/mesh/broker.py +237 -129
  142. package/src/superlocalmemory/mesh/remote_sync.py +50 -8
  143. package/src/superlocalmemory/optimize/NOTICE +1 -6
  144. package/src/superlocalmemory/optimize/adapters/anthropic_adapter.py +1 -4
  145. package/src/superlocalmemory/optimize/adapters/openai_adapter.py +1 -4
  146. package/src/superlocalmemory/optimize/cache/semantic.py +27 -19
  147. package/src/superlocalmemory/optimize/compress/align.py +32 -26
  148. package/src/superlocalmemory/optimize/compress/ccr.py +14 -71
  149. package/src/superlocalmemory/optimize/compress/router.py +105 -22
  150. package/src/superlocalmemory/optimize/config/defaults.py +1 -1
  151. package/src/superlocalmemory/optimize/config/schema.py +87 -4
  152. package/src/superlocalmemory/optimize/metrics/counters.py +13 -4
  153. package/src/superlocalmemory/optimize/metrics/estimator.py +0 -3
  154. package/src/superlocalmemory/optimize/proxy/_helpers.py +31 -4
  155. package/src/superlocalmemory/optimize/storage/db.py +38 -9
  156. package/src/superlocalmemory/optimize/storage/schema.py +10 -0
  157. package/src/superlocalmemory/parameterization/pattern_extractor.py +6 -3
  158. package/src/superlocalmemory/retrieval/agentic.py +1 -1
  159. package/src/superlocalmemory/retrieval/bm25_channel.py +68 -10
  160. package/src/superlocalmemory/retrieval/engine.py +168 -26
  161. package/src/superlocalmemory/retrieval/entity_channel.py +7 -5
  162. package/src/superlocalmemory/retrieval/hopfield_channel.py +9 -2
  163. package/src/superlocalmemory/retrieval/semantic_channel.py +114 -21
  164. package/src/superlocalmemory/retrieval/spreading_activation.py +11 -2
  165. package/src/superlocalmemory/retrieval/temporal_channel.py +48 -9
  166. package/src/superlocalmemory/retrieval/temporal_frame.py +102 -0
  167. package/src/superlocalmemory/retrieval/temporal_validity_filter.py +135 -0
  168. package/src/superlocalmemory/retrieval/time_window.py +181 -0
  169. package/src/superlocalmemory/server/api.py +4 -4
  170. package/src/superlocalmemory/server/profile_runtime.py +125 -8
  171. package/src/superlocalmemory/server/rbac_enforce.py +142 -0
  172. package/src/superlocalmemory/server/recall_health.py +24 -3
  173. package/src/superlocalmemory/server/recall_serializer.py +19 -1
  174. package/src/superlocalmemory/server/routes/abstraction.py +115 -0
  175. package/src/superlocalmemory/server/routes/agents.py +128 -38
  176. package/src/superlocalmemory/server/routes/backup.py +34 -10
  177. package/src/superlocalmemory/server/routes/behavioral.py +13 -12
  178. package/src/superlocalmemory/server/routes/brain.py +21 -5
  179. package/src/superlocalmemory/server/routes/chat.py +10 -5
  180. package/src/superlocalmemory/server/routes/compliance.py +171 -21
  181. package/src/superlocalmemory/server/routes/config_api.py +436 -0
  182. package/src/superlocalmemory/server/routes/data_io.py +30 -8
  183. package/src/superlocalmemory/server/routes/entity.py +9 -4
  184. package/src/superlocalmemory/server/routes/events.py +24 -8
  185. package/src/superlocalmemory/server/routes/evolution.py +135 -17
  186. package/src/superlocalmemory/server/routes/helpers.py +16 -1
  187. package/src/superlocalmemory/server/routes/ingest.py +7 -4
  188. package/src/superlocalmemory/server/routes/insights.py +3 -3
  189. package/src/superlocalmemory/server/routes/learning.py +14 -14
  190. package/src/superlocalmemory/server/routes/lifecycle.py +59 -8
  191. package/src/superlocalmemory/server/routes/memories.py +182 -57
  192. package/src/superlocalmemory/server/routes/mesh.py +95 -15
  193. package/src/superlocalmemory/server/routes/optimize.py +33 -1
  194. package/src/superlocalmemory/server/routes/prewarm.py +2 -0
  195. package/src/superlocalmemory/server/routes/profiles.py +63 -17
  196. package/src/superlocalmemory/server/routes/ratelimit.py +124 -0
  197. package/src/superlocalmemory/server/routes/rbac.py +367 -0
  198. package/src/superlocalmemory/server/routes/stats.py +13 -6
  199. package/src/superlocalmemory/server/routes/tiers.py +11 -9
  200. package/src/superlocalmemory/server/routes/v3_api.py +183 -69
  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 +384 -56
  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 +53 -0
  208. package/src/superlocalmemory/storage/migrations/M021_ingestion_log_profile.py +108 -0
  209. package/src/superlocalmemory/storage/migrations/M022_entity_aliases_profile.py +86 -0
  210. package/src/superlocalmemory/storage/migrations/M023_mesh_profile_isolation.py +194 -0
  211. package/src/superlocalmemory/storage/migrations/M024_rbac_users_roles.py +87 -0
  212. package/src/superlocalmemory/storage/migrations/M025_perf_indexes.py +90 -0
  213. package/src/superlocalmemory/storage/migrations/M026_rbac_memberships_fk.py +136 -0
  214. package/src/superlocalmemory/storage/migrations/M027_transferable_patterns_profile.py +163 -0
  215. package/src/superlocalmemory/storage/models.py +4 -0
  216. package/src/superlocalmemory/storage/schema.py +87 -0
  217. package/src/superlocalmemory/storage/schema_v343.py +24 -12
  218. package/src/superlocalmemory/trust/gate.py +49 -8
  219. package/src/superlocalmemory/ui/assets/slm-icon-white.svg +64 -0
  220. package/src/superlocalmemory/ui/assets/slm-icon.svg +36 -0
  221. package/src/superlocalmemory/ui/css/design-system.css +621 -0
  222. package/src/superlocalmemory/ui/css/neural-glass.css +6 -0
  223. package/src/superlocalmemory/ui/css/od-bridge.css +158 -0
  224. package/src/superlocalmemory/ui/favicon.svg +35 -4
  225. package/src/superlocalmemory/ui/index.html +306 -173
  226. package/src/superlocalmemory/ui/js/brain.js +5 -20
  227. package/src/superlocalmemory/ui/js/core.js +47 -31
  228. package/src/superlocalmemory/ui/js/dashboard.js +314 -63
  229. package/src/superlocalmemory/ui/js/event-delegation.js +102 -0
  230. package/src/superlocalmemory/ui/js/knowledge-graph.js +11 -11
  231. package/src/superlocalmemory/ui/js/math-health.js +1 -1
  232. package/src/superlocalmemory/ui/js/memories.js +15 -4
  233. package/src/superlocalmemory/ui/js/memory-chat.js +7 -7
  234. package/src/superlocalmemory/ui/js/ng-entities.js +6 -8
  235. package/src/superlocalmemory/ui/js/ng-ingestion.js +4 -4
  236. package/src/superlocalmemory/ui/js/ng-mesh.js +4 -9
  237. package/src/superlocalmemory/ui/js/ng-shell.js +8 -8
  238. package/src/superlocalmemory/ui/js/ng-skills.js +54 -2
  239. package/src/superlocalmemory/ui/js/od-agents.js +544 -0
  240. package/src/superlocalmemory/ui/js/od-auth-gate.js +257 -0
  241. package/src/superlocalmemory/ui/js/od-backup.js +780 -0
  242. package/src/superlocalmemory/ui/js/od-brain.js +779 -0
  243. package/src/superlocalmemory/ui/js/od-entities.js +579 -0
  244. package/src/superlocalmemory/ui/js/od-graph.js +593 -0
  245. package/src/superlocalmemory/ui/js/od-health.js +539 -0
  246. package/src/superlocalmemory/ui/js/od-mcp.js +508 -0
  247. package/src/superlocalmemory/ui/js/od-memories.js +887 -0
  248. package/src/superlocalmemory/ui/js/od-mesh.js +539 -0
  249. package/src/superlocalmemory/ui/js/od-operations.js +1250 -0
  250. package/src/superlocalmemory/ui/js/od-optimize.js +787 -0
  251. package/src/superlocalmemory/ui/js/od-settings.js +1053 -0
  252. package/src/superlocalmemory/ui/js/od-shell.js +593 -0
  253. package/src/superlocalmemory/ui/js/od-skills.js +573 -0
  254. package/src/superlocalmemory/ui/js/od-team.js +258 -0
  255. package/src/superlocalmemory/ui/js/profiles.js +159 -46
  256. package/src/superlocalmemory/ui/js/settings.js +2 -2
  257. package/src/superlocalmemory/ui/js/timeline.js +34 -5
  258. package/src/superlocalmemory/ui/js/trust-dashboard.js +2 -2
  259. package/src/superlocalmemory/vector/lancedb_backend.py +8 -6
  260. package/src/superlocalmemory/learning/behavioral_listener.py +0 -94
@@ -73,6 +73,38 @@ _LEGACY_PORT = 8767
73
73
  _ACTIVE_DAEMON_DESCRIPTOR: DaemonDescriptor | None = None
74
74
 
75
75
 
76
+ def _rbac_read_gate(request, app_state):
77
+ """RBAC gate for sensitive content reads. Returns a JSONResponse to reject,
78
+ or None to allow. No-op unless RBAC is active (>=1 user)."""
79
+ from fastapi.responses import JSONResponse
80
+ rbac = getattr(app_state, "rbac", None)
81
+ if rbac is None:
82
+ return None
83
+ try:
84
+ active = rbac.user_count() > 0
85
+ except Exception:
86
+ # Fail CLOSED: if we cannot determine RBAC state we must not silently
87
+ # allow reads (a DB error would otherwise open the whole read surface).
88
+ return JSONResponse(status_code=503,
89
+ content={"error": "authorization temporarily unavailable"})
90
+ if not active:
91
+ return None # single-operator install — reads are open
92
+ token = (request.headers.get("x-slm-user-session", "")
93
+ or (request.cookies.get("slm_session", "") if request.cookies else ""))
94
+ user = rbac.resolve_session(token) if token else None
95
+ if user is None:
96
+ if rbac.require_login():
97
+ return JSONResponse(status_code=401,
98
+ content={"error": "Login required to read memory."})
99
+ return None # owner/operator, personal mode
100
+ from superlocalmemory.access.rbac import Permission
101
+ from superlocalmemory.server.routes.helpers import get_active_profile
102
+ if rbac.has_permission(user["user_id"], get_active_profile(), Permission.READ):
103
+ return None
104
+ return JSONResponse(status_code=403,
105
+ content={"error": "Your role cannot read this workspace."})
106
+
107
+
76
108
  def _configured_daemon_port() -> int:
77
109
  """Return the configured bind port, falling back safely to the default."""
78
110
  try:
@@ -210,7 +242,10 @@ class EngineRecallAdapter:
210
242
  if r.fact.memory_id
211
243
  })
212
244
  memory_map = (
213
- self._engine._db.get_memory_content_batch(memory_ids)
245
+ self._engine._db.get_memory_content_batch(
246
+ memory_ids, self._engine.profile_id,
247
+ include_global=True, include_shared=True,
248
+ )
214
249
  if memory_ids else {}
215
250
  )
216
251
  # v3.6.6: same shared chokepoint as the HTTP route — identical output.
@@ -534,7 +569,7 @@ class ObserveBuffer:
534
569
  "captured": False,
535
570
  "durable": False,
536
571
  "reason": "durable admission failed",
537
- "error": str(exc),
572
+ "error": "internal error",
538
573
  }
539
574
 
540
575
  def _clear_seen(self) -> None:
@@ -643,6 +678,43 @@ async def _start_legacy_redirect(primary_port: int, legacy_port: int) -> None:
643
678
  # Lifespan
644
679
  # ---------------------------------------------------------------------------
645
680
 
681
+ def _warm_spreading_activation(engine, runtime) -> bool:
682
+ """Pre-warm the spreading-activation channel for the active profile.
683
+
684
+ The ``--fast`` warmup recalls deliberately skip spreading activation (and the
685
+ Mode-C remote agentic verification), which left the first FULL user recall
686
+ paying the cold graph-load cost: the ``graph_edges`` + ``association_edges``
687
+ page cache and the ``fact_importance`` PageRank/community cache. This warms
688
+ that channel directly — pure local graph work, never a remote/LLM call — so
689
+ the first full recall is warm. Fail-soft; returns True only when it ran.
690
+ """
691
+ try:
692
+ retr = getattr(engine, "_retrieval_engine", None)
693
+ sa = getattr(retr, "_spreading_activation", None) if retr else None
694
+ embedder = getattr(retr, "_embedder", None) if retr else None
695
+ if sa is None or embedder is None or not hasattr(embedder, "embed"):
696
+ return False
697
+ query_embedding = embedder.embed("memory recall performance")
698
+ if query_embedding is None:
699
+ return False
700
+ active_pid = getattr(engine, "profile_id", "default") or "default"
701
+ lease = runtime.operation_nowait() if runtime is not None else None
702
+ if lease is not None:
703
+ with lease as snap:
704
+ if snap is None:
705
+ return False
706
+ sa.search(query_embedding, profile_id=active_pid, top_k=7)
707
+ else:
708
+ sa.search(query_embedding, profile_id=active_pid, top_k=7)
709
+ logger.info(
710
+ "Spreading-activation graph pre-warmed for profile %s", active_pid,
711
+ )
712
+ return True
713
+ except Exception as exc:
714
+ logger.warning("Spreading-activation warmup failed (non-fatal): %s", exc)
715
+ return False
716
+
717
+
646
718
  @asynccontextmanager
647
719
  async def lifespan(application: FastAPI):
648
720
  """Initialize engine, workers, and optional services on startup."""
@@ -765,14 +837,10 @@ async def lifespan(application: FastAPI):
765
837
  engine = MemoryEngine(config)
766
838
  engine.initialize()
767
839
 
768
- # Enforce WAL mode for concurrent reads
769
- db = getattr(engine, '_db', None) or getattr(engine, '_storage', None)
770
- if db and hasattr(db, 'execute'):
771
- try:
772
- db.execute("PRAGMA journal_mode=WAL")
773
- db.execute("PRAGMA synchronous=NORMAL")
774
- except Exception:
775
- pass
840
+ # WAL is already established at DB creation (DatabaseManager._enable_wal
841
+ # / schema init). Re-asserting PRAGMA journal_mode=WAL here is a
842
+ # schema-level write on the shared connection that raced in-flight
843
+ # startup requests for the writer lock — removed (H-CONC-3).
776
844
 
777
845
  from superlocalmemory.server.profile_runtime import bind_profile_runtime
778
846
 
@@ -846,24 +914,28 @@ async def lifespan(application: FastAPI):
846
914
  try:
847
915
  import sqlite3 as _sqlite3
848
916
  _idx_conn = _sqlite3.connect(str(_memory_db))
849
- _idx_conn.execute("PRAGMA journal_mode=WAL")
850
- _idx_conn.execute(
851
- "CREATE INDEX IF NOT EXISTS idx_edges_source_weight "
852
- "ON graph_edges(profile_id, source_id, weight DESC)"
853
- )
854
- _idx_conn.execute(
855
- "CREATE INDEX IF NOT EXISTS idx_edges_target_weight "
856
- "ON graph_edges(profile_id, target_id, weight DESC)"
857
- )
858
- _idx_conn.execute(
859
- "CREATE INDEX IF NOT EXISTS idx_assoc_source_weight "
860
- "ON association_edges(profile_id, source_fact_id, weight DESC)"
861
- )
862
- _idx_conn.execute(
863
- "CREATE INDEX IF NOT EXISTS idx_assoc_target_weight "
864
- "ON association_edges(profile_id, target_fact_id, weight DESC)"
865
- )
866
- _idx_conn.close()
917
+ try:
918
+ _idx_conn.execute("PRAGMA journal_mode=WAL")
919
+ _idx_conn.execute(
920
+ "CREATE INDEX IF NOT EXISTS idx_edges_source_weight "
921
+ "ON graph_edges(profile_id, source_id, weight DESC)"
922
+ )
923
+ _idx_conn.execute(
924
+ "CREATE INDEX IF NOT EXISTS idx_edges_target_weight "
925
+ "ON graph_edges(profile_id, target_id, weight DESC)"
926
+ )
927
+ _idx_conn.execute(
928
+ "CREATE INDEX IF NOT EXISTS idx_assoc_source_weight "
929
+ "ON association_edges(profile_id, source_fact_id, weight DESC)"
930
+ )
931
+ _idx_conn.execute(
932
+ "CREATE INDEX IF NOT EXISTS idx_assoc_target_weight "
933
+ "ON association_edges(profile_id, target_fact_id, weight DESC)"
934
+ )
935
+ finally:
936
+ # CP-09: close even if an execute() raises, so the connection
937
+ # (and its file handle / shared DB lock) never leaks.
938
+ _idx_conn.close()
867
939
  except Exception as _idx_exc:
868
940
  logger.debug("SpreadingActivation covering indexes skipped: %s", _idx_exc)
869
941
 
@@ -909,6 +981,11 @@ async def lifespan(application: FastAPI):
909
981
 
910
982
  Runs after embedding warm (embed first so recall can use it).
911
983
  Named 'recall-warmup' so it appears clearly in thread dumps.
984
+
985
+ v3.x: each warmup query holds its own operation_nowait() lease
986
+ (previously one lease across both queries held for up to 20s,
987
+ blocking any profile switch issued at daemon start). A pending
988
+ transition preempts remaining queries; they complete on next boot.
912
989
  """
913
990
  import time as _t
914
991
  for _ in range(60):
@@ -920,9 +997,20 @@ async def lifespan(application: FastAPI):
920
997
  # Fire 2 warmup queries: one to load the graph page cache,
921
998
  # second to warm the reranker subprocess + all producers.
922
999
  # Without this, dashboard POST /api/search hits 11s cold.
923
- with profile_runtime.operation():
924
- for wq in ("memory recall performance", "context injection retrieval"):
925
- engine.recall(wq, limit=5)
1000
+ # Each query holds its own brief operation_nowait() lease so a
1001
+ # concurrent profile switch is not blocked by both recalls.
1002
+ for wq in ("memory recall performance", "context injection retrieval"):
1003
+ with profile_runtime.operation_nowait() as _snap:
1004
+ if _snap is None:
1005
+ logger.debug(
1006
+ "Recall warmup preempted by profile transition "
1007
+ "— skipping remaining warmup queries"
1008
+ )
1009
+ break
1010
+ engine.recall(wq, limit=5, fast=True) # short lease so a profile switch can drain within 5s
1011
+ # v3.8: the --fast recalls above skip spreading activation; warm
1012
+ # that channel directly so the first FULL recall is not cold.
1013
+ _warm_spreading_activation(engine, profile_runtime)
926
1014
  elapsed = round((_t.monotonic() - t0) * 1000)
927
1015
  logger.info(
928
1016
  "Recall engine pre-warmed in %dms", elapsed,
@@ -1079,9 +1167,50 @@ async def lifespan(application: FastAPI):
1079
1167
  else:
1080
1168
  application.state.mesh_broker = None
1081
1169
  except Exception as exc:
1082
- logger.debug("Mesh broker init: %s", exc)
1170
+ logger.warning("Mesh broker init failed: %s", exc)
1083
1171
  application.state.mesh_broker = None
1084
1172
 
1173
+ # RBAC / teams (C3): user identity + role enforcement over memory.db.
1174
+ # Additive — with zero users the daemon stays single-operator (owner).
1175
+ try:
1176
+ from superlocalmemory.access.rbac import RbacEngine
1177
+ rbac_db = config.db_path if config else state_path("memory.db")
1178
+ rbac_engine = RbacEngine(str(rbac_db))
1179
+ rbac_engine.purge_expired_sessions()
1180
+ application.state.rbac = rbac_engine
1181
+ logger.info("RBAC engine ready (users=%d)", rbac_engine.user_count())
1182
+ except Exception as exc:
1183
+ logger.warning("RBAC engine init failed: %s", exc)
1184
+ application.state.rbac = None
1185
+
1186
+ # Deployment config (v3.8.0) — read [deployment] from config.toml and wire.
1187
+ # Additive / fail-open: personal defaults are a no-op so existing installs
1188
+ # with no [deployment] section behave EXACTLY as before.
1189
+ # ENFORCE rule: only UPGRADE a setting, NEVER downgrade an already-stronger
1190
+ # runtime setting (e.g. RBAC require_login already True → leave it alone).
1191
+ try:
1192
+ from superlocalmemory.core.config import load_deployment_config
1193
+ deployment = load_deployment_config()
1194
+ application.state.deployment = deployment
1195
+ if deployment.require_login:
1196
+ _dep_rbac = getattr(application.state, "rbac", None)
1197
+ if _dep_rbac is not None and not _dep_rbac.require_login():
1198
+ _dep_rbac.set_require_login(True)
1199
+ logger.info(
1200
+ "Deployment: require_login enforced via enterprise deployment config"
1201
+ )
1202
+ # TODO: Wire deployment.pii_redaction → PII redaction subsystem (WP-10)
1203
+ # TODO: Wire deployment.retention_enabled → retention scheduler (WP-11)
1204
+ logger.info(
1205
+ "Deployment config loaded: mode=%s require_login=%s "
1206
+ "pii=%s retention=%s audit=%s",
1207
+ deployment.mode, deployment.require_login,
1208
+ deployment.pii_redaction, deployment.retention_enabled, deployment.audit,
1209
+ )
1210
+ except Exception as _dep_exc:
1211
+ logger.warning("Deployment config wire failed (non-fatal): %s", _dep_exc)
1212
+ application.state.deployment = None
1213
+
1085
1214
  # Start idle watchdog if configured
1086
1215
  idle_timeout = int(os.environ.get("SLM_DAEMON_IDLE_TIMEOUT", "0"))
1087
1216
  if config and hasattr(config, 'daemon_idle_timeout'):
@@ -1114,7 +1243,9 @@ async def lifespan(application: FastAPI):
1114
1243
  try:
1115
1244
  from superlocalmemory.cli.context_commands import build_default_adapters
1116
1245
  from superlocalmemory.hooks.sync_loop import schedule as _schedule_sync
1117
- _schedule_sync(build_default_adapters())
1246
+ # Keep the task handle so it can be cancelled at shutdown (H-CONC-2)
1247
+ # — otherwise adapter file I/O outlives the daemon.
1248
+ application.state._sync_task = _schedule_sync(build_default_adapters())
1118
1249
  except Exception as exc: # pragma: no cover — defensive
1119
1250
  logger.warning("cross-platform sync loop failed to start: %s", exc)
1120
1251
 
@@ -1221,6 +1352,19 @@ async def lifespan(application: FastAPI):
1221
1352
  )
1222
1353
  yield
1223
1354
 
1355
+ # Cancel the cross-platform sync loop (H-CONC-2) so adapter file I/O does
1356
+ # not outlive the daemon.
1357
+ try:
1358
+ _sync_task = getattr(application.state, "_sync_task", None)
1359
+ if _sync_task is not None and not _sync_task.done():
1360
+ _sync_task.cancel()
1361
+ try:
1362
+ await _sync_task
1363
+ except asyncio.CancelledError:
1364
+ pass
1365
+ except Exception: # pragma: no cover — defensive
1366
+ pass
1367
+
1224
1368
  # Cancel optimize metrics flush loop + run final flush before shutdown
1225
1369
  try:
1226
1370
  _flush_task = getattr(application.state, "_optimize_flush_task", None)
@@ -1463,7 +1607,9 @@ def create_app() -> FastAPI:
1463
1607
  allow_origins=[
1464
1608
  "http://localhost:8765", "http://127.0.0.1:8765",
1465
1609
  "http://localhost:8767", "http://127.0.0.1:8767", # legacy compat
1466
- "http://localhost:8417", "http://127.0.0.1:8417",
1610
+ # M-04 (3.7.9): removed the undocumented port 8417 origin — it had
1611
+ # no known consumer and let any local service on 8417 make
1612
+ # credentialed cross-origin requests to the API.
1467
1613
  ],
1468
1614
  allow_credentials=True,
1469
1615
  allow_methods=["GET", "POST", "PUT", "DELETE", "PATCH", "OPTIONS"],
@@ -1568,13 +1714,19 @@ def create_app() -> FastAPI:
1568
1714
  # toggled INDEPENDENTLY from the UI with no restart. UI save fires the
1569
1715
  # callback immediately; external edits are caught by the 2s watchdog.
1570
1716
  _opt_store.register_change_callback(_proxy.reload_from_config)
1571
- _opt_store.start_watchdog()
1572
1717
  logger.info(
1573
1718
  "optimize.proxy mounted on /v1/*, /v1beta/* port=8765 "
1574
1719
  "(runtime cache/compress hot-reload enabled)"
1575
1720
  )
1576
1721
  else:
1577
1722
  application.state.optimize_proxy = None
1723
+ # H1 fix: the config watchdog must run REGARDLESS of proxy state so
1724
+ # optimize.json edits (cache/compress toggles, and a future
1725
+ # proxy_enabled flip) are picked up at runtime. Previously it only
1726
+ # started when the proxy was already on, so a daemon that booted with
1727
+ # the proxy off never saw any optimize.json change. start_watchdog()
1728
+ # is idempotent.
1729
+ _opt_store.start_watchdog()
1578
1730
  except ImportError:
1579
1731
  application.state.optimize_proxy = None
1580
1732
  logger.debug("optimize.proxy not installed — skipping")
@@ -1610,6 +1762,14 @@ def create_app() -> FastAPI:
1610
1762
  if _mcp_allowed:
1611
1763
  from mcp.server.transport_security import TransportSecuritySettings
1612
1764
  if _mcp_allowed == "*":
1765
+ # M-05 (3.7.9): "*" fully disables DNS-rebinding protection.
1766
+ # Never silent — a convenience setting in a CI/Docker env must
1767
+ # not quietly expose the instance.
1768
+ logger.warning(
1769
+ "SLM_MCP_ALLOWED_HOSTS=* disables MCP DNS-rebinding "
1770
+ "protection entirely. Prefer an explicit host list; only "
1771
+ "use '*' on a trusted private network."
1772
+ )
1613
1773
  _mcp_fastmcp.settings.transport_security = TransportSecuritySettings(
1614
1774
  enable_dns_rebinding_protection=False,
1615
1775
  )
@@ -1688,22 +1848,47 @@ def _register_dashboard_routes(application: FastAPI) -> None:
1688
1848
  _write_limiter = RateLimiter(max_requests=_rl_write, window_seconds=_rl_window)
1689
1849
  _read_limiter = RateLimiter(max_requests=_rl_read, window_seconds=_rl_window)
1690
1850
 
1691
- # S9-DASH-09: loopback (127.0.0.1 / ::1) is always the dashboard
1692
- # itself — it legitimately makes many rapid reads (Brain + tabs +
1693
- # polling). Rate-limiting our own UI produces 429s that cascade
1694
- # into blank panels. CORS already restricts origins to localhost,
1695
- # so we don't lose the anti-abuse posture for external callers.
1696
- # v3.6.12 (issue #40): in SLM_REMOTE mode an allowlisted LAN browser is
1697
- # the user's own dashboard doing the same rapid polling, so it is exempt
1698
- # too (is_rate_limit_exempt) otherwise normal polling trips 429.
1851
+ # S9-DASH-09: loopback (127.0.0.1 / ::1) is the local dashboard and
1852
+ # makes many rapid reads (Brain + tabs + polling). L-03 (3.7.9): rather
1853
+ # than exempt loopback entirely, give it a *generous* limit — far above
1854
+ # normal UI polling so a runaway local agent's write flood is still
1855
+ # eventually throttled. A LAN browser allowlisted in SLM_REMOTE mode
1856
+ # (is_rate_limit_exempt, non-loopback) stays fully exempt.
1857
+ _lb_write = max(300, _rl_write * 10)
1858
+ _lb_read = max(2000, _rl_read * 20)
1859
+ _lb_write_limiter = RateLimiter(max_requests=_lb_write, window_seconds=_rl_window)
1860
+ _lb_read_limiter = RateLimiter(max_requests=_lb_read, window_seconds=_rl_window)
1861
+
1862
+ # Task #47: register the live limiters so the dashboard PUT
1863
+ # /api/v3/ratelimit reconfigures them at runtime (no restart), then
1864
+ # apply any persisted override from config.json.
1865
+ try:
1866
+ from superlocalmemory.infra.rate_limiter import (
1867
+ register_managed as _reg_rl, reset_managed as _reset_rl,
1868
+ )
1869
+ _reset_rl()
1870
+ _reg_rl("write", _write_limiter)
1871
+ _reg_rl("read", _read_limiter)
1872
+ _reg_rl("lb_write", _lb_write_limiter)
1873
+ _reg_rl("lb_read", _lb_read_limiter)
1874
+ from superlocalmemory.server.routes.ratelimit import (
1875
+ load_persisted_limits as _load_rl,
1876
+ )
1877
+ _load_rl()
1878
+ except Exception as _reg_exc: # pragma: no cover - defensive
1879
+ logger.debug("rate-limit runtime registration skipped: %s", _reg_exc)
1699
1880
 
1700
1881
  @application.middleware("http")
1701
1882
  async def rate_limit_middleware(request, call_next):
1702
1883
  client_ip = request.client.host if request.client else "unknown"
1703
- if is_rate_limit_exempt(client_ip):
1704
- return await call_next(request)
1705
1884
  is_write = request.method in ("POST", "PUT", "DELETE", "PATCH")
1706
- limiter = _write_limiter if is_write else _read_limiter
1885
+ loopback = client_ip in ("127.0.0.1", "::1")
1886
+ if not loopback and is_rate_limit_exempt(client_ip):
1887
+ return await call_next(request)
1888
+ if loopback:
1889
+ limiter = _lb_write_limiter if is_write else _lb_read_limiter
1890
+ else:
1891
+ limiter = _write_limiter if is_write else _read_limiter
1707
1892
  allowed, remaining = limiter.is_allowed(client_ip)
1708
1893
  if not allowed:
1709
1894
  from fastapi.responses import JSONResponse
@@ -1848,6 +2033,38 @@ def _register_dashboard_routes(application: FastAPI) -> None:
1848
2033
  )
1849
2034
  },
1850
2035
  )
2036
+ # RBAC read gate (C3/audit SEC-C-01): sensitive content reads must
2037
+ # also respect roles. Engages ONLY when RBAC is active (>=1 user) —
2038
+ # single-operator installs are unaffected. Owner (no session) reads
2039
+ # freely unless company mode (require_login) is on; a logged-in user
2040
+ # must hold READ on the active workspace.
2041
+ _p = request.url.path
2042
+ _is_sensitive_read = (
2043
+ (request.method == "GET" and _p.startswith((
2044
+ "/api/memories", "/api/facts", "/api/clusters", "/api/graph",
2045
+ "/api/v3/associations", "/api/v3/core-memory",
2046
+ "/api/v3/soft-prompts",
2047
+ # Config metadata GETs expose the install path (base_dir) and
2048
+ # LLM stack (provider/model/endpoint). Harmless to the loopback
2049
+ # owner (personal mode: gate is a no-op), but in company mode an
2050
+ # unauthenticated caller must not read them — same login gate as
2051
+ # content reads.
2052
+ "/api/v3/dashboard", "/api/v3/mode", "/api/v3/embedding/config",
2053
+ # SEC-M-02: the remaining config GETs also expose base_dir,
2054
+ # daemon port, and backend topology — gate them in company mode.
2055
+ "/api/v3/scope/config", "/api/v3/storage/config",
2056
+ "/api/v3/daemon/config", "/api/v3/mesh/config",
2057
+ "/api/v3/trust/config", "/api/v3/forgetting/config",
2058
+ # MCP profile metadata — reveals which tools agent clients
2059
+ # can invoke; consistent with other config GETs.
2060
+ "/api/v3/mcp/profiles")))
2061
+ or _p in ("/api/search", "/api/v3/recall/trace")
2062
+ or _p.startswith("/api/v3/recall")
2063
+ )
2064
+ if _is_sensitive_read:
2065
+ _resp = _rbac_read_gate(request, application.state)
2066
+ if _resp is not None:
2067
+ return _resp
1851
2068
  return await call_next(request)
1852
2069
  except Exception as _auth_exc:
1853
2070
  # v3.6.12 (failopen-1): security middleware must NEVER fail open silently.
@@ -1920,6 +2137,21 @@ def _register_dashboard_routes(application: FastAPI) -> None:
1920
2137
  application.include_router(v3_router)
1921
2138
  application.include_router(adapters_router)
1922
2139
 
2140
+ # RBAC / teams (C3) — user & role administration + login.
2141
+ try:
2142
+ from superlocalmemory.server.routes.rbac import router as rbac_router
2143
+ application.include_router(rbac_router)
2144
+ except ImportError:
2145
+ logger.debug("rbac_router not available")
2146
+
2147
+ # Config endpoints (storage, daemon, mesh, trust, forgetting)
2148
+ from superlocalmemory.server.routes.config_api import router as config_api_router
2149
+ application.include_router(config_api_router)
2150
+
2151
+ # Task #47: dashboard-editable rate limits (GET/PUT /api/v3/ratelimit)
2152
+ from superlocalmemory.server.routes.ratelimit import router as ratelimit_router
2153
+ application.include_router(ratelimit_router)
2154
+
1923
2155
  # v3.4.1 chat SSE
1924
2156
  for _mod_name in ("chat",):
1925
2157
  try:
@@ -1931,7 +2163,7 @@ def _register_dashboard_routes(application: FastAPI) -> None:
1931
2163
  pass
1932
2164
 
1933
2165
  # Optional routers
1934
- for _mod_name in ("learning", "lifecycle", "behavioral", "compliance", "insights", "timeline"):
2166
+ for _mod_name in ("learning", "lifecycle", "behavioral", "compliance", "insights", "timeline", "abstraction"):
1935
2167
  try:
1936
2168
  _mod = __import__(
1937
2169
  f"superlocalmemory.server.routes.{_mod_name}", fromlist=["router"],
@@ -2056,7 +2288,7 @@ def _register_daemon_routes(application: FastAPI) -> None:
2056
2288
  )
2057
2289
 
2058
2290
  @application.get("/health")
2059
- async def health():
2291
+ async def health(request: Request = None):
2060
2292
  _update_activity()
2061
2293
  # Non-blocking peek: report status without forcing a re-init.
2062
2294
  engine = getattr(application.state, "engine", None)
@@ -2090,6 +2322,27 @@ def _register_daemon_routes(application: FastAPI) -> None:
2090
2322
  from superlocalmemory.server.profile_runtime import get_profile_runtime
2091
2323
 
2092
2324
  profile_snapshot = get_profile_runtime(application.state).snapshot
2325
+ # H-05 (3.7.9): operational metadata (pid, daemon identity including
2326
+ # capability_fingerprint/instance_id, active profile, readiness detail)
2327
+ # is returned only to loopback callers. A remote or unauthenticated
2328
+ # probe receives liveness fields only, so it cannot harvest targeting
2329
+ # intel (version stays, since clients legitimately gate on it).
2330
+ public = {
2331
+ "status": "ok",
2332
+ "ready": fully_ready,
2333
+ # Runtime readiness is more precise than descriptor lifecycle.
2334
+ # A process can be alive and identity-valid while retrieval warms.
2335
+ "state": runtime_state,
2336
+ "version": getattr(application, 'version', 'unknown'),
2337
+ }
2338
+ # request is None only for direct internal/test calls (no HTTP client),
2339
+ # which are trusted; over HTTP FastAPI always injects the real Request.
2340
+ client_host = request.client.host if (request and request.client) else ""
2341
+ _trusted = request is None or client_host in (
2342
+ "127.0.0.1", "::1", "localhost", "testclient",
2343
+ )
2344
+ if not _trusted:
2345
+ return public
2093
2346
  return {
2094
2347
  "status": "ok",
2095
2348
  "ready": fully_ready,
@@ -2104,8 +2357,9 @@ def _register_daemon_routes(application: FastAPI) -> None:
2104
2357
  # health probe; includes self-heal counters.
2105
2358
  "recall_health": _recall_health,
2106
2359
  **(identity.public_health_fields() if identity is not None else {}),
2107
- # Runtime readiness is more precise than descriptor lifecycle.
2108
- # A process can be alive and identity-valid while retrieval warms.
2360
+ # runtime_state must come AFTER the identity spread so the live
2361
+ # readiness state wins over the descriptor's last-known lifecycle
2362
+ # value (which can be "starting").
2109
2363
  "state": runtime_state,
2110
2364
  "active_profile": profile_snapshot.profile_id,
2111
2365
  "profile_generation": profile_snapshot.generation,
@@ -2121,6 +2375,7 @@ def _register_daemon_routes(application: FastAPI) -> None:
2121
2375
  include_source: bool = False,
2122
2376
  include_global: bool | None = None,
2123
2377
  include_shared: bool | None = None,
2378
+ window: str = "",
2124
2379
  ):
2125
2380
  _update_activity()
2126
2381
  search_query = q or query # Accept both ?q= and ?query= for compatibility
@@ -2170,6 +2425,7 @@ def _register_daemon_routes(application: FastAPI) -> None:
2170
2425
  fast=fast,
2171
2426
  include_global=include_global,
2172
2427
  include_shared=include_shared,
2428
+ window=window or None,
2173
2429
  )
2174
2430
  # v3.4.26: return the same field shape as recall_worker so
2175
2431
  # MCP processes proxying through the daemon get recall_trace-
@@ -2179,7 +2435,10 @@ def _register_daemon_routes(application: FastAPI) -> None:
2179
2435
  if r.fact.memory_id
2180
2436
  })
2181
2437
  memory_map = (
2182
- engine._db.get_memory_content_batch(memory_ids)
2438
+ engine._db.get_memory_content_batch(
2439
+ memory_ids, engine.profile_id,
2440
+ include_global=True, include_shared=True,
2441
+ )
2183
2442
  if memory_ids else {}
2184
2443
  )
2185
2444
  # v3.6.6: single shared serialization chokepoint — budget + source
@@ -2404,7 +2663,8 @@ def _register_daemon_routes(application: FastAPI) -> None:
2404
2663
  maint_result = _run_maint(engine._db, engine._config, pid)
2405
2664
  results["langevin"] = {"updated": maint_result.get("updated", 0)}
2406
2665
  except Exception as exc:
2407
- results["langevin"] = {"error": str(exc)}
2666
+ logger.exception("maintenance langevin step failed")
2667
+ results["langevin"] = {"error": "internal error"}
2408
2668
  try:
2409
2669
  from superlocalmemory.math.ebbinghaus import EbbinghausCurve
2410
2670
  from superlocalmemory.learning.forgetting_scheduler import (
@@ -2416,7 +2676,8 @@ def _register_daemon_routes(application: FastAPI) -> None:
2416
2676
  )
2417
2677
  results["forgetting"] = sched.run_decay_cycle(pid, force=False)
2418
2678
  except Exception as exc:
2419
- results["forgetting"] = {"error": str(exc)}
2679
+ logger.exception("maintenance forgetting step failed")
2680
+ results["forgetting"] = {"error": "internal error"}
2420
2681
  try:
2421
2682
  from superlocalmemory.learning.consolidation_worker import (
2422
2683
  ConsolidationWorker,
@@ -2428,7 +2689,8 @@ def _register_daemon_routes(application: FastAPI) -> None:
2428
2689
  count = cw._generate_patterns(pid, False)
2429
2690
  results["behavioral"] = {"patterns_mined": count}
2430
2691
  except Exception as exc:
2431
- results["behavioral"] = {"error": str(exc)}
2692
+ logger.exception("maintenance behavioral step failed")
2693
+ results["behavioral"] = {"error": "internal error"}
2432
2694
  authorization.complete()
2433
2695
  return {"ok": True, "profile": pid, **results}
2434
2696
  except HTTPException:
@@ -2520,6 +2782,46 @@ def _register_daemon_routes(application: FastAPI) -> None:
2520
2782
  os.kill(os.getpid(), signal.SIGTERM)
2521
2783
  return {"status": "stopping"}
2522
2784
 
2785
+ @application.post("/api/daemon/restart")
2786
+ async def restart_daemon(request: Request):
2787
+ """Restart the daemon from the dashboard (non-technical end users).
2788
+
2789
+ Spawns a DETACHED ``slm restart`` process (its own session) so it
2790
+ survives this daemon being stopped, then returns immediately. The child
2791
+ stops this daemon and starts a fresh one via the standard 5-step
2792
+ pipeline (namespace lock → stop → start → warmup → verify).
2793
+ """
2794
+ # Dashboard-callable: accept the install-token principal (same auth as
2795
+ # /remember and other dashboard mutations), not just the private CLI
2796
+ # capability — a non-technical user restarts from their own dashboard.
2797
+ _require_write_actor(request)
2798
+ import subprocess
2799
+ import sys as _sys
2800
+ from superlocalmemory.core.platform_utils import popen_platform_kwargs
2801
+
2802
+ logger.info("Daemon restart requested via API")
2803
+ _observe_buffer.flush_sync()
2804
+ try:
2805
+ subprocess.Popen(
2806
+ [_sys.executable, "-m", "superlocalmemory.cli.main", "restart",
2807
+ "--json"],
2808
+ stdout=subprocess.DEVNULL,
2809
+ stderr=subprocess.DEVNULL,
2810
+ # CP-04: use the shared platform kwargs (CREATE_NO_WINDOW on
2811
+ # Windows, start_new_session on POSIX) like every other Popen so
2812
+ # a GUI-triggered restart never flashes a console window.
2813
+ # close_fds defaults to True on all platforms.
2814
+ **popen_platform_kwargs(),
2815
+ )
2816
+ except Exception:
2817
+ logger.exception("Failed to spawn restart process")
2818
+ return {"success": False, "error": "Could not initiate restart"}
2819
+ return {
2820
+ "success": True,
2821
+ "status": "restarting",
2822
+ "message": "Daemon restart initiated — it will be back in a few seconds.",
2823
+ }
2824
+
2523
2825
  @application.post("/session/open")
2524
2826
  async def session_open(req: SessionOpenRequest, request: Request):
2525
2827
  """#49: Open a session locally — warm recall context with no model
@@ -2675,7 +2977,17 @@ def _materializer_actor_id() -> str:
2675
2977
 
2676
2978
 
2677
2979
  def _run_materializer_operation(runtime, engine_supplier, operation):
2678
- """Run one bounded background unit against an admitted engine snapshot."""
2980
+ """Run one bounded background unit against an admitted engine snapshot.
2981
+
2982
+ Cooperative preemption: if a profile transition is already in progress,
2983
+ skip this materialization cycle entirely and return None. The caller's
2984
+ loop retries on the next iteration, by which time the switch has committed
2985
+ and a clean admission is available. This prevents the materializer from
2986
+ holding the operation lease during the transition drain window.
2987
+ """
2988
+ # Writer-priority: don't acquire a new lease when a transition is draining.
2989
+ if runtime is not None and runtime.transitioning:
2990
+ return None
2679
2991
  with runtime.operation():
2680
2992
  # Resolve the engine only after admission. A concurrent mode/provider
2681
2993
  # reconfiguration may have replaced the module-level engine while this
@@ -2831,7 +3143,11 @@ def _start_pending_materializer() -> None:
2831
3143
  ),
2832
3144
  )
2833
3145
  durable_complete, durable_failed = cycle_result or (0, 0)
2834
- pending = get_pending(limit=50)
3146
+ # Only backfill legacy pending items enqueued under the active
3147
+ # profile — never materialize another profile's queued memory
3148
+ # under whichever profile happens to be active now.
3149
+ _active_profile = runtime.snapshot.profile_id
3150
+ pending = get_pending(limit=50, profile_id=_active_profile)
2835
3151
  if not pending and not durable_complete and not durable_failed:
2836
3152
  time.sleep(1.0)
2837
3153
  continue
@@ -2915,6 +3231,18 @@ def start_server(port: int = _DEFAULT_PORT) -> None:
2915
3231
  or os.environ.get("SLM_HOST")
2916
3232
  or "127.0.0.1"
2917
3233
  )
3234
+ # M-01 / L-02 (3.7.9): binding beyond loopback exposes the write API to the
3235
+ # network, where the loopback trusted-actor bypass no longer protects it.
3236
+ # Warn loudly unless the operator has opted into credential enforcement.
3237
+ if bind_host not in ("127.0.0.1", "::1", "localhost") and \
3238
+ os.environ.get("SLM_REQUIRE_CREDENTIALS") != "1":
3239
+ logger.warning(
3240
+ "SLM daemon binding to %s (non-loopback): the write API is "
3241
+ "reachable from the network but SLM_REQUIRE_CREDENTIALS is not set "
3242
+ "and API-key auth may be off. A remote caller could write without "
3243
+ "credentials. Set SLM_REQUIRE_CREDENTIALS=1 and configure an API "
3244
+ "key before exposing this instance.", bind_host,
3245
+ )
2918
3246
  listener = socket.socket(socket.AF_INET, socket.SOCK_STREAM)
2919
3247
  # This handles a just-closed connection in TIME_WAIT. It is safe only
2920
3248
  # with the active-listener probe immediately below; without that guard,