superlocalmemory 3.6.23 → 3.7.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 (301) hide show
  1. package/CHANGELOG.md +52 -0
  2. package/README.md +271 -71
  3. package/bin/slm-npm +43 -89
  4. package/ide/configs/antigravity-mcp.json +2 -2
  5. package/ide/configs/chatgpt-desktop-mcp.json +1 -1
  6. package/ide/configs/claude-desktop-mcp.json +2 -2
  7. package/ide/configs/windsurf-mcp.json +2 -2
  8. package/ide/hooks/context-hook.js +6 -2
  9. package/ide/hooks/post-recall-hook.js +7 -3
  10. package/ide/hooks/tool-event-hook.sh +2 -1
  11. package/package.json +18 -10
  12. package/plugin/.claude-plugin/plugin.json +1 -1
  13. package/plugin/_GENERATED.md +1 -1
  14. package/plugin/agents/slm-memory-advisor.md +1 -1
  15. package/plugin/requirements.txt +1 -1
  16. package/plugin/skills/slm-session/SKILL.md +1 -1
  17. package/plugin-src/rules/AGENTS.md +1 -1
  18. package/pyproject.toml +40 -8
  19. package/scripts/postinstall-interactive.js +17 -94
  20. package/scripts/postinstall.js +185 -258
  21. package/scripts/preuninstall.js +9 -50
  22. package/src/superlocalmemory/__init__.py +2 -2
  23. package/src/superlocalmemory/attribution/mathematical_dna.py +1 -1
  24. package/src/superlocalmemory/attribution/signer.py +34 -19
  25. package/src/superlocalmemory/attribution/watermark.py +1 -1
  26. package/src/superlocalmemory/cli/_lazy_init.py +3 -5
  27. package/src/superlocalmemory/cli/commands.py +453 -191
  28. package/src/superlocalmemory/cli/context_commands.py +5 -4
  29. package/src/superlocalmemory/cli/daemon.py +282 -187
  30. package/src/superlocalmemory/cli/db_migrate.py +3 -1
  31. package/src/superlocalmemory/cli/diagnostics_cmd.py +28 -0
  32. package/src/superlocalmemory/cli/evidence_cmd.py +103 -0
  33. package/src/superlocalmemory/cli/ingest_cmd.py +7 -3
  34. package/src/superlocalmemory/cli/main.py +128 -31
  35. package/src/superlocalmemory/cli/pending_store.py +54 -38
  36. package/src/superlocalmemory/cli/scale_engine_cmd.py +37 -0
  37. package/src/superlocalmemory/cli/service_installer.py +57 -52
  38. package/src/superlocalmemory/cli/setup_wizard.py +142 -88
  39. package/src/superlocalmemory/cli/version_banner.py +2 -1
  40. package/src/superlocalmemory/code_graph/config.py +3 -1
  41. package/src/superlocalmemory/core/backend_orchestrator.py +81 -21
  42. package/src/superlocalmemory/core/config.py +65 -20
  43. package/src/superlocalmemory/core/consolidation_engine.py +9 -7
  44. package/src/superlocalmemory/core/context_cache.py +56 -8
  45. package/src/superlocalmemory/core/derivation_lineage.py +246 -0
  46. package/src/superlocalmemory/core/embedding_worker.py +32 -20
  47. package/src/superlocalmemory/core/embeddings.py +54 -18
  48. package/src/superlocalmemory/core/engine.py +150 -104
  49. package/src/superlocalmemory/core/engine_ingestion.py +513 -0
  50. package/src/superlocalmemory/core/engine_wiring.py +2 -0
  51. package/src/superlocalmemory/core/evidence_bundle.py +526 -0
  52. package/src/superlocalmemory/core/fact_consolidator.py +5 -11
  53. package/src/superlocalmemory/core/graph_analyzer.py +2 -2
  54. package/src/superlocalmemory/core/health_monitor.py +4 -2
  55. package/src/superlocalmemory/core/ingestion_command.py +636 -0
  56. package/src/superlocalmemory/core/injection.py +69 -18
  57. package/src/superlocalmemory/core/lifecycle_state.py +153 -0
  58. package/src/superlocalmemory/core/maintenance.py +1 -1
  59. package/src/superlocalmemory/core/maintenance_scheduler.py +51 -35
  60. package/src/superlocalmemory/core/mutations.py +143 -0
  61. package/src/superlocalmemory/core/platform_utils.py +7 -4
  62. package/src/superlocalmemory/core/ram_lock.py +16 -5
  63. package/src/superlocalmemory/core/rate_limit.py +1 -1
  64. package/src/superlocalmemory/core/recall_pipeline.py +60 -101
  65. package/src/superlocalmemory/core/recall_worker.py +76 -59
  66. package/src/superlocalmemory/core/registry.py +1 -1
  67. package/src/superlocalmemory/core/scale_engine.py +293 -0
  68. package/src/superlocalmemory/core/score_contract.py +62 -0
  69. package/src/superlocalmemory/core/security_primitives.py +3 -1
  70. package/src/superlocalmemory/core/slm_disabled.py +3 -5
  71. package/src/superlocalmemory/core/store_pipeline.py +172 -40
  72. package/src/superlocalmemory/core/tier_manager.py +32 -20
  73. package/src/superlocalmemory/core/worker_pool.py +13 -4
  74. package/src/superlocalmemory/dynamics/activation_guided_quantization.py +1 -1
  75. package/src/superlocalmemory/dynamics/eap_scheduler.py +10 -3
  76. package/src/superlocalmemory/dynamics/ebbinghaus_langevin_coupling.py +1 -1
  77. package/src/superlocalmemory/dynamics/fisher_langevin_coupling.py +1 -1
  78. package/src/superlocalmemory/encoding/auto_linker.py +1 -1
  79. package/src/superlocalmemory/encoding/cognitive_consolidator.py +7 -16
  80. package/src/superlocalmemory/encoding/consolidator.py +22 -5
  81. package/src/superlocalmemory/encoding/fact_extractor.py +1 -1
  82. package/src/superlocalmemory/encoding/foresight.py +2 -0
  83. package/src/superlocalmemory/encoding/graph_builder.py +1 -1
  84. package/src/superlocalmemory/encoding/temporal_parser.py +2 -0
  85. package/src/superlocalmemory/evaluation/__init__.py +13 -0
  86. package/src/superlocalmemory/evaluation/calibration.py +308 -0
  87. package/src/superlocalmemory/evolution/skill_evolver.py +2 -1
  88. package/src/superlocalmemory/graph/cozo_backend.py +256 -23
  89. package/src/superlocalmemory/hooks/_outcome_common.py +21 -11
  90. package/src/superlocalmemory/hooks/antigravity_adapter.py +10 -31
  91. package/src/superlocalmemory/hooks/auto_invoker.py +25 -27
  92. package/src/superlocalmemory/hooks/auto_recall.py +31 -6
  93. package/src/superlocalmemory/hooks/auto_recall_hook.py +13 -33
  94. package/src/superlocalmemory/hooks/before_web_hook.py +9 -7
  95. package/src/superlocalmemory/hooks/claude_code_hooks.py +123 -35
  96. package/src/superlocalmemory/hooks/codex_assets.py +59 -0
  97. package/src/superlocalmemory/hooks/codex_hooks.py +186 -0
  98. package/src/superlocalmemory/hooks/context_payload.py +1 -1
  99. package/src/superlocalmemory/hooks/copilot_adapter.py +9 -24
  100. package/src/superlocalmemory/hooks/cursor_adapter.py +10 -32
  101. package/src/superlocalmemory/hooks/hook_daemon.py +4 -2
  102. package/src/superlocalmemory/hooks/hook_handlers.py +219 -32
  103. package/src/superlocalmemory/hooks/memory_protocol.py +5 -3
  104. package/src/superlocalmemory/hooks/post_tool_async_hook.py +4 -2
  105. package/src/superlocalmemory/hooks/session_registry.py +15 -8
  106. package/src/superlocalmemory/hooks/stop_outcome_hook.py +10 -6
  107. package/src/superlocalmemory/hooks/topic_shift_hook.py +42 -12
  108. package/src/superlocalmemory/hooks/user_prompt_hook.py +9 -14
  109. package/src/superlocalmemory/hooks/user_prompt_rehash_hook.py +19 -11
  110. package/src/superlocalmemory/infra/auth_middleware.py +38 -5
  111. package/src/superlocalmemory/infra/backup.py +7 -5
  112. package/src/superlocalmemory/infra/cloud_backup.py +18 -8
  113. package/src/superlocalmemory/infra/daemon_identity.py +248 -0
  114. package/src/superlocalmemory/infra/data_root.py +199 -0
  115. package/src/superlocalmemory/infra/event_bus.py +3 -1
  116. package/src/superlocalmemory/infra/local_diagnostics.py +327 -0
  117. package/src/superlocalmemory/infra/process_reaper.py +23 -0
  118. package/src/superlocalmemory/ingestion/adapter_manager.py +27 -9
  119. package/src/superlocalmemory/ingestion/base_adapter.py +25 -31
  120. package/src/superlocalmemory/ingestion/calendar_adapter.py +13 -4
  121. package/src/superlocalmemory/ingestion/credentials.py +14 -7
  122. package/src/superlocalmemory/ingestion/gmail_adapter.py +13 -4
  123. package/src/superlocalmemory/ingestion/transcript_adapter.py +7 -2
  124. package/src/superlocalmemory/learning/consolidation_quantization_worker.py +1 -1
  125. package/src/superlocalmemory/learning/ensemble.py +11 -0
  126. package/src/superlocalmemory/learning/entity_compiler.py +1 -1
  127. package/src/superlocalmemory/learning/feedback.py +1 -1
  128. package/src/superlocalmemory/learning/forgetting_scheduler.py +12 -7
  129. package/src/superlocalmemory/learning/quantization_scheduler.py +1 -1
  130. package/src/superlocalmemory/learning/ranker.py +4 -1
  131. package/src/superlocalmemory/learning/source_quality.py +1 -1
  132. package/src/superlocalmemory/learning/trigram_index.py +3 -2
  133. package/src/superlocalmemory/llm/backbone.py +1 -1
  134. package/src/superlocalmemory/math/ebbinghaus.py +1 -1
  135. package/src/superlocalmemory/math/fisher.py +1 -1
  136. package/src/superlocalmemory/math/fisher_quantized.py +1 -1
  137. package/src/superlocalmemory/math/hopfield.py +1 -1
  138. package/src/superlocalmemory/math/langevin.py +1 -1
  139. package/src/superlocalmemory/math/polar_quant.py +3 -4
  140. package/src/superlocalmemory/math/qjl.py +1 -1
  141. package/src/superlocalmemory/math/sheaf.py +1 -1
  142. package/src/superlocalmemory/math/turbo_quant.py +3 -2
  143. package/src/superlocalmemory/mcp/_daemon_proxy.py +12 -11
  144. package/src/superlocalmemory/mcp/_pool_adapter.py +27 -0
  145. package/src/superlocalmemory/mcp/http_transport.py +53 -0
  146. package/src/superlocalmemory/mcp/server.py +39 -13
  147. package/src/superlocalmemory/mcp/shared.py +69 -3
  148. package/src/superlocalmemory/mcp/tools_active.py +141 -31
  149. package/src/superlocalmemory/mcp/tools_core.py +128 -29
  150. package/src/superlocalmemory/mcp/tools_evolution.py +5 -7
  151. package/src/superlocalmemory/mcp/tools_learning.py +42 -2
  152. package/src/superlocalmemory/mcp/tools_mesh.py +7 -23
  153. package/src/superlocalmemory/mcp/tools_optimize.py +8 -1
  154. package/src/superlocalmemory/mcp/tools_v28.py +23 -2
  155. package/src/superlocalmemory/mcp/tools_v3.py +26 -1
  156. package/src/superlocalmemory/mcp/tools_v33.py +56 -17
  157. package/src/superlocalmemory/mesh/broker.py +2 -0
  158. package/src/superlocalmemory/mesh/remote_sync.py +50 -12
  159. package/src/superlocalmemory/optimize/cache/manager.py +77 -1
  160. package/src/superlocalmemory/optimize/cache/semantic.py +23 -3
  161. package/src/superlocalmemory/optimize/compress/ccr.py +4 -0
  162. package/src/superlocalmemory/optimize/compress/router.py +6 -1
  163. package/src/superlocalmemory/optimize/config/__init__.py +5 -0
  164. package/src/superlocalmemory/optimize/config/store.py +6 -4
  165. package/src/superlocalmemory/optimize/proxy/_helpers.py +15 -5
  166. package/src/superlocalmemory/optimize/proxy/capture.py +3 -2
  167. package/src/superlocalmemory/optimize/proxy/server.py +2 -2
  168. package/src/superlocalmemory/optimize/storage/db.py +12 -12
  169. package/src/superlocalmemory/retrieval/agentic.py +1 -1
  170. package/src/superlocalmemory/retrieval/ann_index.py +1 -1
  171. package/src/superlocalmemory/retrieval/bm25_channel.py +35 -11
  172. package/src/superlocalmemory/retrieval/bridge_discovery.py +73 -8
  173. package/src/superlocalmemory/retrieval/engine.py +169 -79
  174. package/src/superlocalmemory/retrieval/entity_channel.py +289 -67
  175. package/src/superlocalmemory/retrieval/forgetting_filter.py +1 -1
  176. package/src/superlocalmemory/retrieval/fusion.py +1 -1
  177. package/src/superlocalmemory/retrieval/hopfield_channel.py +118 -30
  178. package/src/superlocalmemory/retrieval/profile_channel.py +1 -1
  179. package/src/superlocalmemory/retrieval/quantization_aware_search.py +16 -10
  180. package/src/superlocalmemory/retrieval/reranker.py +56 -20
  181. package/src/superlocalmemory/retrieval/scope_policy.py +85 -0
  182. package/src/superlocalmemory/retrieval/semantic_channel.py +122 -14
  183. package/src/superlocalmemory/retrieval/spreading_activation.py +141 -25
  184. package/src/superlocalmemory/retrieval/strategy.py +1 -1
  185. package/src/superlocalmemory/retrieval/temporal_channel.py +30 -15
  186. package/src/superlocalmemory/retrieval/vector_store.py +1 -1
  187. package/src/superlocalmemory/server/api.py +10 -7
  188. package/src/superlocalmemory/server/bandit_loops.py +4 -2
  189. package/src/superlocalmemory/server/recall_serializer.py +24 -0
  190. package/src/superlocalmemory/server/route_mutations.py +84 -0
  191. package/src/superlocalmemory/server/routes/agents.py +8 -6
  192. package/src/superlocalmemory/server/routes/brain.py +14 -12
  193. package/src/superlocalmemory/server/routes/chat.py +29 -12
  194. package/src/superlocalmemory/server/routes/data_io.py +55 -24
  195. package/src/superlocalmemory/server/routes/helpers.py +8 -63
  196. package/src/superlocalmemory/server/routes/ingest.py +53 -36
  197. package/src/superlocalmemory/server/routes/memories.py +104 -43
  198. package/src/superlocalmemory/server/routes/mesh.py +31 -0
  199. package/src/superlocalmemory/server/routes/profiles.py +26 -4
  200. package/src/superlocalmemory/server/routes/tiers.py +43 -11
  201. package/src/superlocalmemory/server/routes/timeline.py +5 -1
  202. package/src/superlocalmemory/server/routes/v3_api.py +76 -21
  203. package/src/superlocalmemory/server/security_middleware.py +1 -1
  204. package/src/superlocalmemory/server/unified_daemon.py +680 -293
  205. package/src/superlocalmemory/server/write_identity.py +147 -0
  206. package/src/superlocalmemory/storage/access_log.py +4 -3
  207. package/src/superlocalmemory/storage/database.py +118 -25
  208. package/src/superlocalmemory/storage/migration_runner.py +84 -1
  209. package/src/superlocalmemory/storage/migration_v33.py +1 -1
  210. package/src/superlocalmemory/storage/migrations/M002_model_state_history.py +6 -60
  211. package/src/superlocalmemory/storage/migrations/M018_ingestion_operations.py +120 -0
  212. package/src/superlocalmemory/storage/migrations/M019_derivation_lineage.py +54 -0
  213. package/src/superlocalmemory/storage/migrations/M020_model_state_integrity.py +52 -0
  214. package/src/superlocalmemory/storage/migrations/__init__.py +5 -0
  215. package/src/superlocalmemory/storage/models.py +16 -0
  216. package/src/superlocalmemory/storage/quantized_store.py +20 -3
  217. package/src/superlocalmemory/storage/v2_migrator.py +5 -3
  218. package/src/superlocalmemory/ui/favicon.svg +5 -0
  219. package/src/superlocalmemory/ui/index.html +1 -0
  220. package/src/superlocalmemory/ui/js/compliance.js +1 -1
  221. package/src/superlocalmemory/ui/js/core.js +49 -8
  222. package/src/superlocalmemory/ui/js/dashboard.js +23 -2
  223. package/src/superlocalmemory/ui/js/feedback.js +1 -1
  224. package/src/superlocalmemory/ui/js/graph-filters.js +1 -1
  225. package/src/superlocalmemory/ui/js/graph-ui.js +1 -1
  226. package/src/superlocalmemory/ui/js/lifecycle.js +1 -1
  227. package/src/superlocalmemory/ui/js/ng-mesh.js +15 -49
  228. package/src/superlocalmemory/ui/js/settings.js +4 -2
  229. package/src/superlocalmemory/vector/lancedb_backend.py +57 -9
  230. package/bin/slm +0 -59
  231. package/bin/slm.bat +0 -77
  232. package/bin/slm.cmd +0 -5
  233. package/ide/integrations/langchain/README.md +0 -106
  234. package/ide/integrations/langchain/langchain_superlocalmemory/__init__.py +0 -9
  235. package/ide/integrations/langchain/langchain_superlocalmemory/chat_message_history.py +0 -201
  236. package/ide/integrations/langchain/pyproject.toml +0 -38
  237. package/ide/integrations/langchain/tests/__init__.py +0 -3
  238. package/ide/integrations/langchain/tests/test_chat_message_history.py +0 -215
  239. package/ide/integrations/langchain/tests/test_security.py +0 -117
  240. package/ide/integrations/llamaindex/README.md +0 -81
  241. package/ide/integrations/llamaindex/llama_index/storage/chat_store/superlocalmemory/__init__.py +0 -9
  242. package/ide/integrations/llamaindex/llama_index/storage/chat_store/superlocalmemory/base.py +0 -316
  243. package/ide/integrations/llamaindex/pyproject.toml +0 -43
  244. package/ide/integrations/llamaindex/tests/__init__.py +0 -3
  245. package/ide/integrations/llamaindex/tests/test_chat_store.py +0 -294
  246. package/ide/integrations/llamaindex/tests/test_security.py +0 -241
  247. package/plugin-src/.mcp.json +0 -12
  248. package/plugin-src/agents/slm-memory-advisor.md +0 -44
  249. package/plugin-src/agents/slm-optimize-advisor.md +0 -38
  250. package/plugin-src/hooks/.gitkeep +0 -0
  251. package/plugin-src/hooks/hooks.json +0 -23
  252. package/plugin-src/manifest.json +0 -25
  253. package/plugin-src/requirements.txt +0 -1
  254. package/plugin-src/rules/CLAUDE.md.fragment +0 -44
  255. package/plugin-src/scripts/ensure-venv.bat +0 -122
  256. package/plugin-src/scripts/ensure-venv.sh +0 -105
  257. package/plugin-src/scripts/slm-launch +0 -15
  258. package/plugin-src/scripts/slm-launch.bat +0 -17
  259. package/plugin-src/settings.json +0 -16
  260. package/plugin-src/skills/slm-cache/SKILL.md +0 -140
  261. package/plugin-src/skills/slm-compress/SKILL.md +0 -143
  262. package/plugin-src/skills/slm-graph/SKILL.md +0 -300
  263. package/plugin-src/skills/slm-recall/SKILL.md +0 -204
  264. package/plugin-src/skills/slm-remember/SKILL.md +0 -194
  265. package/plugin-src/skills/slm-session/SKILL.md +0 -207
  266. package/plugin-src/skills/slm-status/SKILL.md +0 -149
  267. package/scripts/__tests__/build-plugin.test.mjs +0 -613
  268. package/scripts/_savings_math.py +0 -270
  269. package/scripts/build-dmg.sh +0 -417
  270. package/scripts/build-plugin.js +0 -742
  271. package/scripts/build-slm-hook.ps1 +0 -40
  272. package/scripts/build-slm-hook.sh +0 -45
  273. package/scripts/build_entry.py +0 -452
  274. package/scripts/ci/stage5b_gate.sh +0 -50
  275. package/scripts/dogfood_savings.py +0 -490
  276. package/scripts/generate-thumbnails.py +0 -218
  277. package/scripts/install-skills.ps1 +0 -4
  278. package/scripts/install-skills.sh +0 -5
  279. package/scripts/install.ps1 +0 -701
  280. package/scripts/install.sh +0 -1015
  281. package/scripts/postinstall_binary.js +0 -287
  282. package/scripts/prepack.js +0 -33
  283. package/scripts/release_manifest.py +0 -273
  284. package/scripts/slm-hook.spec +0 -56
  285. package/scripts/start-dashboard.ps1 +0 -52
  286. package/scripts/start-dashboard.sh +0 -41
  287. package/scripts/sync-wiki.ps1 +0 -127
  288. package/scripts/sync-wiki.sh +0 -82
  289. package/scripts/test-dmg.sh +0 -161
  290. package/scripts/test-npm-package.ps1 +0 -252
  291. package/scripts/test-npm-package.sh +0 -207
  292. package/scripts/verify-install.ps1 +0 -294
  293. package/scripts/verify-install.sh +0 -266
  294. package/scripts/verify-v27.ps1 +0 -301
  295. package/scripts/verify-v27.sh +0 -233
  296. package/src/superlocalmemory.egg-info/PKG-INFO +0 -516
  297. package/src/superlocalmemory.egg-info/SOURCES.txt +0 -529
  298. package/src/superlocalmemory.egg-info/dependency_links.txt +0 -1
  299. package/src/superlocalmemory.egg-info/entry_points.txt +0 -2
  300. package/src/superlocalmemory.egg-info/requires.txt +0 -71
  301. package/src/superlocalmemory.egg-info/top_level.txt +0 -1
@@ -20,7 +20,7 @@ Port 8767: TCP redirect for backward compat (deprecated)
20
20
  24/7 by default. Opt-in auto-kill: --idle-timeout=1800
21
21
 
22
22
  Part of Qualixar | Author: Varun Pratap Bhardwaj
23
- License: Elastic-2.0
23
+ License: AGPL-3.0-or-later
24
24
  """
25
25
 
26
26
  from __future__ import annotations
@@ -34,7 +34,9 @@ import signal
34
34
  import sys
35
35
  import threading
36
36
  import time
37
+ import uuid
37
38
  from contextlib import asynccontextmanager, AsyncExitStack
39
+ from dataclasses import replace
38
40
  from datetime import datetime, timezone
39
41
  from pathlib import Path
40
42
  from typing import Optional
@@ -51,13 +53,85 @@ from fastapi.middleware.gzip import GZipMiddleware
51
53
  from pydantic import BaseModel
52
54
 
53
55
  from superlocalmemory.core.config import CANONICAL_RECALL_LIMIT
56
+ from superlocalmemory.infra.daemon_identity import (
57
+ DaemonDescriptor,
58
+ build_descriptor,
59
+ clear_descriptor,
60
+ descriptor_path,
61
+ write_descriptor,
62
+ )
63
+ from superlocalmemory.infra.data_root import (
64
+ assert_no_durable_root_conflict,
65
+ canonical_data_root,
66
+ state_path,
67
+ )
54
68
 
55
69
  logger = logging.getLogger("superlocalmemory.unified_daemon")
56
70
 
57
71
  _DEFAULT_PORT = 8765
58
72
  _LEGACY_PORT = 8767
59
- _PID_FILE = Path.home() / ".superlocalmemory" / "daemon.pid"
60
- _PORT_FILE = Path.home() / ".superlocalmemory" / "daemon.port"
73
+ _ACTIVE_DAEMON_DESCRIPTOR: DaemonDescriptor | None = None
74
+
75
+
76
+ def _configured_daemon_port() -> int:
77
+ """Return the configured bind port, falling back safely to the default."""
78
+ try:
79
+ return int(os.environ.get("SLM_DAEMON_PORT", "") or _DEFAULT_PORT)
80
+ except ValueError:
81
+ return _DEFAULT_PORT
82
+
83
+
84
+ def _process_descriptor(port: int, version: str, state: str) -> DaemonDescriptor:
85
+ """Return this process's stable namespace/instance identity."""
86
+ global _ACTIVE_DAEMON_DESCRIPTOR
87
+ if _ACTIVE_DAEMON_DESCRIPTOR is None:
88
+ descriptor = build_descriptor(
89
+ port=port,
90
+ version=version,
91
+ pid=os.getpid(),
92
+ instance_id=os.environ.get("SLM_DAEMON_INSTANCE_ID") or None,
93
+ capability=os.environ.get("SLM_DAEMON_CAPABILITY") or None,
94
+ state=state,
95
+ )
96
+ os.environ["SLM_DAEMON_INSTANCE_ID"] = descriptor.instance_id
97
+ os.environ["SLM_DAEMON_CAPABILITY"] = descriptor.capability
98
+ _ACTIVE_DAEMON_DESCRIPTOR = descriptor
99
+ elif _ACTIVE_DAEMON_DESCRIPTOR.state != state:
100
+ _ACTIVE_DAEMON_DESCRIPTOR = replace(
101
+ _ACTIVE_DAEMON_DESCRIPTOR,
102
+ state=state,
103
+ port=port,
104
+ version=version,
105
+ )
106
+ return _ACTIVE_DAEMON_DESCRIPTOR
107
+
108
+
109
+ def _publish_process_descriptor(
110
+ port: int, version: str, state: str,
111
+ ) -> DaemonDescriptor:
112
+ """Atomically publish identity plus one-release PID/port mirrors."""
113
+ descriptor = _process_descriptor(port, version, state)
114
+ write_descriptor(descriptor)
115
+ pid_file = descriptor_path().with_name("daemon.pid")
116
+ port_file = descriptor_path().with_name("daemon.port")
117
+ pid_file.write_text(str(descriptor.pid))
118
+ port_file.write_text(str(descriptor.port))
119
+ return descriptor
120
+
121
+
122
+ def _cleanup_process_descriptor(descriptor: DaemonDescriptor | None) -> None:
123
+ """Remove lifecycle state only when this process still owns the instance."""
124
+ if descriptor is None or not clear_descriptor(descriptor.instance_id):
125
+ return
126
+ for path, expected in (
127
+ (descriptor_path().with_name("daemon.pid"), str(descriptor.pid)),
128
+ (descriptor_path().with_name("daemon.port"), str(descriptor.port)),
129
+ ):
130
+ try:
131
+ if path.read_text().strip() == expected:
132
+ path.unlink()
133
+ except OSError:
134
+ pass
61
135
 
62
136
 
63
137
  # ---------------------------------------------------------------------------
@@ -68,6 +142,8 @@ class RememberRequest(BaseModel):
68
142
  content: str
69
143
  tags: str = ""
70
144
  metadata: dict | None = None # v3.4.26: pass-through from MCP pool_store
145
+ idempotency_key: str | None = None
146
+ session_id: str = ""
71
147
  # v3.6.15 multi-scope: visibility of the new memory. ``None`` scope means
72
148
  # "use the configured default_scope" (personal). shared_with is the list of
73
149
  # profile_ids for scope='shared'.
@@ -123,6 +199,7 @@ class EngineRecallAdapter:
123
199
  )
124
200
  # v3.6.6: same shared chokepoint as the HTTP route — identical output.
125
201
  from superlocalmemory.server.recall_serializer import (
202
+ recall_response_metadata,
126
203
  serialize_recall_response,
127
204
  )
128
205
  _rc = getattr(self._engine._config, "retrieval", None)
@@ -148,6 +225,7 @@ class EngineRecallAdapter:
148
225
  "total_candidates": getattr(response, "total_candidates", 0),
149
226
  "results": results,
150
227
  "no_confident_match": no_confident_match,
228
+ **recall_response_metadata(response),
151
229
  }
152
230
 
153
231
 
@@ -237,15 +315,15 @@ def _sanitize_json_text(text: str) -> str:
237
315
  # ---------------------------------------------------------------------------
238
316
 
239
317
  class ObserveBuffer:
240
- """Thread-safe debounce buffer for observation processing.
318
+ """Durable observation admission with a short duplicate window.
241
319
 
242
- Buffers observations for a configurable window, deduplicates by content
243
- hash, then processes as a batch via the singleton MemoryEngine.
320
+ An accepted observation is submitted to M018 before ``enqueue`` returns.
321
+ The timer clears only the in-memory duplicate set; it never owns evidence
322
+ or delays persistence.
244
323
  """
245
324
 
246
325
  def __init__(self, debounce_sec: float = 3.0):
247
326
  self._debounce_sec = debounce_sec
248
- self._buffer: list[str] = []
249
327
  self._seen: set[str] = set()
250
328
  self._lock = threading.Lock()
251
329
  self._timer: threading.Timer | None = None
@@ -254,17 +332,16 @@ class ObserveBuffer:
254
332
  def set_engine(self, engine) -> None:
255
333
  self._engine = engine
256
334
 
257
- def enqueue(self, content: str) -> dict:
258
- content_hash = hashlib.md5(content.encode()).hexdigest()
335
+ def enqueue(self, content: str, *, trusted_actor_id: str = "") -> dict:
336
+ content_hash = hashlib.sha256(content.encode("utf-8")).hexdigest()
259
337
  with self._lock:
260
338
  if content_hash in self._seen:
261
339
  return {"captured": False, "reason": "duplicate within debounce window"}
262
340
  self._seen.add(content_hash)
263
- self._buffer.append(content)
264
- buf_size = len(self._buffer)
341
+ window_size = len(self._seen)
265
342
  if self._timer is not None:
266
343
  self._timer.cancel()
267
- self._timer = threading.Timer(self._debounce_sec, self._flush)
344
+ self._timer = threading.Timer(self._debounce_sec, self._clear_seen)
268
345
  self._timer.daemon = True
269
346
  self._timer.start()
270
347
  _emit_event(
@@ -272,74 +349,113 @@ class ObserveBuffer:
272
349
  payload={
273
350
  "content_hash": content_hash,
274
351
  "content_preview": content[:120],
275
- "buffer_size": buf_size,
352
+ "buffer_size": window_size,
276
353
  },
277
354
  )
278
- return {"captured": True, "queued": True, "buffer_size": buf_size}
279
-
280
- def _flush(self) -> None:
281
- with self._lock:
282
- if not self._buffer:
283
- return
284
- batch = list(self._buffer)
285
- self._buffer.clear()
286
- self._seen.clear()
287
- self._timer = None
288
-
289
355
  if self._engine is None:
290
- return
356
+ with self._lock:
357
+ self._seen.discard(content_hash)
358
+ return {
359
+ "captured": False,
360
+ "durable": False,
361
+ "reason": "memory engine unavailable",
362
+ }
291
363
 
292
364
  try:
293
365
  from superlocalmemory.hooks.auto_capture import AutoCapture
294
- auto = AutoCapture(engine=self._engine)
295
- captured_count = 0
296
- failed_count = 0
297
- for content in batch:
298
- try:
299
- decision = auto.evaluate(content)
300
- if decision.capture:
301
- auto.capture(content, category=decision.category)
302
- # Stage-9: count only what was actually WRITTEN to memory.
303
- # The prior 'processed N' counted skipped (capture=False)
304
- # items as successes — a false-positive write count.
305
- captured_count += 1
306
- _emit_event(
307
- "memory.captured",
308
- payload={
309
- "category": decision.category,
310
- "confidence": getattr(decision, "confidence", None),
311
- "content_preview": content[:120],
312
- },
313
- )
314
- else:
315
- _emit_event(
316
- "memory.dropped",
317
- payload={
318
- "reason": getattr(decision, "reason", "no patterns matched"),
319
- "content_preview": content[:120],
320
- },
321
- )
322
- except Exception as exc:
323
- failed_count += 1
324
- logger.warning(
325
- "ObserveBuffer: auto.capture failed for content %.40r: %s",
326
- content,
327
- exc,
328
- )
329
- logger.info(
330
- "Observe debounce: evaluated=%d captured=%d failed=%d",
331
- len(batch),
332
- captured_count,
333
- failed_count,
366
+ from superlocalmemory.core.engine_ingestion import (
367
+ build_engine_ingestion_command,
368
+ )
369
+ from superlocalmemory.core.ingestion_command import IngestionRequest
370
+
371
+ decision = AutoCapture().evaluate(content)
372
+ if not decision.capture:
373
+ _emit_event(
374
+ "memory.dropped",
375
+ payload={
376
+ "reason": decision.reason,
377
+ "content_preview": content[:120],
378
+ },
379
+ )
380
+ return {
381
+ "captured": False,
382
+ "durable": False,
383
+ "reason": decision.reason,
384
+ "category": decision.category,
385
+ "confidence": round(decision.confidence, 3),
386
+ }
387
+
388
+ scope_config = getattr(self._engine._config, "scope", None)
389
+ scope = getattr(scope_config, "default_scope", "personal")
390
+ command = build_engine_ingestion_command(self._engine)
391
+ receipt = command.submit(IngestionRequest(
392
+ content=content,
393
+ profile_id=self._engine._profile_id,
394
+ source_type="http-observe",
395
+ idempotency_key=f"observe:v1:{content_hash}",
396
+ metadata={
397
+ "source": "auto-capture",
398
+ "category": decision.category,
399
+ "confidence": decision.confidence,
400
+ },
401
+ scope=scope,
402
+ trusted_actor_id=trusted_actor_id or _materializer_actor_id(),
403
+ ))
404
+ _emit_event(
405
+ "memory.captured",
406
+ payload={
407
+ "operation_id": receipt.operation_id,
408
+ "category": decision.category,
409
+ "confidence": decision.confidence,
410
+ "content_preview": content[:120],
411
+ },
334
412
  )
413
+ return {
414
+ "captured": True,
415
+ "durable": True,
416
+ "queued": receipt.state.value != "complete",
417
+ "operation_id": receipt.operation_id,
418
+ "fact_ids": list(receipt.fact_ids),
419
+ "materialization_state": receipt.state.value,
420
+ "category": decision.category,
421
+ "confidence": round(decision.confidence, 3),
422
+ }
335
423
  except Exception as exc:
336
- logger.error("ObserveBuffer: flush batch failed: %s", exc)
424
+ with self._lock:
425
+ self._seen.discard(content_hash)
426
+ logger.warning(
427
+ "ObserveBuffer: durable admission failed for content %.40r: %s",
428
+ content,
429
+ exc,
430
+ )
431
+ _emit_event(
432
+ "memory.dropped",
433
+ payload={
434
+ "reason": "durable admission failed",
435
+ "content_preview": content[:120],
436
+ },
437
+ )
438
+ return {
439
+ "captured": False,
440
+ "durable": False,
441
+ "reason": "durable admission failed",
442
+ "error": str(exc),
443
+ }
444
+
445
+ def _clear_seen(self) -> None:
446
+ with self._lock:
447
+ self._seen.clear()
448
+ self._timer = None
449
+
450
+ def _flush(self) -> None:
451
+ """Compatibility alias: no evidence is buffered in V3.7."""
452
+ self._clear_seen()
337
453
 
338
454
  def flush_sync(self) -> None:
339
- """Force flush for shutdown."""
455
+ """Clear duplicate-window state for shutdown."""
340
456
  if self._timer is not None:
341
457
  self._timer.cancel()
342
- self._flush()
458
+ self._clear_seen()
343
459
 
344
460
 
345
461
  _observe_buffer = ObserveBuffer(
@@ -440,18 +556,38 @@ async def lifespan(application: FastAPI):
440
556
  engine = None
441
557
  config = None
442
558
 
559
+ # The local dashboard obtains its short-lived browser credential from
560
+ # ``/internal/token`` before its first write or token-gated read. A
561
+ # completely fresh Mode A install may not ingest or recall anything during
562
+ # startup, so neither of those paths has created the install token yet.
563
+ # Create it as part of the daemon's durable identity bootstrap instead.
564
+ # This keeps the token endpoint read-only (and therefore fail-closed when a
565
+ # token is unexpectedly missing after startup) while making every normal
566
+ # daemon-backed dashboard usable from its first page load.
567
+ try:
568
+ from superlocalmemory.core.security_primitives import ensure_install_token
569
+
570
+ ensure_install_token()
571
+ except Exception as exc: # pragma: no cover - startup remains fail-soft
572
+ logger.warning("install-token bootstrap failed: %s", exc)
573
+
574
+ # Register the SSE bridge inside the application lifespan. FastAPI's
575
+ # legacy ``on_event`` hook is deprecated and, more importantly, made a
576
+ # second startup mechanism compete with the daemon's existing lifespan.
577
+ from superlocalmemory.server.routes.events import register_event_listener
578
+ register_event_listener()
579
+
443
580
  # H-21 (Stage 8) — first-boot-after-upgrade notice. Compare the cached
444
581
  # version marker against the current package version; if they differ
445
582
  # (fresh install or upgrade), log a one-time banner with a link to the
446
583
  # CHANGELOG. Non-fatal; any filesystem error is swallowed.
447
584
  try:
448
- from pathlib import Path as _VP
449
585
  try:
450
586
  from importlib.metadata import version as _pkg_version
451
587
  _slm_version = _pkg_version("superlocalmemory")
452
588
  except Exception:
453
589
  _slm_version = "unknown"
454
- _version_marker = _VP.home() / ".superlocalmemory" / ".last_version"
590
+ _version_marker = state_path(".last_version")
455
591
  _prev = None
456
592
  if _version_marker.exists():
457
593
  try:
@@ -488,9 +624,8 @@ async def lifespan(application: FastAPI):
488
624
  # engine init so later queries see the expected columns/tables.
489
625
  # Non-fatal: any failure here is logged and the daemon still starts.
490
626
  try:
491
- from pathlib import Path as _P
492
627
  from superlocalmemory.storage.migration_runner import apply_all
493
- _home = _P.home() / ".superlocalmemory"
628
+ _home = canonical_data_root()
494
629
  _learning_db = _home / "learning.db"
495
630
  _memory_db = _home / "memory.db"
496
631
  _result = apply_all(_learning_db, _memory_db)
@@ -520,7 +655,7 @@ async def lifespan(application: FastAPI):
520
655
  except Exception as _exc:
521
656
  logger.warning("migration runner crashed (non-fatal): %s", _exc)
522
657
  application.state.migration_result = {
523
- "applied": [], "skipped": [], "failed": [],
658
+ "applied": [], "skipped": [], "failed": ["_runner_crash"],
524
659
  "details": {"_crash": str(_exc)},
525
660
  }
526
661
 
@@ -565,16 +700,28 @@ async def lifespan(application: FastAPI):
565
700
  set_orchestrator(orch)
566
701
  _cozo_backend = orch.get_graph_backend()
567
702
  _lancedb_backend = orch.get_vector_backend()
568
- # Inject CozoDB into entity_graph channel (already has the param).
703
+ # Cozo storage may be active before its canonical-entity retrieval
704
+ # projection is parity-proven. Never route mismatched ID spaces.
569
705
  re = getattr(engine, '_retrieval_engine', None)
570
706
  if re is not None:
571
707
  eg = getattr(re, '_entity', None)
572
- if eg is not None and _cozo_backend is not None:
708
+ if (
709
+ eg is not None
710
+ and _cozo_backend is not None
711
+ and orch.graph_retrieval_ready()
712
+ ):
573
713
  try:
574
714
  eg._cozo = _cozo_backend
575
715
  logger.info("CozoDB backend wired into entity_graph channel")
576
716
  except Exception as exc:
577
717
  logger.warning("CozoDB channel injection failed: %s", exc)
718
+ semantic = getattr(re, '_semantic', None)
719
+ if semantic is not None and _lancedb_backend is not None:
720
+ try:
721
+ semantic.set_scale_vector_backend(_lancedb_backend)
722
+ logger.info("LanceDB backend wired into semantic channel with SQLite shadow")
723
+ except Exception as exc:
724
+ logger.warning("LanceDB channel injection failed: %s", exc)
578
725
  logger.info("BackendOrchestrator: ready (cozo=%s, lancedb=%s)",
579
726
  "active" if _cozo_backend else "off",
580
727
  "active" if _lancedb_backend else "off")
@@ -660,12 +807,12 @@ async def lifespan(application: FastAPI):
660
807
  # uses the daemon's engine directly via EngineRecallAdapter.
661
808
  # WorkerPool is still available as fallback for dashboard/chat routes.
662
809
 
663
- # Force reranker warmup
810
+ # The reranker constructor has already started its background warmup.
811
+ # Never block daemon publication here: a first-time model download or
812
+ # ONNX compilation previously held every CLI/MCP request for 120s.
813
+ # Until it is ready, retrieval uses its deterministic fallback scorer;
814
+ # the worker upgrades subsequent recalls without changing their API.
664
815
  retrieval_eng = getattr(engine, '_retrieval_engine', None)
665
- if retrieval_eng:
666
- reranker = getattr(retrieval_eng, '_reranker', None)
667
- if reranker and hasattr(reranker, 'warmup_sync'):
668
- reranker.warmup_sync(timeout=120)
669
816
 
670
817
  # V3.4.11: Pre-warm embedding worker (load ONNX model on startup)
671
818
  # Without this, first recall takes 60-90s for model load.
@@ -793,10 +940,9 @@ async def lifespan(application: FastAPI):
793
940
  # Previously routed through WorkerPool → recall_worker subprocess,
794
941
  # which loaded a duplicate MemoryEngine (~800 MB waste).
795
942
  try:
796
- from pathlib import Path as _QP
797
943
  from superlocalmemory.core.queue_consumer import QueueConsumer
798
944
  from superlocalmemory.core.recall_queue import RecallQueue
799
- _queue_db = _QP.home() / ".superlocalmemory" / "recall_queue.db"
945
+ _queue_db = state_path("recall_queue.db")
800
946
  _recall_queue = RecallQueue(_queue_db)
801
947
  _queue_consumer = QueueConsumer(
802
948
  queue=_recall_queue,
@@ -854,7 +1000,7 @@ async def lifespan(application: FastAPI):
854
1000
  mesh_enabled = getattr(config, 'mesh_enabled', True) if config else True
855
1001
  if mesh_enabled:
856
1002
  from superlocalmemory.mesh.broker import MeshBroker
857
- db_path = config.db_path if config else Path.home() / ".superlocalmemory" / "memory.db"
1003
+ db_path = config.db_path if config else state_path("memory.db")
858
1004
  mesh_broker = MeshBroker(str(db_path))
859
1005
  mesh_broker.start_cleanup()
860
1006
  application.state.mesh_broker = mesh_broker
@@ -874,7 +1020,8 @@ async def lifespan(application: FastAPI):
874
1020
  # Start legacy port redirect
875
1021
  enable_legacy = os.environ.get("SLM_DISABLE_LEGACY_PORT", "").lower() not in ("1", "true")
876
1022
  if enable_legacy:
877
- asyncio.create_task(_start_legacy_redirect(_DEFAULT_PORT, _LEGACY_PORT))
1023
+ identity = application.state.daemon_descriptor
1024
+ asyncio.create_task(_start_legacy_redirect(identity.port, _LEGACY_PORT))
878
1025
 
879
1026
  # V3.4.22 LLD-02: signal-worker background drainer (S8-SK-01 fix).
880
1027
  # Without this, ``signals.enqueue`` fills a bounded queue and drops
@@ -883,8 +1030,7 @@ async def lifespan(application: FastAPI):
883
1030
  if os.environ.get("SLM_SIGNALS_ENABLED", "1") != "0":
884
1031
  try:
885
1032
  from superlocalmemory.learning import signal_worker as _sw
886
- from pathlib import Path as _P
887
- _learning_db = _P.home() / ".superlocalmemory" / "learning.db"
1033
+ _learning_db = state_path("learning.db")
888
1034
  _sw.start(_learning_db)
889
1035
  application.state.signal_worker_started = True
890
1036
  logger.info("signal_worker started on %s", _learning_db)
@@ -920,11 +1066,12 @@ async def lifespan(application: FastAPI):
920
1066
  # Python's logging module then wrote the full stack to stderr. Because the
921
1067
  # call runs inside FastAPI's stacked merged_lifespan, each dump was ~30 KB
922
1068
  # and the error log grew to tens of MB within a day.
1069
+ _display_port = _configured_daemon_port()
923
1070
  if idle_timeout <= 0:
924
- _ready_msg = f"Unified daemon ready on port {_DEFAULT_PORT} (24/7 mode)"
1071
+ _ready_msg = f"Unified daemon ready on port {_display_port} (24/7 mode)"
925
1072
  else:
926
1073
  _ready_msg = (
927
- f"Unified daemon ready on port {_DEFAULT_PORT} "
1074
+ f"Unified daemon ready on port {_display_port} "
928
1075
  f"(idle timeout: {idle_timeout}s)"
929
1076
  )
930
1077
  logger.info(_ready_msg)
@@ -994,6 +1141,13 @@ async def lifespan(application: FastAPI):
994
1141
  _mcp_lifespan_exc,
995
1142
  )
996
1143
 
1144
+ # Uvicorn enters this lifespan only after it has bound the listener.
1145
+ # Publishing ``ready`` here prevents a failed competing process from
1146
+ # overwriting the live daemon descriptor before it owns the port.
1147
+ from superlocalmemory.server.routes.helpers import SLM_VERSION
1148
+ application.state.daemon_descriptor = _publish_process_descriptor(
1149
+ _configured_daemon_port(), SLM_VERSION, "ready",
1150
+ )
997
1151
  yield
998
1152
 
999
1153
  # Cancel optimize metrics flush loop + run final flush before shutdown
@@ -1166,8 +1320,9 @@ async def lifespan(application: FastAPI):
1166
1320
  engine.close()
1167
1321
  except Exception:
1168
1322
  pass
1169
- _PID_FILE.unlink(missing_ok=True)
1170
- _PORT_FILE.unlink(missing_ok=True)
1323
+ _cleanup_process_descriptor(
1324
+ getattr(application.state, "daemon_descriptor", None),
1325
+ )
1171
1326
  logger.info("Unified daemon shutdown complete")
1172
1327
 
1173
1328
 
@@ -1175,6 +1330,24 @@ async def lifespan(application: FastAPI):
1175
1330
  # App factory
1176
1331
  # ---------------------------------------------------------------------------
1177
1332
 
1333
+ def _configure_mcp_transport_settings(fastmcp) -> bool:
1334
+ """Apply the current transport mode without leaking singleton state.
1335
+
1336
+ ``superlocalmemory.mcp.server.server`` is process-global. App factories
1337
+ are invoked more than once by tests and embedded hosts, so both flags must
1338
+ be assigned on every call; an earlier stateless app must not silently turn
1339
+ a later default app stateless. Keeping this small policy separate also
1340
+ lets tests exercise the wiring without reloading FastMCP and rebuilding
1341
+ hundreds of Pydantic models in a native-heavy Python process.
1342
+ """
1343
+ from superlocalmemory.core.remote_mode import mcp_stateless
1344
+
1345
+ stateless = bool(mcp_stateless())
1346
+ fastmcp.settings.stateless_http = stateless
1347
+ fastmcp.settings.json_response = stateless
1348
+ return stateless
1349
+
1350
+
1178
1351
  def create_app() -> FastAPI:
1179
1352
  """Create the unified FastAPI application."""
1180
1353
  from superlocalmemory.server.routes.helpers import SLM_VERSION
@@ -1185,6 +1358,10 @@ def create_app() -> FastAPI:
1185
1358
  version=SLM_VERSION,
1186
1359
  lifespan=lifespan,
1187
1360
  )
1361
+ identity_port = _configured_daemon_port()
1362
+ application.state.daemon_descriptor = _process_descriptor(
1363
+ identity_port, SLM_VERSION, "starting",
1364
+ )
1188
1365
 
1189
1366
  # -- Middleware --
1190
1367
  from superlocalmemory.server.security_middleware import SecurityHeadersMiddleware
@@ -1199,7 +1376,10 @@ def create_app() -> FastAPI:
1199
1376
  ],
1200
1377
  allow_credentials=True,
1201
1378
  allow_methods=["GET", "POST", "PUT", "DELETE", "PATCH", "OPTIONS"],
1202
- allow_headers=["Content-Type", "Authorization", "X-SLM-API-Key"],
1379
+ allow_headers=[
1380
+ "Content-Type", "Authorization", "X-SLM-API-Key",
1381
+ "X-SLM-Daemon-Capability", "X-SLM-Target-Instance",
1382
+ ],
1203
1383
  )
1204
1384
 
1205
1385
  # -- Register all dashboard routes (from existing api.py) --
@@ -1359,10 +1539,8 @@ def create_app() -> FastAPI:
1359
1539
  # clients keep full stateful sessions); enabled by SLM_REMOTE=1 or
1360
1540
  # SLM_MCP_STATELESS=1. Per-agent /mcp/{agent_id} routing is unaffected
1361
1541
  # (path-based, not session-based).
1362
- from superlocalmemory.core.remote_mode import mcp_stateless, is_remote_mode
1363
- if mcp_stateless():
1364
- _mcp_fastmcp.settings.stateless_http = True
1365
- _mcp_fastmcp.settings.json_response = True
1542
+ from superlocalmemory.core.remote_mode import is_remote_mode
1543
+ if _configure_mcp_transport_settings(_mcp_fastmcp):
1366
1544
  if is_remote_mode():
1367
1545
  logger.warning(
1368
1546
  "MCP transport: STATELESS mode ON (SLM_REMOTE) — LAN "
@@ -1387,7 +1565,11 @@ def create_app() -> FastAPI:
1387
1565
  from superlocalmemory.mcp.agent_context import AgentIDExtractorASGI
1388
1566
 
1389
1567
  application.mount("/mcp", AgentIDExtractorASGI(_mcp_app))
1390
- logger.info("MCP HTTP transport mounted at /mcp (Streamable HTTP, port 8765; per-agent routing enabled)")
1568
+ logger.info(
1569
+ "MCP HTTP transport mounted at /mcp (Streamable HTTP, port %d; "
1570
+ "per-agent routing enabled)",
1571
+ _configured_daemon_port(),
1572
+ )
1391
1573
  except Exception as _mcp_exc: # pragma: no cover — defensive
1392
1574
  logger.warning("MCP HTTP mount failed (non-fatal, stdio still works): %s", _mcp_exc)
1393
1575
 
@@ -1450,16 +1632,20 @@ def _register_dashboard_routes(application: FastAPI) -> None:
1450
1632
 
1451
1633
  # Auth middleware (graceful)
1452
1634
  try:
1453
- from superlocalmemory.infra.auth_middleware import check_api_key
1635
+ from superlocalmemory.infra.auth_middleware import (
1636
+ authorize_http_mcp_request,
1637
+ check_api_key,
1638
+ )
1639
+ from superlocalmemory.server.write_identity import (
1640
+ require_http_mutation_actor,
1641
+ )
1454
1642
 
1455
1643
  # Auth-exempt path prefixes — proxy routes carry provider API keys
1456
1644
  # (x-api-key for Anthropic, Authorization: Bearer for OpenAI, x-goog-api-key
1457
1645
  # for Gemini), never X-SLM-API-Key. Verified: auth_middleware.py:50-82
1458
1646
  # returns False for POST when api_key file exists and X-SLM-API-Key
1459
1647
  # is absent.
1460
- # v3.6.7: /mcp is also exempt — MCP clients negotiate their own session
1461
- # via the MCP protocol; they have no knowledge of X-SLM-API-Key.
1462
- _AUTH_EXEMPT_PREFIXES = ("/v1/", "/v1beta/", "/mcp")
1648
+ _AUTH_EXEMPT_PREFIXES = ("/v1/", "/v1beta/")
1463
1649
 
1464
1650
  @application.middleware("http")
1465
1651
  async def auth_middleware(request, call_next):
@@ -1467,13 +1653,27 @@ def _register_dashboard_routes(application: FastAPI) -> None:
1467
1653
  if request.url.path.startswith(_AUTH_EXEMPT_PREFIXES):
1468
1654
  return await call_next(request)
1469
1655
  is_write = request.method in ("POST", "PUT", "DELETE", "PATCH")
1656
+ records_recall_telemetry = request.url.path.startswith("/recall")
1657
+ requires_mutation_actor = is_write or records_recall_telemetry
1470
1658
  headers = dict(request.headers)
1659
+ client_host = request.client.host if request.client else ""
1660
+ if request.url.path.startswith("/mcp") and not authorize_http_mcp_request(
1661
+ headers,
1662
+ client_host=client_host,
1663
+ ):
1664
+ from fastapi.responses import JSONResponse
1665
+ return JSONResponse(
1666
+ status_code=401,
1667
+ content={
1668
+ "error": "Remote HTTP MCP requires a configured SLM API key."
1669
+ },
1670
+ )
1471
1671
  # v3.6.12 (csrf-1): defense-in-depth CSRF/DNS-rebinding guard on
1472
1672
  # state-changing requests. A cross-origin browser Origin is rejected;
1473
1673
  # loopback origins (the local dashboard) always pass, and LAN origins
1474
1674
  # pass only when explicitly allowlisted in SLM_REMOTE mode. Non-browser
1475
1675
  # clients (CLI/MCP/curl) send no Origin and are unaffected.
1476
- if is_write:
1676
+ if requires_mutation_actor:
1477
1677
  _origin = headers.get("origin", "") or headers.get("Origin", "")
1478
1678
  if _origin:
1479
1679
  _ok_origin = any(_origin.startswith(p) for p in (
@@ -1490,6 +1690,26 @@ def _register_dashboard_routes(application: FastAPI) -> None:
1490
1690
  status_code=403,
1491
1691
  content={"error": "cross-origin request rejected"},
1492
1692
  )
1693
+ _mesh_secret = None
1694
+ if request.url.path.startswith("/mesh"):
1695
+ _mesh_broker = getattr(application.state, "mesh_broker", None)
1696
+ _mesh_secret = getattr(_mesh_broker, "_shared_secret", None)
1697
+ try:
1698
+ request.state.authenticated_actor = require_http_mutation_actor(
1699
+ request,
1700
+ getattr(application.state, "daemon_descriptor", None),
1701
+ actor_kind="http-route",
1702
+ mesh_secret=_mesh_secret,
1703
+ )
1704
+ except Exception as _identity_exc:
1705
+ from fastapi import HTTPException as _HTTPException
1706
+ from fastapi.responses import JSONResponse
1707
+ if isinstance(_identity_exc, _HTTPException):
1708
+ return JSONResponse(
1709
+ status_code=_identity_exc.status_code,
1710
+ content={"error": str(_identity_exc.detail)},
1711
+ )
1712
+ raise
1493
1713
  if not check_api_key(headers, is_write=is_write):
1494
1714
  from fastapi.responses import JSONResponse
1495
1715
  return JSONResponse(
@@ -1515,7 +1735,7 @@ def _register_dashboard_routes(application: FastAPI) -> None:
1515
1735
 
1516
1736
  @application.middleware("http")
1517
1737
  async def _failclosed_auth(request, call_next):
1518
- if request.url.path.startswith(("/v1/", "/v1beta/", "/mcp")):
1738
+ if request.url.path.startswith(("/v1/", "/v1beta/")):
1519
1739
  return await call_next(request)
1520
1740
  is_write = request.method in ("POST", "PUT", "DELETE", "PATCH")
1521
1741
  client_host = request.client.host if request.client else ""
@@ -1538,9 +1758,7 @@ def _register_dashboard_routes(application: FastAPI) -> None:
1538
1758
  from superlocalmemory.server.routes.profiles import router as profiles_router
1539
1759
  from superlocalmemory.server.routes.backup import router as backup_router
1540
1760
  from superlocalmemory.server.routes.data_io import router as data_io_router
1541
- from superlocalmemory.server.routes.events import (
1542
- router as events_router, register_event_listener,
1543
- )
1761
+ from superlocalmemory.server.routes.events import router as events_router
1544
1762
  from superlocalmemory.server.routes.agents import router as agents_router
1545
1763
  from superlocalmemory.server.routes.ws import router as ws_router, manager as ws_manager
1546
1764
  from superlocalmemory.server.routes.v3_api import router as v3_router
@@ -1661,12 +1879,6 @@ def _register_dashboard_routes(application: FastAPI) -> None:
1661
1879
  html = index_path.read_text()
1662
1880
  return html.replace("__SLM_VERSION__", _SLM_VERSION)
1663
1881
 
1664
- # Startup event for event listener
1665
- @application.on_event("startup")
1666
- async def startup_event():
1667
- register_event_listener()
1668
-
1669
-
1670
1882
  def _register_daemon_routes(application: FastAPI) -> None:
1671
1883
  """Add daemon-specific routes for CLI integration."""
1672
1884
  global _last_activity
@@ -1685,11 +1897,56 @@ def _register_daemon_routes(application: FastAPI) -> None:
1685
1897
  raise HTTPException(503, detail="Engine not initialized")
1686
1898
  return engine
1687
1899
 
1900
+ def _require_daemon_actor(request: Request) -> str:
1901
+ """Authenticate the private capability for this exact process."""
1902
+ from superlocalmemory.server.write_identity import require_daemon_actor
1903
+
1904
+ return require_daemon_actor(
1905
+ request,
1906
+ getattr(application.state, "daemon_descriptor", None),
1907
+ )
1908
+
1909
+ def _require_write_actor(request: Request) -> str:
1910
+ """Authenticate a local write and return its trusted actor.
1911
+
1912
+ Caller-provided agent labels are audit metadata only. A mutating
1913
+ daemon client may borrow the process actor only after proving the
1914
+ private capability for this exact instance. Same-origin dashboard
1915
+ writers instead present the install token, which is never the daemon
1916
+ process capability.
1917
+ """
1918
+ from superlocalmemory.server.write_identity import require_write_actor
1919
+
1920
+ return require_write_actor(
1921
+ request,
1922
+ getattr(application.state, "daemon_descriptor", None),
1923
+ actor_kind="dashboard",
1924
+ )
1925
+
1688
1926
  @application.get("/health")
1689
1927
  async def health():
1690
1928
  _update_activity()
1691
1929
  # Non-blocking peek: report status without forcing a re-init.
1692
1930
  engine = getattr(application.state, "engine", None)
1931
+ migration_result = getattr(application.state, "migration_result", None)
1932
+ migration_failures = list(
1933
+ (migration_result or {}).get("failed", []) or []
1934
+ )
1935
+ migration_details = (migration_result or {}).get("details", {}) or {}
1936
+ migrations_ready = bool(migration_result) and not migration_failures
1937
+ if migration_details.get("_crash"):
1938
+ migrations_ready = False
1939
+ readiness = {
1940
+ "engine": engine is not None,
1941
+ "migrations": migrations_ready,
1942
+ "retrieval": bool(_embedding_warm),
1943
+ "migration_failures": migration_failures,
1944
+ }
1945
+ base_ready = all((readiness["engine"], readiness["migrations"]))
1946
+ fully_ready = base_ready and readiness["retrieval"]
1947
+ runtime_state = (
1948
+ "ready" if fully_ready else "warming" if base_ready else "not_ready"
1949
+ )
1693
1950
  # v3.6.8: surface the recall-health verdict so a silently-degraded
1694
1951
  # recall path (warm-but-broken embedder) is VISIBLE, never silent.
1695
1952
  try:
@@ -1697,8 +1954,11 @@ def _register_daemon_routes(application: FastAPI) -> None:
1697
1954
  _recall_health = get_recall_health()
1698
1955
  except Exception:
1699
1956
  _recall_health = {"recall_healthy": None}
1957
+ identity = getattr(application.state, "daemon_descriptor", None)
1700
1958
  return {
1701
1959
  "status": "ok",
1960
+ "ready": fully_ready,
1961
+ "readiness": readiness,
1702
1962
  "pid": os.getpid(),
1703
1963
  "engine": "initialized" if engine else "unavailable",
1704
1964
  "version": getattr(application, 'version', 'unknown'),
@@ -1708,6 +1968,10 @@ def _register_daemon_routes(application: FastAPI) -> None:
1708
1968
  # v3.6.8: True iff the semantic channel actually fired on the last
1709
1969
  # health probe; includes self-heal counters.
1710
1970
  "recall_health": _recall_health,
1971
+ **(identity.public_health_fields() if identity is not None else {}),
1972
+ # Runtime readiness is more precise than descriptor lifecycle.
1973
+ # A process can be alive and identity-valid while retrieval warms.
1974
+ "state": runtime_state,
1711
1975
  }
1712
1976
 
1713
1977
  @application.get("/recall")
@@ -1736,6 +2000,16 @@ def _register_daemon_routes(application: FastAPI) -> None:
1736
2000
  if not effective_sid:
1737
2001
  import time as _t
1738
2002
  effective_sid = f"http:{int(_t.time() * 1000)}"
2003
+ recall_actor = getattr(request.state, "authenticated_actor", "")
2004
+ if not recall_actor:
2005
+ from superlocalmemory.server.write_identity import (
2006
+ require_http_mutation_actor,
2007
+ )
2008
+ recall_actor = require_http_mutation_actor(
2009
+ request,
2010
+ getattr(application.state, "daemon_descriptor", None),
2011
+ actor_kind="http-recall",
2012
+ )
1739
2013
  # v3.4.32: mark recall in-flight so the pending materializer pauses
1740
2014
  # v3.4.52: run engine.recall() in a thread-pool executor so the
1741
2015
  # FastAPI event loop stays responsive for /health, /remember, and
@@ -1747,13 +2021,15 @@ def _register_daemon_routes(application: FastAPI) -> None:
1747
2021
  # prevent resource oversaturation. Ollama serialises concurrent
1748
2022
  # embedding calls and the reranker subprocess has a single lock —
1749
2023
  # queuing more than ~3 concurrent full recalls just adds latency.
1750
- # Fast recalls (SQLite/BM25 only) skip the semaphore.
2024
+ # Fast recalls retain the bounded retrieval channels but skip remote
2025
+ # agentic verification, so they do not need the full-recall semaphore.
1751
2026
  if not fast:
1752
2027
  await _recall_semaphore.acquire()
1753
2028
  try:
1754
2029
  response = await asyncio.to_thread(
1755
2030
  engine.recall,
1756
2031
  search_query, limit=limit, session_id=effective_sid,
2032
+ agent_id=recall_actor,
1757
2033
  fast=fast,
1758
2034
  include_global=include_global,
1759
2035
  include_shared=include_shared,
@@ -1772,6 +2048,7 @@ def _register_daemon_routes(application: FastAPI) -> None:
1772
2048
  # v3.6.6: single shared serialization chokepoint — budget + source
1773
2049
  # discipline + no_confident_match, identical across every surface.
1774
2050
  from superlocalmemory.server.recall_serializer import (
2051
+ recall_response_metadata,
1775
2052
  serialize_recall_response,
1776
2053
  )
1777
2054
  _rc = getattr(engine._config, "retrieval", None)
@@ -1800,6 +2077,7 @@ def _register_daemon_routes(application: FastAPI) -> None:
1800
2077
  "results": results,
1801
2078
  "count": len(results),
1802
2079
  "no_confident_match": no_confident_match,
2080
+ **recall_response_metadata(response),
1803
2081
  }
1804
2082
  except Exception as exc:
1805
2083
  raise HTTPException(500, detail=str(exc))
@@ -1809,12 +2087,18 @@ def _register_daemon_routes(application: FastAPI) -> None:
1809
2087
  _end_recall()
1810
2088
 
1811
2089
  @application.post("/remember")
1812
- async def remember(req: RememberRequest, wait: bool = False):
1813
- """v3.4.32: Async by default — writes to pending.db, returns pending_id
1814
- in <100ms. Materializer thread drains at low priority, yielding to
1815
- /search. Pass ``?wait=true`` for legacy synchronous behavior (blocks
1816
- on the embedder until facts are written).
2090
+ async def remember(
2091
+ req: RememberRequest,
2092
+ request: Request,
2093
+ wait: bool = False,
2094
+ ):
2095
+ """Persist through the durable canonical ingestion state machine.
2096
+
2097
+ The default path returns after the relational/FTS projection is
2098
+ queryable. ``wait=true`` materializes the same operation inline; the
2099
+ background worker handles all other queryable operations.
1817
2100
  """
2101
+ trusted_actor_id = _require_write_actor(request)
1818
2102
  _update_activity()
1819
2103
  engine = _get_engine_or_503()
1820
2104
 
@@ -1825,95 +2109,100 @@ def _register_daemon_routes(application: FastAPI) -> None:
1825
2109
  scope = req.scope or getattr(_scope_cfg, "default_scope", "personal")
1826
2110
  shared_with = req.shared_with
1827
2111
 
1828
- if wait:
1829
- try:
1830
- metadata = {"tags": req.tags} if req.tags else {}
1831
- extra = getattr(req, "metadata", None)
1832
- if isinstance(extra, dict):
1833
- metadata.update(extra)
1834
- fact_ids = engine.store(
1835
- req.content, metadata=metadata,
1836
- scope=scope, shared_with=shared_with,
1837
- )
1838
- _emit_event(
1839
- "memory.stored",
1840
- payload={
1841
- "fact_ids": list(fact_ids) if fact_ids else [],
1842
- "count": len(fact_ids) if fact_ids else 0,
1843
- "path": "remember_sync",
1844
- "content_preview": req.content[:120],
1845
- },
1846
- )
1847
- return {"ok": True, "fact_ids": fact_ids, "count": len(fact_ids)}
1848
- except Exception as exc:
1849
- raise HTTPException(500, detail=str(exc))
1850
-
1851
2112
  try:
1852
- from superlocalmemory.cli.pending_store import store_pending
2113
+ from superlocalmemory.core.engine_ingestion import (
2114
+ build_engine_ingestion_command,
2115
+ )
2116
+ from superlocalmemory.core.ingestion_command import (
2117
+ IngestionRequest,
2118
+ IngestionState,
2119
+ )
2120
+
1853
2121
  meta = {}
1854
2122
  if req.tags:
1855
2123
  meta["tags"] = req.tags
1856
2124
  extra = getattr(req, "metadata", None)
1857
2125
  if isinstance(extra, dict):
1858
2126
  meta.update(extra)
1859
- # v3.6.15 multi-scope: persist the resolved scope INSIDE the pending
1860
- # metadata so the materializer (_process_pending_memories) replays
1861
- # the write with the correct visibility instead of defaulting to
1862
- # personal. Non-personal only — keeps personal rows byte-identical
1863
- # to pre-3.6.15 so nothing downstream sees a new key by default.
1864
- if scope and scope != "personal":
1865
- meta["scope"] = scope
1866
- if shared_with:
1867
- meta["shared_with"] = shared_with
1868
- # v3.5.5 WRITE-THROUGH: synchronous verbatim insert → the memory is
1869
- # keyword/BM25-recallable the instant this returns (~ms). Closes the
1870
- # recall window so a parallel/next agent finds memories saved seconds
1871
- # ago. Embedding/graph enrichment is deferred to the materializer.
1872
- fact_ids: list[str] = []
1873
- try:
1874
- fact_ids = engine.store_fast(
1875
- req.content, metadata=meta,
1876
- scope=scope, shared_with=shared_with,
1877
- )
1878
- except Exception as fexc:
1879
- logger.warning("store_fast failed, falling back to pending-only: %s", fexc)
1880
- # Enqueue for async enrichment (embedding + entities + graph). The
1881
- # materializer detects the already-inserted verbatim fact and enriches
1882
- # it in place rather than duplicating.
1883
- pending_id = store_pending(
1884
- req.content, tags=req.tags or "", metadata=meta,
1885
- )
2127
+ command = build_engine_ingestion_command(engine)
2128
+ receipt = command.submit(IngestionRequest(
2129
+ content=req.content,
2130
+ profile_id=engine._profile_id,
2131
+ source_type="http",
2132
+ idempotency_key=req.idempotency_key or uuid.uuid4().hex,
2133
+ metadata=meta,
2134
+ scope=scope,
2135
+ shared_with=tuple(shared_with or ()),
2136
+ trusted_actor_id=trusted_actor_id,
2137
+ session_id=req.session_id,
2138
+ ))
2139
+
2140
+ result = command.materialize(receipt.operation_id) if wait else receipt
2141
+ if result.state is IngestionState.FAILED:
2142
+ raise RuntimeError(result.last_error or "materialization failed")
2143
+
2144
+ fact_ids = list(result.fact_ids)
1886
2145
  _emit_event(
1887
- "memory.queued",
2146
+ "memory.stored" if wait else "memory.queued",
1888
2147
  payload={
1889
- "pending_id": pending_id,
2148
+ "operation_id": result.operation_id,
2149
+ "fact_ids": fact_ids,
1890
2150
  "tags": req.tags or "",
1891
2151
  "content_preview": req.content[:120],
2152
+ "path": "remember_sync" if wait else "remember_queryable",
1892
2153
  },
1893
2154
  )
1894
2155
  return {
1895
2156
  "ok": True,
1896
2157
  "fact_ids": fact_ids,
1897
2158
  "count": len(fact_ids),
1898
- "pending_id": pending_id,
1899
- "status": "stored" if fact_ids else "queued",
1900
- "note": "write-through: recallable now; enriching async",
2159
+ "operation_id": result.operation_id,
2160
+ # One-release compatibility alias. The durable operation ID is
2161
+ # opaque and replaces the integer pending.db row identifier.
2162
+ "pending_id": result.operation_id,
2163
+ "status": "stored" if wait else "queryable",
2164
+ "materialization_state": result.state.value,
2165
+ "note": (
2166
+ "canonical ingestion complete"
2167
+ if wait
2168
+ else "queryable now; canonical enrichment pending"
2169
+ ),
1901
2170
  }
1902
2171
  except Exception as exc:
1903
2172
  raise HTTPException(500, detail=str(exc))
1904
2173
 
1905
2174
  @application.post("/observe")
1906
- async def observe(req: ObserveRequest):
2175
+ async def observe(req: ObserveRequest, request: Request):
1907
2176
  _update_activity()
1908
- result = _observe_buffer.enqueue(req.content)
2177
+ from superlocalmemory.server.write_identity import (
2178
+ authenticated_request_actor,
2179
+ )
2180
+ actor_id = authenticated_request_actor(
2181
+ request,
2182
+ getattr(application.state, "daemon_descriptor", None),
2183
+ actor_kind="http-observe",
2184
+ )
2185
+ result = _observe_buffer.enqueue(
2186
+ req.content,
2187
+ trusted_actor_id=actor_id,
2188
+ )
1909
2189
  return result
1910
2190
 
1911
2191
  # v3.4.26: CCQ consolidation via daemon so MCP clients don't need to
1912
2192
  # import CognitiveConsolidator (which pulls sentence-transformers).
1913
2193
  @application.post("/consolidate/cognitive")
1914
- async def consolidate_cognitive_endpoint(body: dict):
2194
+ async def consolidate_cognitive_endpoint(body: dict, request: Request):
1915
2195
  _update_activity()
1916
2196
  engine = _get_engine_or_503()
2197
+ from superlocalmemory.server.route_mutations import (
2198
+ authorize_route_mutation,
2199
+ )
2200
+ authorization = authorize_route_mutation(
2201
+ request,
2202
+ operation="update",
2203
+ source_agent_id="http-cognitive-consolidation",
2204
+ profile_id=body.get("profile_id") or engine.profile_id,
2205
+ )
1917
2206
  try:
1918
2207
  pid = body.get("profile_id") or engine.profile_id
1919
2208
  from superlocalmemory.encoding.cognitive_consolidator import (
@@ -1921,21 +2210,33 @@ def _register_daemon_routes(application: FastAPI) -> None:
1921
2210
  )
1922
2211
  consolidator = CognitiveConsolidator(db=engine._db)
1923
2212
  result = consolidator.run_pipeline(pid)
2213
+ authorization.complete()
1924
2214
  return {
1925
2215
  "ok": True,
1926
2216
  "profile_id": pid,
1927
2217
  "clusters_processed": result.clusters_processed,
1928
2218
  "blocks_created": result.blocks_created,
1929
2219
  }
2220
+ except HTTPException:
2221
+ raise
1930
2222
  except Exception as exc:
1931
2223
  raise HTTPException(500, detail=str(exc))
1932
2224
 
1933
2225
  # v3.4.26: run_maintenance via daemon so MCP doesn't import
1934
2226
  # EbbinghausCurve, ForgettingScheduler, or ConsolidationWorker.
1935
2227
  @application.post("/maintenance/run")
1936
- async def run_maintenance_endpoint(body: dict):
2228
+ async def run_maintenance_endpoint(body: dict, request: Request):
1937
2229
  _update_activity()
1938
2230
  engine = _get_engine_or_503()
2231
+ from superlocalmemory.server.route_mutations import (
2232
+ authorize_route_mutation,
2233
+ )
2234
+ authorization = authorize_route_mutation(
2235
+ request,
2236
+ operation="update",
2237
+ source_agent_id="http-maintenance",
2238
+ profile_id=body.get("profile_id") or engine.profile_id,
2239
+ )
1939
2240
  try:
1940
2241
  pid = body.get("profile_id") or engine.profile_id
1941
2242
  results: dict = {}
@@ -1969,7 +2270,10 @@ def _register_daemon_routes(application: FastAPI) -> None:
1969
2270
  results["behavioral"] = {"patterns_mined": count}
1970
2271
  except Exception as exc:
1971
2272
  results["behavioral"] = {"error": str(exc)}
2273
+ authorization.complete()
1972
2274
  return {"ok": True, "profile": pid, **results}
2275
+ except HTTPException:
2276
+ raise
1973
2277
  except Exception as exc:
1974
2278
  raise HTTPException(500, detail=str(exc))
1975
2279
 
@@ -1987,7 +2291,7 @@ def _register_daemon_routes(application: FastAPI) -> None:
1987
2291
  "mode": mode,
1988
2292
  "fact_count": fact_count,
1989
2293
  "idle_s": round(time.monotonic() - _last_activity),
1990
- "port": _DEFAULT_PORT,
2294
+ "port": application.state.daemon_descriptor.port,
1991
2295
  "legacy_port": _LEGACY_PORT,
1992
2296
  }
1993
2297
 
@@ -2011,8 +2315,9 @@ def _register_daemon_routes(application: FastAPI) -> None:
2011
2315
  raise HTTPException(500, detail=str(exc))
2012
2316
 
2013
2317
  @application.post("/stop")
2014
- async def stop():
2015
- """Graceful shutdown via uvicorn's mechanism."""
2318
+ async def stop(request: Request):
2319
+ """Gracefully stop only the capability-bound process instance."""
2320
+ _require_daemon_actor(request)
2016
2321
  logger.info("Stop requested via API")
2017
2322
  _observe_buffer.flush_sync()
2018
2323
  # Signal uvicorn to shut down gracefully
@@ -2020,7 +2325,7 @@ def _register_daemon_routes(application: FastAPI) -> None:
2020
2325
  return {"status": "stopping"}
2021
2326
 
2022
2327
  @application.post("/session/open")
2023
- async def session_open(req: SessionOpenRequest):
2328
+ async def session_open(req: SessionOpenRequest, request: Request):
2024
2329
  """#49: Open a session locally — warm recall context with no model
2025
2330
  roundtrip, so a shell/session-start hook can call it directly
2026
2331
  (`slm session open`) instead of going through the MCP tool.
@@ -2034,25 +2339,48 @@ def _register_daemon_routes(application: FastAPI) -> None:
2034
2339
  else:
2035
2340
  query = "recent important decisions"
2036
2341
  try:
2037
- resp = engine.recall(query, limit=req.max_results)
2342
+ from superlocalmemory.server.write_identity import (
2343
+ authenticated_request_actor,
2344
+ )
2345
+ actor_id = authenticated_request_actor(
2346
+ request,
2347
+ getattr(application.state, "daemon_descriptor", None),
2348
+ actor_kind="http-session-open",
2349
+ )
2350
+ resp = engine.recall(
2351
+ query,
2352
+ limit=req.max_results,
2353
+ agent_id=actor_id,
2354
+ )
2038
2355
  results = (
2039
2356
  getattr(resp, "results", None)
2040
2357
  or getattr(resp, "memories", None)
2041
2358
  or []
2042
2359
  )
2043
2360
  return {"ok": True, "query": query, "warmed": len(results)}
2361
+ except HTTPException:
2362
+ raise
2044
2363
  except Exception as exc:
2045
2364
  # Warming is best-effort — never fail the session-open hook.
2046
2365
  return {"ok": True, "query": query, "warmed": 0, "warning": str(exc)}
2047
2366
 
2048
2367
  @application.post("/session/close")
2049
- async def session_close(req: SessionCloseRequest):
2368
+ async def session_close(req: SessionCloseRequest, request: Request):
2050
2369
  """#49: Close a session locally (e.g. a Claude /quit hook calling
2051
2370
  `slm session close`). Creates per-entity temporal summary events.
2052
2371
  An empty session_id closes the most recent real session.
2053
2372
  """
2054
2373
  _update_activity()
2055
2374
  engine = _get_engine_or_503()
2375
+ from superlocalmemory.server.route_mutations import (
2376
+ authorize_route_mutation,
2377
+ )
2378
+ authorization = authorize_route_mutation(
2379
+ request,
2380
+ operation="update",
2381
+ source_agent_id="http-session-close",
2382
+ profile_id=engine.profile_id,
2383
+ )
2056
2384
  sid = req.session_id
2057
2385
  if not sid:
2058
2386
  # Fall back to the most recent session that has memories.
@@ -2073,8 +2401,11 @@ def _register_daemon_routes(application: FastAPI) -> None:
2073
2401
  "message": "no session to close"}
2074
2402
  try:
2075
2403
  created = engine.close_session(sid)
2404
+ authorization.complete()
2076
2405
  return {"ok": True, "session_id": sid,
2077
2406
  "summary_events_created": int(created)}
2407
+ except HTTPException:
2408
+ raise
2078
2409
  except Exception as exc:
2079
2410
  raise HTTPException(500, detail=str(exc))
2080
2411
 
@@ -2137,15 +2468,115 @@ _materializer_stop = threading.Event()
2137
2468
  _materializer_thread: threading.Thread | None = None
2138
2469
 
2139
2470
 
2140
- def _start_pending_materializer() -> None:
2141
- """Background thread: drains pending.db, yields to active /search calls.
2471
+ def _materializer_actor_id() -> str:
2472
+ """Return the process-owned actor identity used by background writes."""
2473
+ descriptor = _ACTIVE_DAEMON_DESCRIPTOR
2474
+ if descriptor is None:
2475
+ from superlocalmemory.server.routes.helpers import SLM_VERSION
2142
2476
 
2143
- Poll loop:
2144
- 1. Fetch up to 5 pending rows.
2145
- 2. For each row: if any /search is in flight, sleep 500ms (yield priority).
2146
- 3. Call engine.store(), mark_done or mark_failed.
2147
- 4. Sleep 2s between polls when idle (empty queue).
2148
- """
2477
+ descriptor = _process_descriptor(_DEFAULT_PORT, SLM_VERSION, "ready")
2478
+ return f"daemon-capability:{descriptor.capability_fingerprint}"
2479
+
2480
+
2481
+ def _materialize_ingestion_one_pass(
2482
+ engine,
2483
+ *,
2484
+ limit: int = 50,
2485
+ min_queryable_age_seconds: float = 1.0,
2486
+ ) -> tuple[int, int]:
2487
+ """Materialize durable M018 work once; return ``(complete, failed)``."""
2488
+ # The durable queue shares the embedder/LLM with foreground recall just
2489
+ # like the legacy pending queue. Yield before even constructing/claiming
2490
+ # work so an active user recall cannot suffer priority inversion.
2491
+ if _recalls_in_flight() > 0:
2492
+ return 0, 0
2493
+
2494
+ from superlocalmemory.core.engine_ingestion import build_engine_ingestion_command
2495
+ from superlocalmemory.core.ingestion_command import IngestionState
2496
+
2497
+ command = build_engine_ingestion_command(engine)
2498
+ completed = failed = 0
2499
+ for operation in command.repository.list_materializable(
2500
+ limit=limit,
2501
+ min_queryable_age_seconds=min_queryable_age_seconds,
2502
+ ):
2503
+ try:
2504
+ result = command.materialize(operation.operation_id)
2505
+ except Exception as exc:
2506
+ failed += 1
2507
+ logger.warning(
2508
+ "Ingestion operation %s could not be materialized: %s",
2509
+ operation.operation_id,
2510
+ exc,
2511
+ )
2512
+ continue
2513
+ if result.state is IngestionState.COMPLETE:
2514
+ completed += 1
2515
+ _emit_event(
2516
+ "memory.stored",
2517
+ payload={
2518
+ "operation_id": result.operation_id,
2519
+ "fact_ids": list(result.fact_ids),
2520
+ "path": "canonical_materializer",
2521
+ "content_preview": result.raw_content[:120],
2522
+ },
2523
+ source_agent="materializer",
2524
+ )
2525
+ else:
2526
+ failed += 1
2527
+ logger.warning(
2528
+ "Ingestion operation %s failed: %s",
2529
+ result.operation_id,
2530
+ result.last_error,
2531
+ )
2532
+ return completed, failed
2533
+
2534
+
2535
+ def _materialize_legacy_pending_item(engine, item: dict) -> str:
2536
+ """Backfill one pre-M018 pending.db row through canonical ingestion."""
2537
+ from superlocalmemory.core.engine_ingestion import build_engine_ingestion_command
2538
+ from superlocalmemory.core.ingestion_command import (
2539
+ IngestionRequest,
2540
+ IngestionState,
2541
+ )
2542
+
2543
+ metadata_value = item.get("metadata") or "{}"
2544
+ try:
2545
+ metadata = (
2546
+ json.loads(metadata_value)
2547
+ if isinstance(metadata_value, str)
2548
+ else dict(metadata_value)
2549
+ )
2550
+ except (TypeError, ValueError):
2551
+ metadata = {}
2552
+ if item.get("tags"):
2553
+ metadata.setdefault("tags", item["tags"])
2554
+ scope = metadata.pop("scope", None) or "personal"
2555
+ shared_with = tuple(metadata.pop("shared_with", None) or ())
2556
+ source_type = str(metadata.pop("_slm_source_type", "legacy-pending"))
2557
+ idempotency_key = str(
2558
+ metadata.pop("_slm_idempotency_key", f"pending:{item['id']}")
2559
+ )
2560
+ command = build_engine_ingestion_command(engine)
2561
+ receipt = command.submit(IngestionRequest(
2562
+ content=item["content"],
2563
+ profile_id=engine._profile_id,
2564
+ source_type=source_type,
2565
+ idempotency_key=idempotency_key,
2566
+ metadata=metadata,
2567
+ scope=scope,
2568
+ shared_with=shared_with,
2569
+ trusted_actor_id=_materializer_actor_id(),
2570
+ session_id=str(metadata.get("session_id") or ""),
2571
+ ))
2572
+ result = command.materialize(receipt.operation_id)
2573
+ if result.state is not IngestionState.COMPLETE:
2574
+ raise RuntimeError(result.last_error or "legacy pending materialization failed")
2575
+ return result.operation_id
2576
+
2577
+
2578
+ def _start_pending_materializer() -> None:
2579
+ """Drain M018 operations and backfill the legacy pending.db queue."""
2149
2580
  global _materializer_thread
2150
2581
 
2151
2582
  def _loop():
@@ -2172,11 +2603,20 @@ def _start_pending_materializer() -> None:
2172
2603
  if not _engine_logged:
2173
2604
  logger.info("Materializer: engine acquired, starting drain loop")
2174
2605
  _engine_logged = True
2606
+
2607
+ durable_complete, durable_failed = _materialize_ingestion_one_pass(
2608
+ engine,
2609
+ limit=50,
2610
+ )
2175
2611
  pending = get_pending(limit=50)
2176
- if not pending:
2612
+ if not pending and not durable_complete and not durable_failed:
2177
2613
  time.sleep(1.0)
2178
2614
  continue
2179
- logger.info("Materializer: processing %d pending memories", len(pending))
2615
+ if pending:
2616
+ logger.info(
2617
+ "Materializer: backfilling %d legacy pending memories",
2618
+ len(pending),
2619
+ )
2180
2620
  for item in pending:
2181
2621
  if _materializer_stop.is_set():
2182
2622
  break
@@ -2185,92 +2625,15 @@ def _start_pending_materializer() -> None:
2185
2625
  time.sleep(0.5)
2186
2626
  waits += 1
2187
2627
  try:
2188
- import hashlib
2189
- content = item["content"]
2190
- content_hash = hashlib.md5(content.encode()).hexdigest()
2191
- # v3.5.5: the write-through path already inserted a
2192
- # verbatim fact (recallable via BM25). If it lacks an
2193
- # embedding, ENRICH it in place (compute embedding +
2194
- # upsert vector store) rather than skipping — otherwise
2195
- # the fact would never be semantically searchable.
2196
- # v3.6.15: scope the dedup to THIS profile. Without the
2197
- # profile_id filter, a memory whose verbatim text matches
2198
- # another profile's fact was treated as a duplicate and
2199
- # silently dropped — cross-profile data loss + leakage.
2200
- dup = engine._db.execute(
2201
- "SELECT fact_id, embedding FROM atomic_facts "
2202
- "WHERE content = ? AND profile_id = ? LIMIT 1",
2203
- (content, engine._profile_id),
2204
- )
2205
- if dup:
2206
- try:
2207
- row = dict(dup[0])
2208
- if not row.get("embedding") and engine._embedder:
2209
- emb = engine._embedder.embed(content)
2210
- if emb:
2211
- upd = {"embedding": emb}
2212
- try:
2213
- fm, fv = engine._embedder.compute_fisher_params(emb)
2214
- upd["fisher_mean"] = fm
2215
- upd["fisher_variance"] = fv
2216
- except Exception:
2217
- pass
2218
- engine._db.update_fact(row["fact_id"], upd)
2219
- vs = getattr(engine, "_vector_store", None)
2220
- if vs and getattr(vs, "available", False):
2221
- vs.upsert(row["fact_id"], engine._profile_id, emb)
2222
- except Exception as eexc:
2223
- logger.debug("enrichment of write-through fact failed: %s", eexc)
2224
- mark_done(item["id"])
2225
- continue
2226
- import json as _json
2227
- md_str = item.get("metadata") or "{}"
2228
- try:
2229
- md = _json.loads(md_str)
2230
- except Exception:
2231
- md = {}
2232
- if item.get("tags"):
2233
- md.setdefault("tags", item["tags"])
2234
- # v3.6.15: replay the scope the async /remember path
2235
- # stashed in metadata, so a queued non-personal write
2236
- # materializes with the right visibility (not personal).
2237
- _mscope = md.get("scope") or "personal"
2238
- _mshared = md.get("shared_with")
2239
- _shared_json = _json.dumps(_mshared) if _mshared else None
2240
- # Create memory row (FK target for atomic_facts)
2241
- from datetime import datetime, timezone
2242
- from superlocalmemory.storage.models import (
2243
- AtomicFact, FactType,
2244
- )
2245
- mem_id = content_hash[:16]
2246
- engine._db.execute(
2247
- "INSERT OR IGNORE INTO memories "
2248
- "(memory_id, profile_id, content, "
2249
- "session_id, speaker, role, created_at, "
2250
- "metadata_json, scope, shared_with) "
2251
- "VALUES (?,?,?,?,?,?,?,?,?,?)",
2252
- (mem_id, engine._profile_id, content,
2253
- "", "", "user",
2254
- datetime.now(timezone.utc).isoformat(),
2255
- _json.dumps(md), _mscope, _shared_json),
2256
- )
2257
- fact = AtomicFact(
2258
- content=content,
2259
- fact_type=FactType.EPISODIC,
2260
- memory_id=mem_id,
2261
- profile_id=engine._profile_id,
2262
- scope=_mscope,
2263
- shared_with=_mshared,
2264
- )
2265
- engine.store_fact_direct(fact)
2628
+ operation_id = _materialize_legacy_pending_item(engine, item)
2266
2629
  mark_done(item["id"])
2267
2630
  _emit_event(
2268
2631
  "memory.stored",
2269
2632
  payload={
2270
2633
  "pending_id": item["id"],
2271
- "memory_id": mem_id,
2272
- "path": "materializer_drain",
2273
- "content_preview": content[:120],
2634
+ "operation_id": operation_id,
2635
+ "path": "legacy_pending_backfill",
2636
+ "content_preview": item["content"][:120],
2274
2637
  },
2275
2638
  source_agent="materializer",
2276
2639
  )
@@ -2293,8 +2656,42 @@ def _start_pending_materializer() -> None:
2293
2656
  def start_server(port: int = _DEFAULT_PORT) -> None:
2294
2657
  """Start the unified daemon. Blocks until stopped."""
2295
2658
  global _start_time
2659
+ assert_no_durable_root_conflict()
2660
+ import socket
2296
2661
  import uvicorn
2297
2662
 
2663
+ # Bind before any migration or engine work. A process which cannot own
2664
+ # the listener must not open the user's databases or publish lifecycle
2665
+ # state: otherwise a stale service and a manual restart can briefly run
2666
+ # two engines against one SQLite root.
2667
+ bind_host = (
2668
+ os.environ.get("SLM_DAEMON_HOST")
2669
+ or os.environ.get("SLM_HOST")
2670
+ or "127.0.0.1"
2671
+ )
2672
+ listener = socket.socket(socket.AF_INET, socket.SOCK_STREAM)
2673
+ # This handles a just-closed connection in TIME_WAIT. It is safe only
2674
+ # with the active-listener probe immediately below; without that guard,
2675
+ # macOS can permit a second SO_REUSEADDR listener on the same port.
2676
+ listener.setsockopt(socket.SOL_SOCKET, socket.SO_REUSEADDR, 1)
2677
+ try:
2678
+ with socket.socket(socket.AF_INET, socket.SOCK_STREAM) as probe:
2679
+ probe.settimeout(0.5)
2680
+ if probe.connect_ex(("127.0.0.1", port)) == 0:
2681
+ raise OSError("an active daemon listener already owns the port")
2682
+ listener.bind((bind_host, port))
2683
+ listener.listen(socket.SOMAXCONN)
2684
+ except OSError as exc:
2685
+ listener.close()
2686
+ logger.error(
2687
+ "SLM daemon will not start: %s:%d is already unavailable (%s)",
2688
+ bind_host, port, exc,
2689
+ )
2690
+ return
2691
+ # The lifespan uses the configured port for its identity and health
2692
+ # payload, so a CLI --port must be reflected there as well.
2693
+ os.environ["SLM_DAEMON_PORT"] = str(port)
2694
+
2298
2695
  # v3.4.23: rotate oversized logs before anything else so both the CLI
2299
2696
  # path (`slm serve`) and the LaunchAgent path (__main__) are covered.
2300
2697
  try:
@@ -2302,17 +2699,16 @@ def start_server(port: int = _DEFAULT_PORT) -> None:
2302
2699
  except Exception:
2303
2700
  pass # never block startup on log housekeeping
2304
2701
 
2305
- _PID_FILE.parent.mkdir(parents=True, exist_ok=True)
2306
- _PID_FILE.write_text(str(os.getpid()))
2307
- _PORT_FILE.write_text(str(port))
2702
+ from superlocalmemory.server.routes.helpers import SLM_VERSION
2703
+
2704
+ _publish_process_descriptor(port, SLM_VERSION, "starting")
2308
2705
  _start_time = time.monotonic()
2309
2706
 
2310
2707
  try:
2311
2708
  from superlocalmemory.migrations.v3_4_25_to_v3_4_26 import (
2312
2709
  is_ready as _is_ready, migrate as _migrate,
2313
2710
  )
2314
- _data = Path(os.environ.get("SLM_DATA_DIR")
2315
- or Path.home() / ".superlocalmemory")
2711
+ _data = canonical_data_root()
2316
2712
  if not _is_ready(_data):
2317
2713
  _migrate(_data)
2318
2714
  except Exception as exc:
@@ -2327,18 +2723,9 @@ def start_server(port: int = _DEFAULT_PORT) -> None:
2327
2723
  # v3.4.32: Continuous pending-queue materializer with recall priority.
2328
2724
  _start_pending_materializer()
2329
2725
 
2330
- log_dir = Path.home() / ".superlocalmemory" / "logs"
2726
+ log_dir = state_path("logs")
2331
2727
  log_dir.mkdir(parents=True, exist_ok=True)
2332
2728
 
2333
- # Bind address. `SLM_DAEMON_HOST` is the canonical name; `SLM_HOST` is
2334
- # accepted as a shorter alias (issue #23). Set either to 0.0.0.0 to serve
2335
- # a shared instance over a trusted private network (e.g. WireGuard mesh).
2336
- bind_host = (
2337
- os.environ.get("SLM_DAEMON_HOST")
2338
- or os.environ.get("SLM_HOST")
2339
- or "127.0.0.1"
2340
- )
2341
-
2342
2729
  config = uvicorn.Config(
2343
2730
  app="superlocalmemory.server.unified_daemon:create_app",
2344
2731
  factory=True,
@@ -2350,10 +2737,10 @@ def start_server(port: int = _DEFAULT_PORT) -> None:
2350
2737
  server = uvicorn.Server(config)
2351
2738
 
2352
2739
  try:
2353
- server.run()
2740
+ server.run(sockets=[listener])
2354
2741
  finally:
2355
- _PID_FILE.unlink(missing_ok=True)
2356
- _PORT_FILE.unlink(missing_ok=True)
2742
+ listener.close()
2743
+ _cleanup_process_descriptor(_ACTIVE_DAEMON_DESCRIPTOR)
2357
2744
 
2358
2745
 
2359
2746
  # ---------------------------------------------------------------------------
@@ -2383,7 +2770,7 @@ def rotate_oversized_logs(log_dir: Optional[Path] = None,
2383
2770
  Keeps one rotated copy (.1). Safe under concurrent start attempts:
2384
2771
  rename is atomic on POSIX, and truncation is idempotent.
2385
2772
  """
2386
- log_dir = log_dir or (Path.home() / ".superlocalmemory" / "logs")
2773
+ log_dir = log_dir or state_path("logs")
2387
2774
  try:
2388
2775
  log_dir.mkdir(parents=True, exist_ok=True)
2389
2776
  except Exception: