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
@@ -31,6 +31,7 @@ import json
31
31
  import logging
32
32
  import os
33
33
  import signal
34
+ import sqlite3
34
35
  import sys
35
36
  import threading
36
37
  import time
@@ -65,12 +66,83 @@ from superlocalmemory.infra.data_root import (
65
66
  canonical_data_root,
66
67
  state_path,
67
68
  )
69
+ from superlocalmemory.learning.source_quality import (
70
+ SourceQualityRepairUnavailable,
71
+ enumerate_source_quality_repair_profiles,
72
+ repair_historical_source_quality,
73
+ )
68
74
 
69
75
  logger = logging.getLogger("superlocalmemory.unified_daemon")
70
76
 
71
77
  _DEFAULT_PORT = 8765
72
78
  _LEGACY_PORT = 8767
73
79
  _ACTIVE_DAEMON_DESCRIPTOR: DaemonDescriptor | None = None
80
+ _SOURCE_QUALITY_MAX_BATCH_SIZE = 250
81
+ _SOURCE_QUALITY_PROFILE_REFRESH_SECONDS = 60.0
82
+ _FACT_ENTITY_REPAIR_MIN_RETRY_SECONDS = 0.05
83
+ _FACT_ENTITY_REPAIR_MAX_RETRY_SECONDS = 30.0
84
+ # ``wait=true`` is a compatibility affordance, never permission to hold the
85
+ # ASGI event loop hostage to a local LLM. Normal clients omit it and receive
86
+ # an immediate durable/queryable receipt; explicit waiters get this small
87
+ # completion window, then the M018 materializer continues in the background.
88
+ _REMEMBER_ENRICHMENT_WAIT_SECONDS = 0.75
89
+ _SENSITIVE_READ_PREFIXES = (
90
+ "/api/memories", "/api/facts", "/api/clusters", "/api/graph",
91
+ "/api/v3/associations", "/api/v3/core-memory",
92
+ "/api/v3/soft-prompts", "/api/v3/dashboard", "/api/v3/mode",
93
+ "/api/v3/embedding/config", "/api/v3/scope/config",
94
+ "/api/v3/storage/config", "/api/v3/daemon/config",
95
+ "/api/v3/mesh/config", "/api/v3/trust/config",
96
+ "/api/v3/forgetting/config", "/api/v3/mcp/profiles",
97
+ "/api/learning", "/api/behavioral",
98
+ )
99
+ _SENSITIVE_READ_EXACT_PATHS = (
100
+ "/api/search", "/api/v3/recall/trace", "/api/patterns",
101
+ "/api/feedback/stats", "/api/stats", "/api/timeline",
102
+ )
103
+
104
+
105
+ def _is_sensitive_dashboard_read(method: str, path: str) -> bool:
106
+ return (
107
+ method == "GET"
108
+ and (
109
+ path.startswith(_SENSITIVE_READ_PREFIXES)
110
+ or path in _SENSITIVE_READ_EXACT_PATHS
111
+ or path.startswith("/api/v3/recall")
112
+ )
113
+ )
114
+
115
+
116
+ def _rbac_read_gate(request, app_state):
117
+ """RBAC gate for sensitive content reads. Returns a JSONResponse to reject,
118
+ or None to allow. No-op unless RBAC is active (>=1 user)."""
119
+ from fastapi.responses import JSONResponse
120
+ rbac = getattr(app_state, "rbac", None)
121
+ if rbac is None:
122
+ return None
123
+ try:
124
+ active = rbac.user_count() > 0
125
+ except Exception:
126
+ # Fail CLOSED: if we cannot determine RBAC state we must not silently
127
+ # allow reads (a DB error would otherwise open the whole read surface).
128
+ return JSONResponse(status_code=503,
129
+ content={"error": "authorization temporarily unavailable"})
130
+ if not active:
131
+ return None # single-operator install — reads are open
132
+ token = (request.headers.get("x-slm-user-session", "")
133
+ or (request.cookies.get("slm_session", "") if request.cookies else ""))
134
+ user = rbac.resolve_session(token) if token else None
135
+ if user is None:
136
+ if rbac.require_login():
137
+ return JSONResponse(status_code=401,
138
+ content={"error": "Login required to read memory."})
139
+ return None # owner/operator, personal mode
140
+ from superlocalmemory.access.rbac import Permission
141
+ from superlocalmemory.server.routes.helpers import get_active_profile
142
+ if rbac.has_permission(user["user_id"], get_active_profile(), Permission.READ):
143
+ return None
144
+ return JSONResponse(status_code=403,
145
+ content={"error": "Your role cannot read this workspace."})
74
146
 
75
147
 
76
148
  def _configured_daemon_port() -> int:
@@ -210,7 +282,10 @@ class EngineRecallAdapter:
210
282
  if r.fact.memory_id
211
283
  })
212
284
  memory_map = (
213
- self._engine._db.get_memory_content_batch(memory_ids)
285
+ self._engine._db.get_memory_content_batch(
286
+ memory_ids, self._engine.profile_id,
287
+ include_global=True, include_shared=True,
288
+ )
214
289
  if memory_ids else {}
215
290
  )
216
291
  # v3.6.6: same shared chokepoint as the HTTP route — identical output.
@@ -534,7 +609,7 @@ class ObserveBuffer:
534
609
  "captured": False,
535
610
  "durable": False,
536
611
  "reason": "durable admission failed",
537
- "error": str(exc),
612
+ "error": "internal error",
538
613
  }
539
614
 
540
615
  def _clear_seen(self) -> None:
@@ -643,6 +718,384 @@ async def _start_legacy_redirect(primary_port: int, legacy_port: int) -> None:
643
718
  # Lifespan
644
719
  # ---------------------------------------------------------------------------
645
720
 
721
+ def _warm_spreading_activation(engine, runtime) -> bool:
722
+ """Pre-warm the spreading-activation channel for the active profile.
723
+
724
+ The ``--fast`` warmup recalls deliberately skip spreading activation (and the
725
+ Mode-C remote agentic verification), which left the first FULL user recall
726
+ paying the cold graph-load cost: the ``graph_edges`` + ``association_edges``
727
+ page cache and the ``fact_importance`` PageRank/community cache. This warms
728
+ that channel directly — pure local graph work, never a remote/LLM call — so
729
+ the first full recall is warm. Fail-soft; returns True only when it ran.
730
+ """
731
+ try:
732
+ retr = getattr(engine, "_retrieval_engine", None)
733
+ sa = getattr(retr, "_spreading_activation", None) if retr else None
734
+ embedder = getattr(retr, "_embedder", None) if retr else None
735
+ if sa is None or embedder is None or not hasattr(embedder, "embed"):
736
+ return False
737
+ query_embedding = embedder.embed("memory recall performance")
738
+ if query_embedding is None:
739
+ return False
740
+ active_pid = getattr(engine, "profile_id", "default") or "default"
741
+ lease = runtime.operation_nowait() if runtime is not None else None
742
+ if lease is not None:
743
+ with lease as snap:
744
+ if snap is None:
745
+ return False
746
+ sa.search(query_embedding, profile_id=active_pid, top_k=7)
747
+ else:
748
+ sa.search(query_embedding, profile_id=active_pid, top_k=7)
749
+ logger.info(
750
+ "Spreading-activation graph pre-warmed for profile %s", active_pid,
751
+ )
752
+ return True
753
+ except Exception as exc:
754
+ logger.warning("Spreading-activation warmup failed (non-fatal): %s", exc)
755
+ return False
756
+
757
+
758
+ def _set_source_quality_repair_status(application, **updates) -> dict:
759
+ current = getattr(
760
+ application.state, "source_quality_repair_status", {},
761
+ )
762
+ status = {**current, **updates}
763
+ application.state.source_quality_repair_status = status
764
+ return status
765
+
766
+
767
+ def _schedule_fact_entity_association_repair(
768
+ application,
769
+ memory_db_path: Path,
770
+ *,
771
+ batch_size: int = 250,
772
+ tick_seconds: float = 1.0,
773
+ ) -> asyncio.Task:
774
+ """Schedule bounded M028 backfill only after readiness is published."""
775
+ from superlocalmemory.storage.migrations.M028_fact_entity_associations import (
776
+ get_repair_status,
777
+ )
778
+
779
+ durable = get_repair_status(Path(memory_db_path))
780
+ application.state.fact_entity_association_repair_status = {
781
+ **durable,
782
+ "source": "startup_background_repair",
783
+ "batch_size": batch_size,
784
+ }
785
+ task = asyncio.create_task(
786
+ _fact_entity_association_repair_loop(
787
+ application,
788
+ Path(memory_db_path),
789
+ batch_size=batch_size,
790
+ tick_seconds=tick_seconds,
791
+ ),
792
+ name="fact-entity-association-upgrade-repair",
793
+ )
794
+ application.state.fact_entity_association_repair_task = task
795
+ return task
796
+
797
+
798
+ async def _fact_entity_association_repair_loop(
799
+ application,
800
+ memory_db_path: Path,
801
+ *,
802
+ batch_size: int,
803
+ tick_seconds: float,
804
+ ) -> None:
805
+ from superlocalmemory.storage.migrations.M028_fact_entity_associations import (
806
+ get_repair_status,
807
+ repair_fact_entity_associations,
808
+ )
809
+
810
+ consecutive_failures = 0
811
+ try:
812
+ while True:
813
+ try:
814
+ await asyncio.to_thread(
815
+ repair_fact_entity_associations,
816
+ memory_db_path,
817
+ batch_size=batch_size,
818
+ max_batches=1,
819
+ )
820
+ durable = await asyncio.to_thread(
821
+ get_repair_status, memory_db_path,
822
+ )
823
+ except sqlite3.Error as exc:
824
+ consecutive_failures += 1
825
+ retry_delay = min(
826
+ _FACT_ENTITY_REPAIR_MAX_RETRY_SECONDS,
827
+ max(
828
+ _FACT_ENTITY_REPAIR_MIN_RETRY_SECONDS,
829
+ float(tick_seconds),
830
+ ) * (2 ** min(consecutive_failures - 1, 10)),
831
+ )
832
+ try:
833
+ durable = await asyncio.to_thread(
834
+ get_repair_status, memory_db_path,
835
+ )
836
+ except sqlite3.Error:
837
+ durable = getattr(
838
+ application.state,
839
+ "fact_entity_association_repair_status",
840
+ {},
841
+ )
842
+ application.state.fact_entity_association_repair_status = {
843
+ **durable,
844
+ "state": "retrying",
845
+ "source": "startup_background_repair",
846
+ "batch_size": batch_size,
847
+ "last_error": (
848
+ durable.get("last_error") or type(exc).__name__
849
+ ),
850
+ "retry_attempt": consecutive_failures,
851
+ "retry_delay_seconds": retry_delay,
852
+ }
853
+ await asyncio.sleep(retry_delay)
854
+ continue
855
+
856
+ consecutive_failures = 0
857
+ application.state.fact_entity_association_repair_status = {
858
+ **durable,
859
+ "source": "startup_background_repair",
860
+ "batch_size": batch_size,
861
+ "retry_attempt": 0,
862
+ "retry_delay_seconds": 0.0,
863
+ }
864
+ if durable["state"] == "complete":
865
+ return
866
+ await asyncio.sleep(max(0.0, float(tick_seconds)))
867
+ except asyncio.CancelledError:
868
+ raise
869
+ except Exception as exc:
870
+ durable = await asyncio.to_thread(get_repair_status, memory_db_path)
871
+ application.state.fact_entity_association_repair_status = {
872
+ **durable,
873
+ "source": "startup_background_repair",
874
+ "batch_size": batch_size,
875
+ "last_error": durable.get("last_error") or type(exc).__name__,
876
+ }
877
+
878
+
879
+ async def _cancel_fact_entity_association_repair(application) -> None:
880
+ task = getattr(
881
+ application.state, "fact_entity_association_repair_task", None,
882
+ )
883
+ if task is None:
884
+ return
885
+ if not task.done():
886
+ task.cancel()
887
+ try:
888
+ await task
889
+ except asyncio.CancelledError:
890
+ pass
891
+
892
+
893
+ def _schedule_source_quality_repair(
894
+ application,
895
+ memory_db_path: Path,
896
+ learning_db_path: Path,
897
+ *,
898
+ batch_size: int = 25,
899
+ tick_seconds: float = 1.0,
900
+ ) -> asyncio.Task:
901
+ """Schedule post-readiness repair without awaiting historical DB work."""
902
+ _set_source_quality_repair_status(
903
+ application,
904
+ state="scheduled",
905
+ source="startup_background_repair",
906
+ batch_size=batch_size,
907
+ profiles=[],
908
+ completed_profiles=[],
909
+ profile_results={},
910
+ batches_completed=0,
911
+ scanned=0,
912
+ observations=0,
913
+ last_error=None,
914
+ )
915
+ task = asyncio.create_task(
916
+ _source_quality_repair_loop(
917
+ application,
918
+ Path(memory_db_path),
919
+ Path(learning_db_path),
920
+ batch_size=batch_size,
921
+ tick_seconds=tick_seconds,
922
+ ),
923
+ name="source-quality-upgrade-repair",
924
+ )
925
+ application.state.source_quality_repair_task = task
926
+ return task
927
+
928
+
929
+ async def _repair_one_source_quality_profile(
930
+ memory_db_path: Path,
931
+ learning_db_path: Path,
932
+ profile_id: str,
933
+ batch_size: int,
934
+ ) -> dict[str, int | bool]:
935
+ worker = asyncio.create_task(
936
+ asyncio.to_thread(
937
+ repair_historical_source_quality,
938
+ memory_db_path,
939
+ learning_db_path,
940
+ profile_id,
941
+ batch_size=batch_size,
942
+ max_batches=1,
943
+ ),
944
+ name=f"source-quality-repair-batch-{profile_id}",
945
+ )
946
+ try:
947
+ return await asyncio.shield(worker)
948
+ except asyncio.CancelledError:
949
+ # Cancellation cannot stop a running worker thread. Await the bounded
950
+ # batch so SQLite writes finish before daemon teardown closes storage.
951
+ await worker
952
+ raise
953
+
954
+
955
+ def _record_source_quality_repair_result(
956
+ application,
957
+ profile_id: str,
958
+ result: dict[str, int | bool],
959
+ ) -> bool:
960
+ current = getattr(
961
+ application.state, "source_quality_repair_status", {},
962
+ )
963
+ completed = set(current.get("completed_profiles", []))
964
+ if result["complete"]:
965
+ completed.add(profile_id)
966
+ results = {
967
+ **current.get("profile_results", {}),
968
+ profile_id: result,
969
+ }
970
+ _set_source_quality_repair_status(
971
+ application,
972
+ profile_results=results,
973
+ completed_profiles=sorted(completed),
974
+ batches_completed=int(current.get("batches_completed", 0)) + 1,
975
+ scanned=int(current.get("scanned", 0)) + int(result["scanned"]),
976
+ observations=int(current.get("observations", 0))
977
+ + int(result["observations"]),
978
+ )
979
+ return not bool(result["complete"])
980
+
981
+
982
+ async def _source_quality_repair_tick(
983
+ application,
984
+ memory_db_path: Path,
985
+ learning_db_path: Path,
986
+ profiles: list[str],
987
+ *,
988
+ batch_size: int,
989
+ ) -> list[str]:
990
+ _set_source_quality_repair_status(
991
+ application,
992
+ state="running",
993
+ profiles=profiles,
994
+ current_batch_size=batch_size,
995
+ last_error=None,
996
+ )
997
+ pending = []
998
+ for profile_id in profiles:
999
+ result = await _repair_one_source_quality_profile(
1000
+ memory_db_path, learning_db_path, profile_id, batch_size,
1001
+ )
1002
+ if _record_source_quality_repair_result(
1003
+ application, profile_id, result,
1004
+ ):
1005
+ pending.append(profile_id)
1006
+ await asyncio.sleep(0)
1007
+ return pending
1008
+
1009
+
1010
+ async def _discover_source_quality_profiles(
1011
+ memory_db_path: Path,
1012
+ pending: list[str] | None,
1013
+ completed: list[str],
1014
+ ) -> list[str]:
1015
+ discovered = await asyncio.to_thread(
1016
+ enumerate_source_quality_repair_profiles,
1017
+ memory_db_path,
1018
+ )
1019
+ return sorted(
1020
+ (set(pending or []) | set(discovered)) - set(completed),
1021
+ )
1022
+
1023
+
1024
+ async def _source_quality_repair_loop(
1025
+ application,
1026
+ memory_db_path: Path,
1027
+ learning_db_path: Path,
1028
+ *,
1029
+ batch_size: int,
1030
+ tick_seconds: float,
1031
+ ) -> None:
1032
+ """Run one resumable repair batch per discovered profile and tick."""
1033
+ pending: list[str] | None = None
1034
+ next_refresh = 0.0
1035
+ successful_ticks = 0
1036
+ try:
1037
+ while True:
1038
+ try:
1039
+ now = time.monotonic()
1040
+ if pending is None or now >= next_refresh:
1041
+ status = getattr(
1042
+ application.state,
1043
+ "source_quality_repair_status",
1044
+ {},
1045
+ )
1046
+ pending = await _discover_source_quality_profiles(
1047
+ memory_db_path,
1048
+ pending,
1049
+ status.get("completed_profiles", []),
1050
+ )
1051
+ next_refresh = (
1052
+ now + _SOURCE_QUALITY_PROFILE_REFRESH_SECONDS
1053
+ )
1054
+ adaptive_batch = min(
1055
+ _SOURCE_QUALITY_MAX_BATCH_SIZE,
1056
+ batch_size * (2 ** min(successful_ticks, 4)),
1057
+ )
1058
+ pending = await _source_quality_repair_tick(
1059
+ application,
1060
+ memory_db_path,
1061
+ learning_db_path,
1062
+ pending,
1063
+ batch_size=adaptive_batch,
1064
+ )
1065
+ except (SourceQualityRepairUnavailable, sqlite3.Error):
1066
+ _set_source_quality_repair_status(
1067
+ application,
1068
+ state="retrying",
1069
+ last_error="storage_temporarily_unavailable",
1070
+ )
1071
+ else:
1072
+ successful_ticks += 1
1073
+ if pending == []:
1074
+ _set_source_quality_repair_status(application, state="complete")
1075
+ return
1076
+ await asyncio.sleep(max(0.0, float(tick_seconds)))
1077
+ except asyncio.CancelledError:
1078
+ _set_source_quality_repair_status(application, state="cancelled")
1079
+ raise
1080
+ except Exception as exc:
1081
+ logger.warning("source-quality startup repair failed: %s", exc)
1082
+ _set_source_quality_repair_status(
1083
+ application, state="failed", last_error=type(exc).__name__,
1084
+ )
1085
+
1086
+
1087
+ async def _cancel_source_quality_repair(application) -> None:
1088
+ task = getattr(application.state, "source_quality_repair_task", None)
1089
+ if task is None:
1090
+ return
1091
+ if not task.done():
1092
+ task.cancel()
1093
+ try:
1094
+ await task
1095
+ except asyncio.CancelledError:
1096
+ pass
1097
+
1098
+
646
1099
  @asynccontextmanager
647
1100
  async def lifespan(application: FastAPI):
648
1101
  """Initialize engine, workers, and optional services on startup."""
@@ -765,14 +1218,10 @@ async def lifespan(application: FastAPI):
765
1218
  engine = MemoryEngine(config)
766
1219
  engine.initialize()
767
1220
 
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
1221
+ # WAL is already established at DB creation (DatabaseManager._enable_wal
1222
+ # / schema init). Re-asserting PRAGMA journal_mode=WAL here is a
1223
+ # schema-level write on the shared connection that raced in-flight
1224
+ # startup requests for the writer lock — removed (H-CONC-3).
776
1225
 
777
1226
  from superlocalmemory.server.profile_runtime import bind_profile_runtime
778
1227
 
@@ -846,24 +1295,28 @@ async def lifespan(application: FastAPI):
846
1295
  try:
847
1296
  import sqlite3 as _sqlite3
848
1297
  _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()
1298
+ try:
1299
+ _idx_conn.execute("PRAGMA journal_mode=WAL")
1300
+ _idx_conn.execute(
1301
+ "CREATE INDEX IF NOT EXISTS idx_edges_source_weight "
1302
+ "ON graph_edges(profile_id, source_id, weight DESC)"
1303
+ )
1304
+ _idx_conn.execute(
1305
+ "CREATE INDEX IF NOT EXISTS idx_edges_target_weight "
1306
+ "ON graph_edges(profile_id, target_id, weight DESC)"
1307
+ )
1308
+ _idx_conn.execute(
1309
+ "CREATE INDEX IF NOT EXISTS idx_assoc_source_weight "
1310
+ "ON association_edges(profile_id, source_fact_id, weight DESC)"
1311
+ )
1312
+ _idx_conn.execute(
1313
+ "CREATE INDEX IF NOT EXISTS idx_assoc_target_weight "
1314
+ "ON association_edges(profile_id, target_fact_id, weight DESC)"
1315
+ )
1316
+ finally:
1317
+ # CP-09: close even if an execute() raises, so the connection
1318
+ # (and its file handle / shared DB lock) never leaks.
1319
+ _idx_conn.close()
867
1320
  except Exception as _idx_exc:
868
1321
  logger.debug("SpreadingActivation covering indexes skipped: %s", _idx_exc)
869
1322
 
@@ -909,6 +1362,11 @@ async def lifespan(application: FastAPI):
909
1362
 
910
1363
  Runs after embedding warm (embed first so recall can use it).
911
1364
  Named 'recall-warmup' so it appears clearly in thread dumps.
1365
+
1366
+ v3.x: each warmup query holds its own operation_nowait() lease
1367
+ (previously one lease across both queries held for up to 20s,
1368
+ blocking any profile switch issued at daemon start). A pending
1369
+ transition preempts remaining queries; they complete on next boot.
912
1370
  """
913
1371
  import time as _t
914
1372
  for _ in range(60):
@@ -920,9 +1378,20 @@ async def lifespan(application: FastAPI):
920
1378
  # Fire 2 warmup queries: one to load the graph page cache,
921
1379
  # second to warm the reranker subprocess + all producers.
922
1380
  # 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)
1381
+ # Each query holds its own brief operation_nowait() lease so a
1382
+ # concurrent profile switch is not blocked by both recalls.
1383
+ for wq in ("memory recall performance", "context injection retrieval"):
1384
+ with profile_runtime.operation_nowait() as _snap:
1385
+ if _snap is None:
1386
+ logger.debug(
1387
+ "Recall warmup preempted by profile transition "
1388
+ "— skipping remaining warmup queries"
1389
+ )
1390
+ break
1391
+ engine.recall(wq, limit=5, fast=True) # short lease so a profile switch can drain within 5s
1392
+ # v3.8: the --fast recalls above skip spreading activation; warm
1393
+ # that channel directly so the first FULL recall is not cold.
1394
+ _warm_spreading_activation(engine, profile_runtime)
926
1395
  elapsed = round((_t.monotonic() - t0) * 1000)
927
1396
  logger.info(
928
1397
  "Recall engine pre-warmed in %dms", elapsed,
@@ -1079,9 +1548,50 @@ async def lifespan(application: FastAPI):
1079
1548
  else:
1080
1549
  application.state.mesh_broker = None
1081
1550
  except Exception as exc:
1082
- logger.debug("Mesh broker init: %s", exc)
1551
+ logger.warning("Mesh broker init failed: %s", exc)
1083
1552
  application.state.mesh_broker = None
1084
1553
 
1554
+ # RBAC / teams (C3): user identity + role enforcement over memory.db.
1555
+ # Additive — with zero users the daemon stays single-operator (owner).
1556
+ try:
1557
+ from superlocalmemory.access.rbac import RbacEngine
1558
+ rbac_db = config.db_path if config else state_path("memory.db")
1559
+ rbac_engine = RbacEngine(str(rbac_db))
1560
+ rbac_engine.purge_expired_sessions()
1561
+ application.state.rbac = rbac_engine
1562
+ logger.info("RBAC engine ready (users=%d)", rbac_engine.user_count())
1563
+ except Exception as exc:
1564
+ logger.warning("RBAC engine init failed: %s", exc)
1565
+ application.state.rbac = None
1566
+
1567
+ # Deployment config (v3.8.0) — read [deployment] from config.toml and wire.
1568
+ # Additive / fail-open: personal defaults are a no-op so existing installs
1569
+ # with no [deployment] section behave EXACTLY as before.
1570
+ # ENFORCE rule: only UPGRADE a setting, NEVER downgrade an already-stronger
1571
+ # runtime setting (e.g. RBAC require_login already True → leave it alone).
1572
+ try:
1573
+ from superlocalmemory.core.config import load_deployment_config
1574
+ deployment = load_deployment_config()
1575
+ application.state.deployment = deployment
1576
+ if deployment.require_login:
1577
+ _dep_rbac = getattr(application.state, "rbac", None)
1578
+ if _dep_rbac is not None and not _dep_rbac.require_login():
1579
+ _dep_rbac.set_require_login(True)
1580
+ logger.info(
1581
+ "Deployment: require_login enforced via enterprise deployment config"
1582
+ )
1583
+ # TODO: Wire deployment.pii_redaction → PII redaction subsystem (WP-10)
1584
+ # TODO: Wire deployment.retention_enabled → retention scheduler (WP-11)
1585
+ logger.info(
1586
+ "Deployment config loaded: mode=%s require_login=%s "
1587
+ "pii=%s retention=%s audit=%s",
1588
+ deployment.mode, deployment.require_login,
1589
+ deployment.pii_redaction, deployment.retention_enabled, deployment.audit,
1590
+ )
1591
+ except Exception as _dep_exc:
1592
+ logger.warning("Deployment config wire failed (non-fatal): %s", _dep_exc)
1593
+ application.state.deployment = None
1594
+
1085
1595
  # Start idle watchdog if configured
1086
1596
  idle_timeout = int(os.environ.get("SLM_DAEMON_IDLE_TIMEOUT", "0"))
1087
1597
  if config and hasattr(config, 'daemon_idle_timeout'):
@@ -1114,7 +1624,9 @@ async def lifespan(application: FastAPI):
1114
1624
  try:
1115
1625
  from superlocalmemory.cli.context_commands import build_default_adapters
1116
1626
  from superlocalmemory.hooks.sync_loop import schedule as _schedule_sync
1117
- _schedule_sync(build_default_adapters())
1627
+ # Keep the task handle so it can be cancelled at shutdown (H-CONC-2)
1628
+ # — otherwise adapter file I/O outlives the daemon.
1629
+ application.state._sync_task = _schedule_sync(build_default_adapters())
1118
1630
  except Exception as exc: # pragma: no cover — defensive
1119
1631
  logger.warning("cross-platform sync loop failed to start: %s", exc)
1120
1632
 
@@ -1219,7 +1731,33 @@ async def lifespan(application: FastAPI):
1219
1731
  application.state.daemon_descriptor = _publish_process_descriptor(
1220
1732
  _configured_daemon_port(), SLM_VERSION, "ready",
1221
1733
  )
1222
- yield
1734
+ _schedule_source_quality_repair(
1735
+ application,
1736
+ state_path("memory.db"),
1737
+ state_path("learning.db"),
1738
+ )
1739
+ _schedule_fact_entity_association_repair(
1740
+ application,
1741
+ state_path("memory.db"),
1742
+ )
1743
+ try:
1744
+ yield
1745
+ finally:
1746
+ await _cancel_fact_entity_association_repair(application)
1747
+ await _cancel_source_quality_repair(application)
1748
+
1749
+ # Cancel the cross-platform sync loop (H-CONC-2) so adapter file I/O does
1750
+ # not outlive the daemon.
1751
+ try:
1752
+ _sync_task = getattr(application.state, "_sync_task", None)
1753
+ if _sync_task is not None and not _sync_task.done():
1754
+ _sync_task.cancel()
1755
+ try:
1756
+ await _sync_task
1757
+ except asyncio.CancelledError:
1758
+ pass
1759
+ except Exception: # pragma: no cover — defensive
1760
+ pass
1223
1761
 
1224
1762
  # Cancel optimize metrics flush loop + run final flush before shutdown
1225
1763
  try:
@@ -1463,7 +2001,9 @@ def create_app() -> FastAPI:
1463
2001
  allow_origins=[
1464
2002
  "http://localhost:8765", "http://127.0.0.1:8765",
1465
2003
  "http://localhost:8767", "http://127.0.0.1:8767", # legacy compat
1466
- "http://localhost:8417", "http://127.0.0.1:8417",
2004
+ # M-04 (3.7.9): removed the undocumented port 8417 origin — it had
2005
+ # no known consumer and let any local service on 8417 make
2006
+ # credentialed cross-origin requests to the API.
1467
2007
  ],
1468
2008
  allow_credentials=True,
1469
2009
  allow_methods=["GET", "POST", "PUT", "DELETE", "PATCH", "OPTIONS"],
@@ -1568,13 +2108,19 @@ def create_app() -> FastAPI:
1568
2108
  # toggled INDEPENDENTLY from the UI with no restart. UI save fires the
1569
2109
  # callback immediately; external edits are caught by the 2s watchdog.
1570
2110
  _opt_store.register_change_callback(_proxy.reload_from_config)
1571
- _opt_store.start_watchdog()
1572
2111
  logger.info(
1573
2112
  "optimize.proxy mounted on /v1/*, /v1beta/* port=8765 "
1574
2113
  "(runtime cache/compress hot-reload enabled)"
1575
2114
  )
1576
2115
  else:
1577
2116
  application.state.optimize_proxy = None
2117
+ # H1 fix: the config watchdog must run REGARDLESS of proxy state so
2118
+ # optimize.json edits (cache/compress toggles, and a future
2119
+ # proxy_enabled flip) are picked up at runtime. Previously it only
2120
+ # started when the proxy was already on, so a daemon that booted with
2121
+ # the proxy off never saw any optimize.json change. start_watchdog()
2122
+ # is idempotent.
2123
+ _opt_store.start_watchdog()
1578
2124
  except ImportError:
1579
2125
  application.state.optimize_proxy = None
1580
2126
  logger.debug("optimize.proxy not installed — skipping")
@@ -1610,6 +2156,14 @@ def create_app() -> FastAPI:
1610
2156
  if _mcp_allowed:
1611
2157
  from mcp.server.transport_security import TransportSecuritySettings
1612
2158
  if _mcp_allowed == "*":
2159
+ # M-05 (3.7.9): "*" fully disables DNS-rebinding protection.
2160
+ # Never silent — a convenience setting in a CI/Docker env must
2161
+ # not quietly expose the instance.
2162
+ logger.warning(
2163
+ "SLM_MCP_ALLOWED_HOSTS=* disables MCP DNS-rebinding "
2164
+ "protection entirely. Prefer an explicit host list; only "
2165
+ "use '*' on a trusted private network."
2166
+ )
1613
2167
  _mcp_fastmcp.settings.transport_security = TransportSecuritySettings(
1614
2168
  enable_dns_rebinding_protection=False,
1615
2169
  )
@@ -1688,22 +2242,47 @@ def _register_dashboard_routes(application: FastAPI) -> None:
1688
2242
  _write_limiter = RateLimiter(max_requests=_rl_write, window_seconds=_rl_window)
1689
2243
  _read_limiter = RateLimiter(max_requests=_rl_read, window_seconds=_rl_window)
1690
2244
 
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.
2245
+ # S9-DASH-09: loopback (127.0.0.1 / ::1) is the local dashboard and
2246
+ # makes many rapid reads (Brain + tabs + polling). L-03 (3.7.9): rather
2247
+ # than exempt loopback entirely, give it a *generous* limit — far above
2248
+ # normal UI polling so a runaway local agent's write flood is still
2249
+ # eventually throttled. A LAN browser allowlisted in SLM_REMOTE mode
2250
+ # (is_rate_limit_exempt, non-loopback) stays fully exempt.
2251
+ _lb_write = max(300, _rl_write * 10)
2252
+ _lb_read = max(2000, _rl_read * 20)
2253
+ _lb_write_limiter = RateLimiter(max_requests=_lb_write, window_seconds=_rl_window)
2254
+ _lb_read_limiter = RateLimiter(max_requests=_lb_read, window_seconds=_rl_window)
2255
+
2256
+ # Task #47: register the live limiters so the dashboard PUT
2257
+ # /api/v3/ratelimit reconfigures them at runtime (no restart), then
2258
+ # apply any persisted override from config.json.
2259
+ try:
2260
+ from superlocalmemory.infra.rate_limiter import (
2261
+ register_managed as _reg_rl, reset_managed as _reset_rl,
2262
+ )
2263
+ _reset_rl()
2264
+ _reg_rl("write", _write_limiter)
2265
+ _reg_rl("read", _read_limiter)
2266
+ _reg_rl("lb_write", _lb_write_limiter)
2267
+ _reg_rl("lb_read", _lb_read_limiter)
2268
+ from superlocalmemory.server.routes.ratelimit import (
2269
+ load_persisted_limits as _load_rl,
2270
+ )
2271
+ _load_rl()
2272
+ except Exception as _reg_exc: # pragma: no cover - defensive
2273
+ logger.debug("rate-limit runtime registration skipped: %s", _reg_exc)
1699
2274
 
1700
2275
  @application.middleware("http")
1701
2276
  async def rate_limit_middleware(request, call_next):
1702
2277
  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
2278
  is_write = request.method in ("POST", "PUT", "DELETE", "PATCH")
1706
- limiter = _write_limiter if is_write else _read_limiter
2279
+ loopback = client_ip in ("127.0.0.1", "::1")
2280
+ if not loopback and is_rate_limit_exempt(client_ip):
2281
+ return await call_next(request)
2282
+ if loopback:
2283
+ limiter = _lb_write_limiter if is_write else _lb_read_limiter
2284
+ else:
2285
+ limiter = _write_limiter if is_write else _read_limiter
1707
2286
  allowed, remaining = limiter.is_allowed(client_ip)
1708
2287
  if not allowed:
1709
2288
  from fastapi.responses import JSONResponse
@@ -1760,19 +2339,35 @@ def _register_dashboard_routes(application: FastAPI) -> None:
1760
2339
  "error": "Remote HTTP MCP requires a configured SLM API key."
1761
2340
  },
1762
2341
  )
1763
- # v3.6.12 (csrf-1): defense-in-depth CSRF/DNS-rebinding guard on
1764
- # state-changing requests. A cross-origin browser Origin is rejected;
1765
- # loopback origins (the local dashboard) always pass, and LAN origins
1766
- # pass only when explicitly allowlisted in SLM_REMOTE mode. Non-browser
1767
- # clients (CLI/MCP/curl) send no Origin and are unaffected.
2342
+ # Defense-in-depth CSRF/DNS-rebinding guard. A loopback hostname is
2343
+ # not, by itself, a trusted web origin: a different local process can
2344
+ # serve a page on another port. Credentialless browser writes must
2345
+ # therefore originate from this daemon's exact port. A local
2346
+ # integration on another port may still write when it presents a
2347
+ # valid credential; require_http_mutation_actor below validates it.
2348
+ # LAN origins remain opt-in through remote mode. Non-browser clients
2349
+ # (CLI/MCP/curl) send no Origin and are unaffected.
1768
2350
  if requires_mutation_actor:
1769
2351
  _origin = headers.get("origin", "") or headers.get("Origin", "")
1770
2352
  if _origin:
1771
- _ok_origin = any(_origin.startswith(p) for p in (
1772
- "http://127.0.0.1", "https://127.0.0.1",
1773
- "http://localhost", "https://localhost",
1774
- "http://[::1]", "https://[::1]",
1775
- ))
2353
+ from superlocalmemory.server.origin import (
2354
+ origin_is_daemon,
2355
+ origin_is_loopback,
2356
+ )
2357
+
2358
+ _daemon = getattr(application.state, "daemon_descriptor", None)
2359
+ _daemon_port = getattr(_daemon, "port", None) or _configured_daemon_port()
2360
+ _ok_origin = origin_is_daemon(_origin, port=int(_daemon_port))
2361
+ _has_browser_credential = any(
2362
+ headers.get(_header)
2363
+ for _header in (
2364
+ "x-slm-daemon-capability",
2365
+ "x-install-token",
2366
+ "x-slm-api-key",
2367
+ )
2368
+ )
2369
+ if not _ok_origin and origin_is_loopback(_origin) and _has_browser_credential:
2370
+ _ok_origin = True
1776
2371
  if not _ok_origin:
1777
2372
  from superlocalmemory.core.remote_mode import is_remote_origin_allowed
1778
2373
  _ok_origin = is_remote_origin_allowed(_origin)
@@ -1848,6 +2443,19 @@ def _register_dashboard_routes(application: FastAPI) -> None:
1848
2443
  )
1849
2444
  },
1850
2445
  )
2446
+ # RBAC read gate (C3/audit SEC-C-01): sensitive content reads must
2447
+ # also respect roles. Engages ONLY when RBAC is active (>=1 user) —
2448
+ # single-operator installs are unaffected. Owner (no session) reads
2449
+ # freely unless company mode (require_login) is on; a logged-in user
2450
+ # must hold READ on the active workspace.
2451
+ # Config, learning, and behavioral reads expose installation,
2452
+ # preference, workflow, source-reputation, or outcome data.
2453
+ if _is_sensitive_dashboard_read(
2454
+ request.method, request.url.path,
2455
+ ):
2456
+ _resp = _rbac_read_gate(request, application.state)
2457
+ if _resp is not None:
2458
+ return _resp
1851
2459
  return await call_next(request)
1852
2460
  except Exception as _auth_exc:
1853
2461
  # v3.6.12 (failopen-1): security middleware must NEVER fail open silently.
@@ -1920,6 +2528,21 @@ def _register_dashboard_routes(application: FastAPI) -> None:
1920
2528
  application.include_router(v3_router)
1921
2529
  application.include_router(adapters_router)
1922
2530
 
2531
+ # RBAC / teams (C3) — user & role administration + login.
2532
+ try:
2533
+ from superlocalmemory.server.routes.rbac import router as rbac_router
2534
+ application.include_router(rbac_router)
2535
+ except ImportError:
2536
+ logger.debug("rbac_router not available")
2537
+
2538
+ # Config endpoints (storage, daemon, mesh, trust, forgetting)
2539
+ from superlocalmemory.server.routes.config_api import router as config_api_router
2540
+ application.include_router(config_api_router)
2541
+
2542
+ # Task #47: dashboard-editable rate limits (GET/PUT /api/v3/ratelimit)
2543
+ from superlocalmemory.server.routes.ratelimit import router as ratelimit_router
2544
+ application.include_router(ratelimit_router)
2545
+
1923
2546
  # v3.4.1 chat SSE
1924
2547
  for _mod_name in ("chat",):
1925
2548
  try:
@@ -1931,7 +2554,7 @@ def _register_dashboard_routes(application: FastAPI) -> None:
1931
2554
  pass
1932
2555
 
1933
2556
  # Optional routers
1934
- for _mod_name in ("learning", "lifecycle", "behavioral", "compliance", "insights", "timeline"):
2557
+ for _mod_name in ("learning", "lifecycle", "behavioral", "compliance", "insights", "timeline", "abstraction"):
1935
2558
  try:
1936
2559
  _mod = __import__(
1937
2560
  f"superlocalmemory.server.routes.{_mod_name}", fromlist=["router"],
@@ -2056,7 +2679,7 @@ def _register_daemon_routes(application: FastAPI) -> None:
2056
2679
  )
2057
2680
 
2058
2681
  @application.get("/health")
2059
- async def health():
2682
+ async def health(request: Request = None):
2060
2683
  _update_activity()
2061
2684
  # Non-blocking peek: report status without forcing a re-init.
2062
2685
  engine = getattr(application.state, "engine", None)
@@ -2090,6 +2713,27 @@ def _register_daemon_routes(application: FastAPI) -> None:
2090
2713
  from superlocalmemory.server.profile_runtime import get_profile_runtime
2091
2714
 
2092
2715
  profile_snapshot = get_profile_runtime(application.state).snapshot
2716
+ # H-05 (3.7.9): operational metadata (pid, daemon identity including
2717
+ # capability_fingerprint/instance_id, active profile, readiness detail)
2718
+ # is returned only to loopback callers. A remote or unauthenticated
2719
+ # probe receives liveness fields only, so it cannot harvest targeting
2720
+ # intel (version stays, since clients legitimately gate on it).
2721
+ public = {
2722
+ "status": "ok",
2723
+ "ready": fully_ready,
2724
+ # Runtime readiness is more precise than descriptor lifecycle.
2725
+ # A process can be alive and identity-valid while retrieval warms.
2726
+ "state": runtime_state,
2727
+ "version": getattr(application, 'version', 'unknown'),
2728
+ }
2729
+ # request is None only for direct internal/test calls (no HTTP client),
2730
+ # which are trusted; over HTTP FastAPI always injects the real Request.
2731
+ client_host = request.client.host if (request and request.client) else ""
2732
+ _trusted = request is None or client_host in (
2733
+ "127.0.0.1", "::1", "localhost", "testclient",
2734
+ )
2735
+ if not _trusted:
2736
+ return public
2093
2737
  return {
2094
2738
  "status": "ok",
2095
2739
  "ready": fully_ready,
@@ -2104,8 +2748,9 @@ def _register_daemon_routes(application: FastAPI) -> None:
2104
2748
  # health probe; includes self-heal counters.
2105
2749
  "recall_health": _recall_health,
2106
2750
  **(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.
2751
+ # runtime_state must come AFTER the identity spread so the live
2752
+ # readiness state wins over the descriptor's last-known lifecycle
2753
+ # value (which can be "starting").
2109
2754
  "state": runtime_state,
2110
2755
  "active_profile": profile_snapshot.profile_id,
2111
2756
  "profile_generation": profile_snapshot.generation,
@@ -2116,11 +2761,12 @@ def _register_daemon_routes(application: FastAPI) -> None:
2116
2761
  request: Request,
2117
2762
  q: str = "", query: str = "", limit: int = CANONICAL_RECALL_LIMIT,
2118
2763
  session_id: str = "",
2119
- fast: bool = False,
2764
+ fast: bool = True,
2120
2765
  full: bool = False,
2121
2766
  include_source: bool = False,
2122
2767
  include_global: bool | None = None,
2123
2768
  include_shared: bool | None = None,
2769
+ window: str = "",
2124
2770
  ):
2125
2771
  _update_activity()
2126
2772
  search_query = q or query # Accept both ?q= and ?query= for compatibility
@@ -2154,7 +2800,7 @@ def _register_daemon_routes(application: FastAPI) -> None:
2154
2800
  # recall (reranker timeout, cold embedder) blocks ALL endpoints.
2155
2801
  import asyncio
2156
2802
  _begin_recall()
2157
- # v3.4.53: Full (non-fast) recalls are gated by a semaphore to
2803
+ # v3.4.53: Opt-in deep recalls are gated by a semaphore to
2158
2804
  # prevent resource oversaturation. Ollama serialises concurrent
2159
2805
  # embedding calls and the reranker subprocess has a single lock —
2160
2806
  # queuing more than ~3 concurrent full recalls just adds latency.
@@ -2170,6 +2816,7 @@ def _register_daemon_routes(application: FastAPI) -> None:
2170
2816
  fast=fast,
2171
2817
  include_global=include_global,
2172
2818
  include_shared=include_shared,
2819
+ window=window or None,
2173
2820
  )
2174
2821
  # v3.4.26: return the same field shape as recall_worker so
2175
2822
  # MCP processes proxying through the daemon get recall_trace-
@@ -2179,7 +2826,10 @@ def _register_daemon_routes(application: FastAPI) -> None:
2179
2826
  if r.fact.memory_id
2180
2827
  })
2181
2828
  memory_map = (
2182
- engine._db.get_memory_content_batch(memory_ids)
2829
+ engine._db.get_memory_content_batch(
2830
+ memory_ids, engine.profile_id,
2831
+ include_global=True, include_shared=True,
2832
+ )
2183
2833
  if memory_ids else {}
2184
2834
  )
2185
2835
  # v3.6.6: single shared serialization chokepoint — budget + source
@@ -2267,7 +2917,7 @@ def _register_daemon_routes(application: FastAPI) -> None:
2267
2917
  if isinstance(extra, dict):
2268
2918
  meta.update(extra)
2269
2919
  command = build_engine_ingestion_command(engine)
2270
- receipt = command.submit(IngestionRequest(
2920
+ ingestion_request = IngestionRequest(
2271
2921
  content=req.content,
2272
2922
  profile_id=engine._profile_id,
2273
2923
  source_type="http",
@@ -2277,9 +2927,42 @@ def _register_daemon_routes(application: FastAPI) -> None:
2277
2927
  shared_with=tuple(shared_with or ()),
2278
2928
  trusted_actor_id=trusted_actor_id,
2279
2929
  session_id=req.session_id,
2280
- ))
2930
+ )
2931
+ # SQLite admission is usually milliseconds, but it can wait on a
2932
+ # concurrent migration or writer. Keep that wait out of ASGI so
2933
+ # dashboard navigation and recall stay responsive.
2934
+ receipt = await asyncio.to_thread(command.submit, ingestion_request)
2935
+ result = receipt
2936
+ wait_budget_exhausted = False
2937
+ if wait:
2938
+ materialization_task = asyncio.create_task(
2939
+ asyncio.to_thread(command.materialize, receipt.operation_id)
2940
+ )
2941
+ try:
2942
+ result = await asyncio.wait_for(
2943
+ asyncio.shield(materialization_task),
2944
+ timeout=_REMEMBER_ENRICHMENT_WAIT_SECONDS,
2945
+ )
2946
+ except TimeoutError:
2947
+ # The task retains the M018 lease and continues outside
2948
+ # this request. Return the durable receipt honestly;
2949
+ # the normal materializer can also reclaim it after a
2950
+ # lease expiry if the request-owned worker dies.
2951
+ wait_budget_exhausted = True
2952
+
2953
+ def _log_background_materialization(task):
2954
+ try:
2955
+ task.result()
2956
+ except Exception as exc:
2957
+ logger.warning(
2958
+ "bounded remember enrichment failed for %s: %s",
2959
+ receipt.operation_id,
2960
+ exc,
2961
+ )
2281
2962
 
2282
- result = command.materialize(receipt.operation_id) if wait else receipt
2963
+ materialization_task.add_done_callback(
2964
+ _log_background_materialization
2965
+ )
2283
2966
  fact_ids = list(result.fact_ids)
2284
2967
  # The queryable write is a separate durable transaction. A cold
2285
2968
  # optional enrichment dependency (most often the local embedding
@@ -2322,10 +3005,13 @@ def _register_daemon_routes(application: FastAPI) -> None:
2322
3005
  "note": (
2323
3006
  "canonical ingestion complete"
2324
3007
  if completed
3008
+ else "queryable now; enrichment continues after the wait budget"
3009
+ if wait_budget_exhausted
2325
3010
  else "queryable now; canonical enrichment will retry"
2326
3011
  if enrichment_deferred
2327
3012
  else "queryable now; canonical enrichment pending"
2328
3013
  ),
3014
+ "wait_budget_exhausted": wait_budget_exhausted,
2329
3015
  }
2330
3016
  except Exception as exc:
2331
3017
  raise HTTPException(500, detail=str(exc))
@@ -2404,7 +3090,8 @@ def _register_daemon_routes(application: FastAPI) -> None:
2404
3090
  maint_result = _run_maint(engine._db, engine._config, pid)
2405
3091
  results["langevin"] = {"updated": maint_result.get("updated", 0)}
2406
3092
  except Exception as exc:
2407
- results["langevin"] = {"error": str(exc)}
3093
+ logger.exception("maintenance langevin step failed")
3094
+ results["langevin"] = {"error": "internal error"}
2408
3095
  try:
2409
3096
  from superlocalmemory.math.ebbinghaus import EbbinghausCurve
2410
3097
  from superlocalmemory.learning.forgetting_scheduler import (
@@ -2416,7 +3103,8 @@ def _register_daemon_routes(application: FastAPI) -> None:
2416
3103
  )
2417
3104
  results["forgetting"] = sched.run_decay_cycle(pid, force=False)
2418
3105
  except Exception as exc:
2419
- results["forgetting"] = {"error": str(exc)}
3106
+ logger.exception("maintenance forgetting step failed")
3107
+ results["forgetting"] = {"error": "internal error"}
2420
3108
  try:
2421
3109
  from superlocalmemory.learning.consolidation_worker import (
2422
3110
  ConsolidationWorker,
@@ -2428,7 +3116,8 @@ def _register_daemon_routes(application: FastAPI) -> None:
2428
3116
  count = cw._generate_patterns(pid, False)
2429
3117
  results["behavioral"] = {"patterns_mined": count}
2430
3118
  except Exception as exc:
2431
- results["behavioral"] = {"error": str(exc)}
3119
+ logger.exception("maintenance behavioral step failed")
3120
+ results["behavioral"] = {"error": "internal error"}
2432
3121
  authorization.complete()
2433
3122
  return {"ok": True, "profile": pid, **results}
2434
3123
  except HTTPException:
@@ -2520,6 +3209,46 @@ def _register_daemon_routes(application: FastAPI) -> None:
2520
3209
  os.kill(os.getpid(), signal.SIGTERM)
2521
3210
  return {"status": "stopping"}
2522
3211
 
3212
+ @application.post("/api/daemon/restart")
3213
+ async def restart_daemon(request: Request):
3214
+ """Restart the daemon from the dashboard (non-technical end users).
3215
+
3216
+ Spawns a DETACHED ``slm restart`` process (its own session) so it
3217
+ survives this daemon being stopped, then returns immediately. The child
3218
+ stops this daemon and starts a fresh one via the standard 5-step
3219
+ pipeline (namespace lock → stop → start → warmup → verify).
3220
+ """
3221
+ # Dashboard-callable: accept the install-token principal (same auth as
3222
+ # /remember and other dashboard mutations), not just the private CLI
3223
+ # capability — a non-technical user restarts from their own dashboard.
3224
+ _require_write_actor(request)
3225
+ import subprocess
3226
+ import sys as _sys
3227
+ from superlocalmemory.core.platform_utils import popen_platform_kwargs
3228
+
3229
+ logger.info("Daemon restart requested via API")
3230
+ _observe_buffer.flush_sync()
3231
+ try:
3232
+ subprocess.Popen(
3233
+ [_sys.executable, "-m", "superlocalmemory.cli.main", "restart",
3234
+ "--json"],
3235
+ stdout=subprocess.DEVNULL,
3236
+ stderr=subprocess.DEVNULL,
3237
+ # CP-04: use the shared platform kwargs (CREATE_NO_WINDOW on
3238
+ # Windows, start_new_session on POSIX) like every other Popen so
3239
+ # a GUI-triggered restart never flashes a console window.
3240
+ # close_fds defaults to True on all platforms.
3241
+ **popen_platform_kwargs(),
3242
+ )
3243
+ except Exception:
3244
+ logger.exception("Failed to spawn restart process")
3245
+ return {"success": False, "error": "Could not initiate restart"}
3246
+ return {
3247
+ "success": True,
3248
+ "status": "restarting",
3249
+ "message": "Daemon restart initiated — it will be back in a few seconds.",
3250
+ }
3251
+
2523
3252
  @application.post("/session/open")
2524
3253
  async def session_open(req: SessionOpenRequest, request: Request):
2525
3254
  """#49: Open a session locally — warm recall context with no model
@@ -2675,7 +3404,17 @@ def _materializer_actor_id() -> str:
2675
3404
 
2676
3405
 
2677
3406
  def _run_materializer_operation(runtime, engine_supplier, operation):
2678
- """Run one bounded background unit against an admitted engine snapshot."""
3407
+ """Run one bounded background unit against an admitted engine snapshot.
3408
+
3409
+ Cooperative preemption: if a profile transition is already in progress,
3410
+ skip this materialization cycle entirely and return None. The caller's
3411
+ loop retries on the next iteration, by which time the switch has committed
3412
+ and a clean admission is available. This prevents the materializer from
3413
+ holding the operation lease during the transition drain window.
3414
+ """
3415
+ # Writer-priority: don't acquire a new lease when a transition is draining.
3416
+ if runtime is not None and runtime.transitioning:
3417
+ return None
2679
3418
  with runtime.operation():
2680
3419
  # Resolve the engine only after admission. A concurrent mode/provider
2681
3420
  # reconfiguration may have replaced the module-level engine while this
@@ -2831,7 +3570,11 @@ def _start_pending_materializer() -> None:
2831
3570
  ),
2832
3571
  )
2833
3572
  durable_complete, durable_failed = cycle_result or (0, 0)
2834
- pending = get_pending(limit=50)
3573
+ # Only backfill legacy pending items enqueued under the active
3574
+ # profile — never materialize another profile's queued memory
3575
+ # under whichever profile happens to be active now.
3576
+ _active_profile = runtime.snapshot.profile_id
3577
+ pending = get_pending(limit=50, profile_id=_active_profile)
2835
3578
  if not pending and not durable_complete and not durable_failed:
2836
3579
  time.sleep(1.0)
2837
3580
  continue
@@ -2915,6 +3658,18 @@ def start_server(port: int = _DEFAULT_PORT) -> None:
2915
3658
  or os.environ.get("SLM_HOST")
2916
3659
  or "127.0.0.1"
2917
3660
  )
3661
+ # M-01 / L-02 (3.7.9): binding beyond loopback exposes the write API to the
3662
+ # network, where the loopback trusted-actor bypass no longer protects it.
3663
+ # Warn loudly unless the operator has opted into credential enforcement.
3664
+ if bind_host not in ("127.0.0.1", "::1", "localhost") and \
3665
+ os.environ.get("SLM_REQUIRE_CREDENTIALS") != "1":
3666
+ logger.warning(
3667
+ "SLM daemon binding to %s (non-loopback): the write API is "
3668
+ "reachable from the network but SLM_REQUIRE_CREDENTIALS is not set "
3669
+ "and API-key auth may be off. A remote caller could write without "
3670
+ "credentials. Set SLM_REQUIRE_CREDENTIALS=1 and configure an API "
3671
+ "key before exposing this instance.", bind_host,
3672
+ )
2918
3673
  listener = socket.socket(socket.AF_INET, socket.SOCK_STREAM)
2919
3674
  # This handles a just-closed connection in TIME_WAIT. It is safe only
2920
3675
  # with the active-listener probe immediately below; without that guard,