superlocalmemory 3.6.23 → 3.7.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 (285) hide show
  1. package/CHANGELOG.md +61 -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 +17 -9
  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/agents/slm-memory-advisor.md +1 -1
  18. package/plugin-src/manifest.json +1 -1
  19. package/plugin-src/requirements.txt +1 -1
  20. package/plugin-src/rules/AGENTS.md +1 -1
  21. package/plugin-src/skills/slm-session/SKILL.md +1 -1
  22. package/pyproject.toml +40 -8
  23. package/scripts/postinstall-interactive.js +17 -94
  24. package/scripts/postinstall.js +185 -258
  25. package/scripts/preuninstall.js +9 -50
  26. package/src/superlocalmemory/__init__.py +2 -2
  27. package/src/superlocalmemory/attribution/mathematical_dna.py +1 -1
  28. package/src/superlocalmemory/attribution/signer.py +34 -19
  29. package/src/superlocalmemory/attribution/watermark.py +1 -1
  30. package/src/superlocalmemory/cli/_lazy_init.py +7 -7
  31. package/src/superlocalmemory/cli/commands.py +453 -191
  32. package/src/superlocalmemory/cli/context_commands.py +5 -4
  33. package/src/superlocalmemory/cli/daemon.py +282 -187
  34. package/src/superlocalmemory/cli/db_migrate.py +3 -1
  35. package/src/superlocalmemory/cli/diagnostics_cmd.py +28 -0
  36. package/src/superlocalmemory/cli/evidence_cmd.py +103 -0
  37. package/src/superlocalmemory/cli/ingest_cmd.py +7 -3
  38. package/src/superlocalmemory/cli/main.py +128 -31
  39. package/src/superlocalmemory/cli/pending_store.py +54 -38
  40. package/src/superlocalmemory/cli/scale_engine_cmd.py +37 -0
  41. package/src/superlocalmemory/cli/service_installer.py +57 -52
  42. package/src/superlocalmemory/cli/setup_wizard.py +142 -88
  43. package/src/superlocalmemory/cli/version_banner.py +2 -1
  44. package/src/superlocalmemory/code_graph/config.py +3 -1
  45. package/src/superlocalmemory/core/backend_orchestrator.py +81 -21
  46. package/src/superlocalmemory/core/config.py +65 -20
  47. package/src/superlocalmemory/core/consolidation_engine.py +9 -7
  48. package/src/superlocalmemory/core/context_cache.py +56 -8
  49. package/src/superlocalmemory/core/derivation_lineage.py +246 -0
  50. package/src/superlocalmemory/core/embedding_worker.py +32 -20
  51. package/src/superlocalmemory/core/embeddings.py +54 -18
  52. package/src/superlocalmemory/core/engine.py +150 -104
  53. package/src/superlocalmemory/core/engine_ingestion.py +513 -0
  54. package/src/superlocalmemory/core/engine_wiring.py +32 -23
  55. package/src/superlocalmemory/core/evidence_bundle.py +526 -0
  56. package/src/superlocalmemory/core/fact_consolidator.py +5 -11
  57. package/src/superlocalmemory/core/graph_analyzer.py +2 -2
  58. package/src/superlocalmemory/core/health_monitor.py +4 -2
  59. package/src/superlocalmemory/core/ingestion_command.py +636 -0
  60. package/src/superlocalmemory/core/injection.py +69 -18
  61. package/src/superlocalmemory/core/lifecycle_state.py +153 -0
  62. package/src/superlocalmemory/core/maintenance.py +1 -1
  63. package/src/superlocalmemory/core/maintenance_scheduler.py +51 -35
  64. package/src/superlocalmemory/core/mutations.py +143 -0
  65. package/src/superlocalmemory/core/platform_utils.py +7 -4
  66. package/src/superlocalmemory/core/ram_lock.py +16 -5
  67. package/src/superlocalmemory/core/rate_limit.py +1 -1
  68. package/src/superlocalmemory/core/recall_pipeline.py +60 -101
  69. package/src/superlocalmemory/core/recall_worker.py +76 -59
  70. package/src/superlocalmemory/core/registry.py +1 -1
  71. package/src/superlocalmemory/core/scale_engine.py +293 -0
  72. package/src/superlocalmemory/core/score_contract.py +62 -0
  73. package/src/superlocalmemory/core/security_primitives.py +3 -1
  74. package/src/superlocalmemory/core/slm_disabled.py +3 -5
  75. package/src/superlocalmemory/core/store_pipeline.py +172 -40
  76. package/src/superlocalmemory/core/tier_manager.py +32 -20
  77. package/src/superlocalmemory/core/worker_pool.py +13 -4
  78. package/src/superlocalmemory/dynamics/activation_guided_quantization.py +1 -1
  79. package/src/superlocalmemory/dynamics/eap_scheduler.py +10 -3
  80. package/src/superlocalmemory/dynamics/ebbinghaus_langevin_coupling.py +1 -1
  81. package/src/superlocalmemory/dynamics/fisher_langevin_coupling.py +1 -1
  82. package/src/superlocalmemory/encoding/auto_linker.py +1 -1
  83. package/src/superlocalmemory/encoding/cognitive_consolidator.py +7 -16
  84. package/src/superlocalmemory/encoding/consolidator.py +22 -5
  85. package/src/superlocalmemory/encoding/fact_extractor.py +1 -1
  86. package/src/superlocalmemory/encoding/foresight.py +2 -0
  87. package/src/superlocalmemory/encoding/graph_builder.py +1 -1
  88. package/src/superlocalmemory/encoding/temporal_parser.py +2 -0
  89. package/src/superlocalmemory/evaluation/__init__.py +13 -0
  90. package/src/superlocalmemory/evaluation/calibration.py +308 -0
  91. package/src/superlocalmemory/evolution/skill_evolver.py +2 -1
  92. package/src/superlocalmemory/graph/cozo_backend.py +256 -23
  93. package/src/superlocalmemory/hooks/_outcome_common.py +21 -11
  94. package/src/superlocalmemory/hooks/antigravity_adapter.py +10 -31
  95. package/src/superlocalmemory/hooks/auto_invoker.py +25 -27
  96. package/src/superlocalmemory/hooks/auto_recall.py +31 -6
  97. package/src/superlocalmemory/hooks/auto_recall_hook.py +13 -33
  98. package/src/superlocalmemory/hooks/before_web_hook.py +9 -7
  99. package/src/superlocalmemory/hooks/claude_code_hooks.py +123 -35
  100. package/src/superlocalmemory/hooks/codex_assets.py +59 -0
  101. package/src/superlocalmemory/hooks/codex_hooks.py +186 -0
  102. package/src/superlocalmemory/hooks/context_payload.py +1 -1
  103. package/src/superlocalmemory/hooks/copilot_adapter.py +9 -24
  104. package/src/superlocalmemory/hooks/cursor_adapter.py +10 -32
  105. package/src/superlocalmemory/hooks/hook_daemon.py +4 -2
  106. package/src/superlocalmemory/hooks/hook_handlers.py +219 -32
  107. package/src/superlocalmemory/hooks/memory_protocol.py +5 -3
  108. package/src/superlocalmemory/hooks/post_tool_async_hook.py +4 -2
  109. package/src/superlocalmemory/hooks/session_registry.py +15 -8
  110. package/src/superlocalmemory/hooks/stop_outcome_hook.py +10 -6
  111. package/src/superlocalmemory/hooks/topic_shift_hook.py +42 -12
  112. package/src/superlocalmemory/hooks/user_prompt_hook.py +9 -14
  113. package/src/superlocalmemory/hooks/user_prompt_rehash_hook.py +19 -11
  114. package/src/superlocalmemory/infra/auth_middleware.py +38 -5
  115. package/src/superlocalmemory/infra/backup.py +7 -5
  116. package/src/superlocalmemory/infra/cloud_backup.py +18 -8
  117. package/src/superlocalmemory/infra/daemon_identity.py +248 -0
  118. package/src/superlocalmemory/infra/data_root.py +199 -0
  119. package/src/superlocalmemory/infra/event_bus.py +3 -1
  120. package/src/superlocalmemory/infra/local_diagnostics.py +327 -0
  121. package/src/superlocalmemory/infra/process_reaper.py +23 -0
  122. package/src/superlocalmemory/ingestion/adapter_manager.py +27 -9
  123. package/src/superlocalmemory/ingestion/base_adapter.py +25 -31
  124. package/src/superlocalmemory/ingestion/calendar_adapter.py +13 -4
  125. package/src/superlocalmemory/ingestion/credentials.py +14 -7
  126. package/src/superlocalmemory/ingestion/gmail_adapter.py +13 -4
  127. package/src/superlocalmemory/ingestion/transcript_adapter.py +7 -2
  128. package/src/superlocalmemory/learning/consolidation_quantization_worker.py +1 -1
  129. package/src/superlocalmemory/learning/ensemble.py +11 -0
  130. package/src/superlocalmemory/learning/entity_compiler.py +1 -1
  131. package/src/superlocalmemory/learning/feedback.py +1 -1
  132. package/src/superlocalmemory/learning/forgetting_scheduler.py +12 -7
  133. package/src/superlocalmemory/learning/quantization_scheduler.py +1 -1
  134. package/src/superlocalmemory/learning/ranker.py +4 -1
  135. package/src/superlocalmemory/learning/source_quality.py +1 -1
  136. package/src/superlocalmemory/learning/trigram_index.py +3 -2
  137. package/src/superlocalmemory/llm/backbone.py +1 -1
  138. package/src/superlocalmemory/math/ebbinghaus.py +1 -1
  139. package/src/superlocalmemory/math/fisher.py +1 -1
  140. package/src/superlocalmemory/math/fisher_quantized.py +1 -1
  141. package/src/superlocalmemory/math/hopfield.py +1 -1
  142. package/src/superlocalmemory/math/langevin.py +1 -1
  143. package/src/superlocalmemory/math/polar_quant.py +3 -4
  144. package/src/superlocalmemory/math/qjl.py +1 -1
  145. package/src/superlocalmemory/math/sheaf.py +1 -1
  146. package/src/superlocalmemory/math/turbo_quant.py +3 -2
  147. package/src/superlocalmemory/mcp/_daemon_proxy.py +12 -11
  148. package/src/superlocalmemory/mcp/_pool_adapter.py +27 -0
  149. package/src/superlocalmemory/mcp/http_transport.py +53 -0
  150. package/src/superlocalmemory/mcp/server.py +39 -13
  151. package/src/superlocalmemory/mcp/shared.py +69 -3
  152. package/src/superlocalmemory/mcp/tools_active.py +141 -31
  153. package/src/superlocalmemory/mcp/tools_core.py +130 -31
  154. package/src/superlocalmemory/mcp/tools_evolution.py +5 -7
  155. package/src/superlocalmemory/mcp/tools_learning.py +42 -2
  156. package/src/superlocalmemory/mcp/tools_mesh.py +7 -23
  157. package/src/superlocalmemory/mcp/tools_optimize.py +8 -1
  158. package/src/superlocalmemory/mcp/tools_v28.py +23 -2
  159. package/src/superlocalmemory/mcp/tools_v3.py +26 -1
  160. package/src/superlocalmemory/mcp/tools_v33.py +56 -17
  161. package/src/superlocalmemory/mesh/broker.py +2 -0
  162. package/src/superlocalmemory/mesh/remote_sync.py +50 -12
  163. package/src/superlocalmemory/optimize/cache/manager.py +77 -1
  164. package/src/superlocalmemory/optimize/cache/semantic.py +23 -3
  165. package/src/superlocalmemory/optimize/compress/ccr.py +4 -0
  166. package/src/superlocalmemory/optimize/compress/router.py +6 -1
  167. package/src/superlocalmemory/optimize/config/__init__.py +5 -0
  168. package/src/superlocalmemory/optimize/config/store.py +6 -4
  169. package/src/superlocalmemory/optimize/proxy/_helpers.py +15 -5
  170. package/src/superlocalmemory/optimize/proxy/capture.py +3 -2
  171. package/src/superlocalmemory/optimize/proxy/server.py +2 -2
  172. package/src/superlocalmemory/optimize/storage/db.py +12 -12
  173. package/src/superlocalmemory/retrieval/agentic.py +1 -1
  174. package/src/superlocalmemory/retrieval/ann_index.py +1 -1
  175. package/src/superlocalmemory/retrieval/bm25_channel.py +35 -11
  176. package/src/superlocalmemory/retrieval/bridge_discovery.py +73 -8
  177. package/src/superlocalmemory/retrieval/engine.py +169 -79
  178. package/src/superlocalmemory/retrieval/entity_channel.py +289 -67
  179. package/src/superlocalmemory/retrieval/forgetting_filter.py +1 -1
  180. package/src/superlocalmemory/retrieval/fusion.py +1 -1
  181. package/src/superlocalmemory/retrieval/hopfield_channel.py +118 -30
  182. package/src/superlocalmemory/retrieval/profile_channel.py +1 -1
  183. package/src/superlocalmemory/retrieval/quantization_aware_search.py +16 -10
  184. package/src/superlocalmemory/retrieval/reranker.py +56 -20
  185. package/src/superlocalmemory/retrieval/scope_policy.py +85 -0
  186. package/src/superlocalmemory/retrieval/semantic_channel.py +122 -14
  187. package/src/superlocalmemory/retrieval/spreading_activation.py +141 -25
  188. package/src/superlocalmemory/retrieval/strategy.py +1 -1
  189. package/src/superlocalmemory/retrieval/temporal_channel.py +30 -15
  190. package/src/superlocalmemory/retrieval/vector_store.py +1 -1
  191. package/src/superlocalmemory/server/api.py +10 -7
  192. package/src/superlocalmemory/server/bandit_loops.py +4 -2
  193. package/src/superlocalmemory/server/recall_serializer.py +24 -0
  194. package/src/superlocalmemory/server/route_mutations.py +84 -0
  195. package/src/superlocalmemory/server/routes/agents.py +8 -6
  196. package/src/superlocalmemory/server/routes/brain.py +14 -12
  197. package/src/superlocalmemory/server/routes/chat.py +29 -12
  198. package/src/superlocalmemory/server/routes/data_io.py +55 -24
  199. package/src/superlocalmemory/server/routes/helpers.py +8 -63
  200. package/src/superlocalmemory/server/routes/ingest.py +53 -36
  201. package/src/superlocalmemory/server/routes/memories.py +104 -43
  202. package/src/superlocalmemory/server/routes/mesh.py +31 -0
  203. package/src/superlocalmemory/server/routes/profiles.py +26 -4
  204. package/src/superlocalmemory/server/routes/tiers.py +43 -11
  205. package/src/superlocalmemory/server/routes/timeline.py +5 -1
  206. package/src/superlocalmemory/server/routes/v3_api.py +76 -21
  207. package/src/superlocalmemory/server/security_middleware.py +1 -1
  208. package/src/superlocalmemory/server/unified_daemon.py +680 -293
  209. package/src/superlocalmemory/server/write_identity.py +147 -0
  210. package/src/superlocalmemory/storage/access_log.py +4 -3
  211. package/src/superlocalmemory/storage/database.py +118 -25
  212. package/src/superlocalmemory/storage/migration_runner.py +84 -1
  213. package/src/superlocalmemory/storage/migration_v33.py +1 -1
  214. package/src/superlocalmemory/storage/migrations/M002_model_state_history.py +6 -60
  215. package/src/superlocalmemory/storage/migrations/M018_ingestion_operations.py +120 -0
  216. package/src/superlocalmemory/storage/migrations/M019_derivation_lineage.py +54 -0
  217. package/src/superlocalmemory/storage/migrations/M020_model_state_integrity.py +52 -0
  218. package/src/superlocalmemory/storage/migrations/__init__.py +5 -0
  219. package/src/superlocalmemory/storage/models.py +16 -0
  220. package/src/superlocalmemory/storage/quantized_store.py +20 -3
  221. package/src/superlocalmemory/storage/v2_migrator.py +5 -3
  222. package/src/superlocalmemory/ui/favicon.svg +5 -0
  223. package/src/superlocalmemory/ui/index.html +1 -0
  224. package/src/superlocalmemory/ui/js/compliance.js +1 -1
  225. package/src/superlocalmemory/ui/js/core.js +49 -8
  226. package/src/superlocalmemory/ui/js/dashboard.js +23 -2
  227. package/src/superlocalmemory/ui/js/feedback.js +1 -1
  228. package/src/superlocalmemory/ui/js/graph-filters.js +1 -1
  229. package/src/superlocalmemory/ui/js/graph-ui.js +1 -1
  230. package/src/superlocalmemory/ui/js/lifecycle.js +1 -1
  231. package/src/superlocalmemory/ui/js/ng-mesh.js +15 -49
  232. package/src/superlocalmemory/ui/js/settings.js +4 -2
  233. package/src/superlocalmemory/vector/lancedb_backend.py +57 -9
  234. package/bin/slm +0 -59
  235. package/bin/slm.bat +0 -77
  236. package/bin/slm.cmd +0 -5
  237. package/ide/integrations/langchain/README.md +0 -106
  238. package/ide/integrations/langchain/langchain_superlocalmemory/__init__.py +0 -9
  239. package/ide/integrations/langchain/langchain_superlocalmemory/chat_message_history.py +0 -201
  240. package/ide/integrations/langchain/pyproject.toml +0 -38
  241. package/ide/integrations/langchain/tests/__init__.py +0 -3
  242. package/ide/integrations/langchain/tests/test_chat_message_history.py +0 -215
  243. package/ide/integrations/langchain/tests/test_security.py +0 -117
  244. package/ide/integrations/llamaindex/README.md +0 -81
  245. package/ide/integrations/llamaindex/llama_index/storage/chat_store/superlocalmemory/__init__.py +0 -9
  246. package/ide/integrations/llamaindex/llama_index/storage/chat_store/superlocalmemory/base.py +0 -316
  247. package/ide/integrations/llamaindex/pyproject.toml +0 -43
  248. package/ide/integrations/llamaindex/tests/__init__.py +0 -3
  249. package/ide/integrations/llamaindex/tests/test_chat_store.py +0 -294
  250. package/ide/integrations/llamaindex/tests/test_security.py +0 -241
  251. package/scripts/__tests__/build-plugin.test.mjs +0 -613
  252. package/scripts/_savings_math.py +0 -270
  253. package/scripts/build-dmg.sh +0 -417
  254. package/scripts/build-plugin.js +0 -742
  255. package/scripts/build-slm-hook.ps1 +0 -40
  256. package/scripts/build-slm-hook.sh +0 -45
  257. package/scripts/build_entry.py +0 -452
  258. package/scripts/ci/stage5b_gate.sh +0 -50
  259. package/scripts/dogfood_savings.py +0 -490
  260. package/scripts/generate-thumbnails.py +0 -218
  261. package/scripts/install-skills.ps1 +0 -4
  262. package/scripts/install-skills.sh +0 -5
  263. package/scripts/install.ps1 +0 -701
  264. package/scripts/install.sh +0 -1015
  265. package/scripts/postinstall_binary.js +0 -287
  266. package/scripts/prepack.js +0 -33
  267. package/scripts/release_manifest.py +0 -273
  268. package/scripts/slm-hook.spec +0 -56
  269. package/scripts/start-dashboard.ps1 +0 -52
  270. package/scripts/start-dashboard.sh +0 -41
  271. package/scripts/sync-wiki.ps1 +0 -127
  272. package/scripts/sync-wiki.sh +0 -82
  273. package/scripts/test-dmg.sh +0 -161
  274. package/scripts/test-npm-package.ps1 +0 -252
  275. package/scripts/test-npm-package.sh +0 -207
  276. package/scripts/verify-install.ps1 +0 -294
  277. package/scripts/verify-install.sh +0 -266
  278. package/scripts/verify-v27.ps1 +0 -301
  279. package/scripts/verify-v27.sh +0 -233
  280. package/src/superlocalmemory.egg-info/PKG-INFO +0 -516
  281. package/src/superlocalmemory.egg-info/SOURCES.txt +0 -529
  282. package/src/superlocalmemory.egg-info/dependency_links.txt +0 -1
  283. package/src/superlocalmemory.egg-info/entry_points.txt +0 -2
  284. package/src/superlocalmemory.egg-info/requires.txt +0 -71
  285. package/src/superlocalmemory.egg-info/top_level.txt +0 -1
@@ -13,7 +13,6 @@ import json as _json
13
13
  import os
14
14
  from argparse import Namespace
15
15
  from pathlib import Path
16
- from typing import Callable
17
16
 
18
17
  from superlocalmemory.hooks.antigravity_adapter import AntigravityAdapter
19
18
  from superlocalmemory.hooks.copilot_adapter import CopilotAdapter
@@ -53,9 +52,11 @@ def _get_recall_fn() -> RecallFn:
53
52
 
54
53
 
55
54
  def _default_sync_log_db() -> Path:
56
- return Path(os.environ.get("SLM_MEMORY_DB",
57
- str(Path.home() / ".superlocalmemory"
58
- / "memory.db")))
55
+ explicit = os.environ.get("SLM_MEMORY_DB", "").strip()
56
+ if explicit:
57
+ return Path(explicit)
58
+ from superlocalmemory.infra.data_root import state_path
59
+ return state_path("memory.db")
59
60
 
60
61
 
61
62
  def build_default_adapters(
@@ -24,13 +24,23 @@ from __future__ import annotations
24
24
  import json
25
25
  import logging
26
26
  import os
27
- import signal
27
+ import socket
28
28
  import sys
29
29
  import time
30
- from http.server import HTTPServer, BaseHTTPRequestHandler
31
- from pathlib import Path
32
- import threading
33
- from threading import Thread
30
+ from dataclasses import replace
31
+
32
+ from superlocalmemory.infra.daemon_identity import (
33
+ build_descriptor,
34
+ descriptor_matches_health,
35
+ descriptor_path,
36
+ process_create_time_for,
37
+ read_descriptor,
38
+ write_descriptor,
39
+ )
40
+ from superlocalmemory.infra.data_root import (
41
+ assert_no_durable_root_conflict,
42
+ state_path,
43
+ )
34
44
 
35
45
  logger = logging.getLogger(__name__)
36
46
 
@@ -40,8 +50,8 @@ except ValueError:
40
50
  _DEFAULT_PORT = 8765
41
51
  _LEGACY_PORT = 8767 # backward-compat redirect
42
52
  _DEFAULT_IDLE_TIMEOUT = 0 # v3.4.3: 24/7 default (was 1800)
43
- _PID_FILE = Path.home() / ".superlocalmemory" / "daemon.pid"
44
- _PORT_FILE = Path.home() / ".superlocalmemory" / "daemon.port"
53
+ _PID_FILE = None # test-only override; runtime resolution stays dynamic
54
+ _PORT_FILE = None # test-only override; runtime resolution stays dynamic
45
55
 
46
56
 
47
57
  # ---------------------------------------------------------------------------
@@ -61,83 +71,222 @@ def _is_pid_alive(pid: int) -> bool:
61
71
  return False
62
72
 
63
73
 
64
- def is_daemon_running() -> bool:
65
- """Check if daemon is alive via PID file + HTTP health check.
74
+ def _descriptor_process_is_alive(descriptor) -> bool:
75
+ """Reject stale descriptors when a PID has been reused by another process."""
76
+ if not _is_pid_alive(descriptor.pid):
77
+ return False
78
+ try:
79
+ import psutil
80
+
81
+ process = psutil.Process(descriptor.pid)
82
+ # A terminated daemon can remain in the process table briefly as a
83
+ # zombie while its parent reaps it. PID existence is therefore not
84
+ # liveness and must not block a namespace-owned restart.
85
+ if not process.is_running() or process.status() == psutil.STATUS_ZOMBIE:
86
+ return False
87
+ actual = float(process.create_time())
88
+ except ImportError:
89
+ return True
90
+ except Exception:
91
+ return False
92
+ return abs(actual - float(descriptor.process_create_time)) <= 1.0
93
+
94
+
95
+ def _is_port_available(port: int) -> bool:
96
+ """Return whether the daemon port can be exclusively bound right now."""
97
+ try:
98
+ with socket.socket(socket.AF_INET, socket.SOCK_STREAM) as candidate:
99
+ # The server binds with address reuse. Mirror that contract here:
100
+ # a recently closed listener may leave TCP connections in
101
+ # TIME_WAIT, which must not be mistaken for an active owner and
102
+ # block a safe restart for the full TCP timeout.
103
+ candidate.setsockopt(socket.SOL_SOCKET, socket.SO_REUSEADDR, 1)
104
+ candidate.bind(("127.0.0.1", port))
105
+ return True
106
+ except OSError:
107
+ return False
108
+
109
+
110
+ def _has_tcp_listener(port: int) -> bool:
111
+ """Return whether a process is actively accepting on the daemon port."""
112
+ try:
113
+ with socket.socket(socket.AF_INET, socket.SOCK_STREAM) as candidate:
114
+ candidate.settimeout(0.5)
115
+ return candidate.connect_ex(("127.0.0.1", port)) == 0
116
+ except OSError:
117
+ return False
118
+
66
119
 
67
- v3.4.4 FIX: If PID is alive, returns True EVEN IF health check fails.
68
- This prevents starting duplicate daemons when the existing one is
69
- warming up (Ollama processing, model download, embedding init).
120
+ def wait_for_owned_daemon_shutdown(descriptor, timeout: float = 25.0) -> bool:
121
+ """Wait for the stopped instance *and* its TCP listener to be gone.
70
122
 
71
- Priority:
72
- 1. PID file exists AND process alive True (daemon warming up or ready)
73
- 2. No PID file try health check on known ports (MCP/hook started daemon)
74
- 3. PID file stale (process dead) → clean up, return False
123
+ Restart must never spawn a replacement just because the descriptor was
124
+ removed: a graceful shutdown can remove it before Uvicorn releases the
125
+ port. The 25-second budget covers Uvicorn's 10-second graceful drain plus
126
+ SLM worker cleanup. A descriptor carries process creation time, so PID
127
+ reuse cannot make this wait target an unrelated process.
75
128
  """
76
- if _PID_FILE.exists():
77
- try:
78
- pid = int(_PID_FILE.read_text().strip())
79
- if _is_pid_alive(pid):
80
- # PID alive = daemon exists. Don't check health — it might be warming up.
81
- # This is the critical fix: NEVER start a second daemon if PID is alive.
82
- return True
83
- else:
84
- # Process died — clean up stale PID file
85
- _PID_FILE.unlink(missing_ok=True)
86
- _PORT_FILE.unlink(missing_ok=True)
87
- except (ValueError, OSError):
88
- _PID_FILE.unlink(missing_ok=True)
89
-
90
- # No PID file — maybe daemon was started by MCP/hook without PID file.
91
- # Try health check on known ports as last resort.
92
- for try_port in (_DEFAULT_PORT, _LEGACY_PORT):
93
- try:
94
- import urllib.request
95
- resp = urllib.request.urlopen(
96
- f"http://127.0.0.1:{try_port}/health", timeout=2,
97
- )
98
- if resp.status == 200:
99
- # Daemon running without PID file — write one for future checks
100
- try:
101
- import json as _json
102
- data = _json.loads(resp.read().decode())
103
- pid = data.get("pid")
104
- if pid:
105
- _PID_FILE.parent.mkdir(parents=True, exist_ok=True)
106
- _PID_FILE.write_text(str(pid))
107
- _PORT_FILE.write_text(str(try_port))
108
- except Exception:
109
- pass
110
- return True
111
- except Exception:
112
- continue
129
+ port = descriptor.port if descriptor is not None else _DEFAULT_PORT
130
+ deadline = time.monotonic() + timeout
131
+ while time.monotonic() < deadline:
132
+ process_alive = (
133
+ descriptor is not None and _descriptor_process_is_alive(descriptor)
134
+ )
135
+ if not process_alive and _is_port_available(port):
136
+ return True
137
+ time.sleep(0.1)
113
138
  return False
114
139
 
115
140
 
141
+ def is_daemon_running() -> bool:
142
+ """Return True only for a daemon owned by this canonical data namespace.
143
+
144
+ A PID or an HTTP 200 proves liveness, not ownership. V3.7 requires the
145
+ private local descriptor and the health endpoint to agree on namespace,
146
+ process instance, capability fingerprint, owner, PID, protocol, and port.
147
+ """
148
+ local_descriptor_path = descriptor_path()
149
+ descriptor = read_descriptor()
150
+ if descriptor is not None:
151
+ if not _descriptor_process_is_alive(descriptor):
152
+ return False
153
+ if descriptor.state == "starting":
154
+ return True
155
+ health = _fetch_health(descriptor.port)
156
+ return health is not None and descriptor_matches_health(descriptor, health)
157
+
158
+ # A malformed or foreign descriptor must fail closed; never fall through
159
+ # to legacy PID/port adoption in the same namespace.
160
+ if local_descriptor_path.exists():
161
+ return False
162
+
163
+ legacy = _verified_legacy_health()
164
+ return legacy is not None
165
+
166
+
167
+ def _fetch_health(port: int) -> dict | None:
168
+ """Fetch loopback health without following cross-namespace discovery."""
169
+ try:
170
+ import urllib.request
171
+
172
+ expected_url = f"http://127.0.0.1:{port}/health"
173
+ response = urllib.request.urlopen(
174
+ expected_url, timeout=2,
175
+ )
176
+ if response.status != 200:
177
+ return None
178
+ geturl = getattr(response, "geturl", None)
179
+ final_url = geturl() if callable(geturl) else None
180
+ if final_url is not None and final_url != expected_url:
181
+ return None
182
+ payload = json.loads(response.read().decode())
183
+ return payload if isinstance(payload, dict) else None
184
+ except Exception:
185
+ return None
186
+
187
+
188
+ def _is_verified_legacy_process(pid: int) -> bool:
189
+ """One-release bridge for a same-root V3.6 unified-daemon process."""
190
+ if not _is_pid_alive(pid):
191
+ return False
192
+ try:
193
+ import psutil
194
+
195
+ process = psutil.Process(pid)
196
+ command = " ".join(process.cmdline())
197
+ return "superlocalmemory.server.unified_daemon" in command
198
+ except Exception:
199
+ return False
200
+
201
+
202
+ def _verified_legacy_health() -> dict | None:
203
+ """Accept legacy health only with a verified same-root daemon PID file."""
204
+ pid_file = descriptor_path().with_name("daemon.pid")
205
+ port_file = descriptor_path().with_name("daemon.port")
206
+ try:
207
+ pid = int(pid_file.read_text().strip())
208
+ port = int(port_file.read_text().strip()) if port_file.exists() else _DEFAULT_PORT
209
+ except (OSError, ValueError):
210
+ return None
211
+ if not _is_verified_legacy_process(pid):
212
+ return None
213
+ health = _fetch_health(port)
214
+ if health is None or int(health.get("pid", -1)) != pid:
215
+ return None
216
+ # Identity-bearing health without a descriptor is not legacy and cannot
217
+ # be adopted. It belongs to another namespace or stale state.
218
+ if health.get("daemon_protocol") is not None:
219
+ return None
220
+ return {**health, "_legacy_port": port}
221
+
222
+
116
223
  def _get_port() -> int:
117
- if _PORT_FILE.exists():
118
- try:
119
- return int(_PORT_FILE.read_text().strip())
120
- except ValueError:
121
- pass
224
+ descriptor = read_descriptor()
225
+ if descriptor is not None:
226
+ return descriptor.port
227
+ if descriptor_path().exists():
228
+ return _DEFAULT_PORT
229
+ legacy = _verified_legacy_health()
230
+ if legacy is not None:
231
+ return int(legacy["_legacy_port"])
122
232
  return _DEFAULT_PORT
123
233
 
124
234
 
125
- def daemon_request(method: str, path: str, body: dict | None = None) -> dict | None:
126
- """Send request to daemon. Returns parsed JSON or None on failure."""
127
- port = _get_port()
235
+ def daemon_request(
236
+ method: str,
237
+ path: str,
238
+ body: dict | None = None,
239
+ *,
240
+ timeout_seconds: float = 30.0,
241
+ ) -> dict | None:
242
+ """Send a request only after validating the owned daemon identity."""
243
+ descriptor = read_descriptor()
244
+ capability: str | None = None
245
+ if descriptor is not None:
246
+ health = _fetch_health(descriptor.port)
247
+ if health is None or not descriptor_matches_health(descriptor, health):
248
+ return None
249
+ if method.upper() == "GET" and path == "/health":
250
+ return health
251
+ port = descriptor.port
252
+ capability = descriptor.capability
253
+ elif descriptor_path().exists():
254
+ return None
255
+ else:
256
+ legacy = _verified_legacy_health()
257
+ if legacy is None:
258
+ return None
259
+ if method.upper() == "GET" and path == "/health":
260
+ return {key: value for key, value in legacy.items() if key != "_legacy_port"}
261
+ port = int(legacy["_legacy_port"])
128
262
  try:
129
263
  import urllib.request
130
264
  url = f"http://127.0.0.1:{port}{path}"
131
265
  data = json.dumps(body).encode() if body else None
132
266
  headers = {"Content-Type": "application/json"} if data else {}
267
+ if capability is not None:
268
+ headers["X-SLM-Daemon-Capability"] = capability
269
+ headers["X-SLM-Target-Instance"] = descriptor.instance_id
133
270
  req = urllib.request.Request(url, data=data, headers=headers, method=method)
134
- resp = urllib.request.urlopen(req, timeout=30)
271
+ resp = urllib.request.urlopen(req, timeout=timeout_seconds)
135
272
  return json.loads(resp.read().decode())
136
273
  except Exception:
137
274
  return None
138
275
 
139
276
 
140
- _LOCK_FILE = Path.home() / ".superlocalmemory" / "daemon.lock"
277
+ _LOCK_FILE = None # test-only override; runtime resolution stays dynamic
278
+
279
+
280
+ def _pid_file_path():
281
+ return _PID_FILE or state_path("daemon.pid")
282
+
283
+
284
+ def _port_file_path():
285
+ return _PORT_FILE or state_path("daemon.port")
286
+
287
+
288
+ def _lock_file_path():
289
+ return _LOCK_FILE or state_path("daemon.lock")
141
290
 
142
291
 
143
292
  def _start_daemon_subprocess() -> bool:
@@ -161,8 +310,17 @@ def _start_daemon_subprocess() -> bool:
161
310
  """
162
311
  if is_daemon_running():
163
312
  return True
313
+ # Never create a descriptor for a child that cannot own the listener.
314
+ # A closed connection in TIME_WAIT is not a listener and is safe: the
315
+ # server reserves its socket with SO_REUSEADDR during bootstrap.
316
+ if _has_tcp_listener(_DEFAULT_PORT):
317
+ logger.warning("daemon port %d is already owned; refusing a second start", _DEFAULT_PORT)
318
+ return False
319
+ assert_no_durable_root_conflict()
164
320
 
165
321
  import subprocess
322
+
323
+ from superlocalmemory import __version__ as _slm_version
166
324
  # v3.6.9 (#33): pass SLM_DAEMON_PORT as explicit --port= so the daemon
167
325
  # binds the right port even when the env var reaches the subprocess.
168
326
  _target_port = _DEFAULT_PORT
@@ -170,7 +328,7 @@ def _start_daemon_subprocess() -> bool:
170
328
  sys.executable, "-m", "superlocalmemory.server.unified_daemon",
171
329
  "--start", f"--port={_target_port}",
172
330
  ]
173
- log_dir = Path.home() / ".superlocalmemory" / "logs"
331
+ log_dir = state_path("logs")
174
332
  log_dir.mkdir(parents=True, exist_ok=True)
175
333
  log_file = log_dir / "daemon.log"
176
334
 
@@ -189,14 +347,39 @@ def _start_daemon_subprocess() -> bool:
189
347
  daemon_env = os.environ.copy()
190
348
  daemon_env["OMP_NUM_THREADS"] = "1"
191
349
  daemon_env["KMP_DUPLICATE_LIB_OK"] = "TRUE"
350
+ bootstrap_descriptor = build_descriptor(
351
+ port=_target_port,
352
+ version=_slm_version,
353
+ pid=os.getpid(),
354
+ state="starting",
355
+ )
356
+ daemon_env["SLM_DAEMON_INSTANCE_ID"] = bootstrap_descriptor.instance_id
357
+ daemon_env["SLM_DAEMON_CAPABILITY"] = bootstrap_descriptor.capability
192
358
  kwargs["env"] = daemon_env
193
359
 
194
360
  with open(log_file, "a") as lf:
195
361
  proc = subprocess.Popen(cmd, stdout=lf, stderr=lf, **kwargs)
196
362
 
197
- # Write PID immediately so other callers see it during warmup
198
- _PID_FILE.write_text(str(proc.pid))
199
- _PORT_FILE.write_text(str(_target_port))
363
+ # Publish the exact child identity immediately so concurrent callers know
364
+ # this namespace is warming up. If the child won the race and already
365
+ # published the same instance as ready, never overwrite it with starting.
366
+ child_descriptor = replace(
367
+ bootstrap_descriptor,
368
+ pid=proc.pid,
369
+ process_create_time=process_create_time_for(proc.pid),
370
+ )
371
+ current = read_descriptor()
372
+ if not (
373
+ current is not None
374
+ and current.instance_id == child_descriptor.instance_id
375
+ and current.pid == child_descriptor.pid
376
+ and current.state == "ready"
377
+ ):
378
+ write_descriptor(child_descriptor)
379
+
380
+ # One-release compatibility mirrors; never sufficient for ownership.
381
+ _pid_file_path().write_text(str(proc.pid))
382
+ _port_file_path().write_text(str(_target_port))
200
383
 
201
384
  return _wait_for_daemon(timeout=60)
202
385
 
@@ -218,12 +401,21 @@ def ensure_daemon() -> bool:
218
401
  """
219
402
  if is_daemon_running():
220
403
  return True
404
+ if (
405
+ os.environ.get("SLM_TEST_ISOLATION") == "1"
406
+ and os.environ.get("SLM_TEST_ALLOW_DAEMON_SPAWN") != "1"
407
+ ):
408
+ logger.debug(
409
+ "pytest isolation blocked daemon spawn; use an owned daemon fixture",
410
+ )
411
+ return False
221
412
 
222
413
  # File lock — prevent concurrent starts from multiple CLI/MCP calls
223
414
  lock_fd = None
224
415
  try:
225
- _LOCK_FILE.parent.mkdir(parents=True, exist_ok=True)
226
- lock_fd = open(_LOCK_FILE, "w")
416
+ lock_file = _lock_file_path()
417
+ lock_file.parent.mkdir(parents=True, exist_ok=True)
418
+ lock_fd = open(lock_file, "w")
227
419
 
228
420
  # Cross-platform file locking
229
421
  if sys.platform == "win32":
@@ -276,135 +468,38 @@ def ensure_daemon() -> bool:
276
468
  except Exception:
277
469
  pass
278
470
  try:
279
- _LOCK_FILE.unlink(missing_ok=True)
471
+ _lock_file_path().unlink(missing_ok=True)
280
472
  except Exception:
281
473
  pass
282
474
 
283
475
 
284
476
  def _wait_for_daemon(timeout: int = 60) -> bool:
285
- """Wait for daemon to become reachable. Checks PID alive first (fast),
286
- then health endpoint (confirms HTTP server is bound)."""
477
+ """Wait for matching owned health; liveness alone is never readiness."""
287
478
  for _ in range(timeout * 2): # check every 0.5s
288
479
  time.sleep(0.5)
289
- if is_daemon_running():
290
- # PID is alive — now optionally check if HTTP is ready
291
- port = _get_port()
292
- try:
293
- import urllib.request
294
- urllib.request.urlopen(f"http://127.0.0.1:{port}/health", timeout=2)
295
- return True # HTTP is ready
296
- except Exception:
297
- # PID alive but HTTP not ready — daemon is warming up, that's OK
480
+ descriptor = read_descriptor()
481
+ if descriptor is not None:
482
+ if not _descriptor_process_is_alive(descriptor):
483
+ continue
484
+ health = _fetch_health(descriptor.port)
485
+ if health is not None and descriptor_matches_health(descriptor, health):
298
486
  return True
487
+ continue
488
+ if descriptor_path().exists():
489
+ continue
490
+ if _verified_legacy_health() is not None:
491
+ return True
299
492
  return False
300
493
 
301
494
 
302
495
  def stop_daemon() -> bool:
303
- """Stop ALL SLM daemon processes and their workers.
496
+ """Stop only the daemon proven to belong to this data namespace.
304
497
 
305
- v3.4.7: Nuclear cleanup finds and kills ALL processes matching
306
- superlocalmemory.server.unified_daemon, embedding_worker, recall_worker,
307
- reranker_worker. Not just the PID file daemon. Multiple daemons can
308
- accumulate from rapid restarts, MCP warmups, and concurrent sessions.
498
+ Machine-wide process-name scans are forbidden: they can kill another SLM
499
+ installation or a user's live workers during tests. V3.7 uses the owned
500
+ HTTP capability; the daemon itself terminates its child process tree.
309
501
  """
310
- killed = 0
311
-
312
- try:
313
- import psutil
314
- my_pid = os.getpid()
315
- targets = [
316
- "superlocalmemory.server.unified_daemon",
317
- "superlocalmemory.core.embedding_worker",
318
- "superlocalmemory.core.recall_worker",
319
- "superlocalmemory.core.reranker_worker",
320
- ]
321
-
322
- for proc in psutil.process_iter(["pid", "cmdline"]):
323
- try:
324
- if proc.pid == my_pid:
325
- continue
326
- cmdline = " ".join(proc.info.get("cmdline") or [])
327
- if any(t in cmdline for t in targets):
328
- # Kill children first, then process
329
- for child in proc.children(recursive=True):
330
- try:
331
- child.kill()
332
- killed += 1
333
- except (psutil.NoSuchProcess, psutil.AccessDenied):
334
- pass
335
- proc.kill()
336
- killed += 1
337
- except (psutil.NoSuchProcess, psutil.AccessDenied):
338
- pass
339
-
340
- except ImportError:
341
- # Fallback: pkill by pattern
342
- try:
343
- import subprocess as _sp
344
- for pattern in [
345
- "superlocalmemory.server.unified_daemon",
346
- "superlocalmemory.core.embedding_worker",
347
- "superlocalmemory.core.recall_worker",
348
- "superlocalmemory.core.reranker_worker",
349
- ]:
350
- result = _sp.run(
351
- ["pkill", "-9", "-f", pattern],
352
- capture_output=True, timeout=5,
353
- )
354
- if result.returncode == 0:
355
- killed += 1
356
- except Exception:
357
- pass
358
-
359
- # Clean up PID/port files + worker PID files
360
- _PID_FILE.unlink(missing_ok=True)
361
- _PORT_FILE.unlink(missing_ok=True)
362
- # v3.4.13: Clean worker PID files (singleton guards)
363
- for pidfile in (".embedding-worker.pid", ".reranker-worker.pid"):
364
- (Path.home() / ".superlocalmemory" / pidfile).unlink(missing_ok=True)
365
-
366
- # v3.4.13: Wait for ALL workers to actually die before returning.
367
- # Without this, `slm restart` starts a new daemon before old workers exit,
368
- # causing duplicate embedding_workers (1.6GB each).
369
- if killed:
370
- logger.info("Stopped %d SLM processes, waiting for exit...", killed)
371
- _wait_for_workers_dead(timeout=10)
372
-
373
- return True
374
-
375
-
376
- def _wait_for_workers_dead(timeout: int = 10) -> None:
377
- """Wait until no SLM worker processes remain alive."""
378
- targets = [
379
- "superlocalmemory.server.unified_daemon",
380
- "superlocalmemory.core.embedding_worker",
381
- "superlocalmemory.core.recall_worker",
382
- "superlocalmemory.core.reranker_worker",
383
- ]
384
- my_pid = os.getpid()
385
- deadline = time.time() + timeout
386
-
387
- while time.time() < deadline:
388
- alive = False
389
- try:
390
- import psutil
391
- for proc in psutil.process_iter(["pid", "cmdline"]):
392
- try:
393
- if proc.pid == my_pid:
394
- continue
395
- cmdline = " ".join(proc.info.get("cmdline") or [])
396
- if any(t in cmdline for t in targets):
397
- alive = True
398
- break
399
- except (psutil.NoSuchProcess, psutil.AccessDenied):
400
- pass
401
- except ImportError:
402
- # No psutil — just wait a fixed time
403
- time.sleep(3)
404
- return
405
-
406
- if not alive:
407
- return
408
- time.sleep(0.5)
409
-
410
- logger.warning("Some SLM workers still alive after %ds timeout", timeout)
502
+ if read_descriptor() is None and _verified_legacy_health() is None:
503
+ return False
504
+ response = daemon_request("POST", "/stop")
505
+ return bool(response and response.get("status") == "stopping")
@@ -18,11 +18,13 @@ from __future__ import annotations
18
18
  from argparse import Namespace
19
19
  from pathlib import Path
20
20
 
21
+ from superlocalmemory.infra.data_root import DynamicStatePath
22
+
21
23
 
22
24
  # Canonical paths — match LLD-01 / LLD-07 layout. Callers can override
23
25
  # via the ``learning_db_path`` / ``memory_db_path`` attributes on args
24
26
  # (tests rely on this to point at fixture DBs).
25
- DEFAULT_HOME = Path.home() / ".superlocalmemory"
27
+ DEFAULT_HOME = DynamicStatePath()
26
28
 
27
29
 
28
30
  def _resolve_paths(args: Namespace) -> tuple[Path, Path]:
@@ -0,0 +1,28 @@
1
+ # Copyright (c) 2026 Varun Pratap Bhardwaj / Qualixar
2
+ # Licensed under AGPL-3.0-or-later - see LICENSE file
3
+
4
+ """Explicit manual export for local aggregate diagnostics."""
5
+
6
+ from __future__ import annotations
7
+
8
+ from argparse import Namespace
9
+
10
+
11
+ def cmd_diagnostics(args: Namespace) -> None:
12
+ action = getattr(args, "diagnostics_command", None)
13
+ if action != "export":
14
+ raise SystemExit("choose a diagnostics subcommand: export")
15
+
16
+ from superlocalmemory.cli.json_output import json_print
17
+ from superlocalmemory.infra.local_diagnostics import default_diagnostics
18
+
19
+ default_diagnostics().export_json(args.destination)
20
+ data = {"exported": True, "reporting": "manual_export_only"}
21
+ if bool(getattr(args, "json", False)):
22
+ json_print("diagnostics", data=data)
23
+ return
24
+ print("Local aggregate diagnostics exported by explicit request.")
25
+ print("No automatic reporting was enabled.")
26
+
27
+
28
+ __all__ = ["cmd_diagnostics"]