superlocalmemory 3.7.8 → 3.8.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (260) hide show
  1. package/.claude-plugin/marketplace.json +1 -1
  2. package/ATTRIBUTION.md +1 -3
  3. package/CHANGELOG.md +69 -0
  4. package/README.md +199 -29
  5. package/package.json +4 -2
  6. package/plugin/.claude-plugin/plugin.json +2 -2
  7. package/plugin/CLAUDE.md +8 -8
  8. package/plugin/agents/slm-governance-advisor.md +80 -0
  9. package/plugin/agents/slm-loop-runner.md +71 -0
  10. package/plugin/agents/slm-memory-advisor.md +10 -5
  11. package/plugin/agents/slm-optimize-advisor.md +9 -3
  12. package/plugin/commands/slm-loop.md +31 -0
  13. package/plugin/hooks/hooks.json +79 -0
  14. package/plugin/requirements.txt +1 -1
  15. package/plugin/scripts/slm-launch +46 -7
  16. package/plugin/settings.json +9 -0
  17. package/plugin/skills/slm-cache/SKILL.md +9 -1
  18. package/plugin/skills/slm-compress/SKILL.md +8 -1
  19. package/plugin/skills/slm-governance/SKILL.md +248 -0
  20. package/plugin/skills/slm-graph/SKILL.md +17 -3
  21. package/plugin/skills/slm-loop/SKILL.md +99 -0
  22. package/plugin/skills/slm-mesh/SKILL.md +282 -0
  23. package/plugin/skills/slm-profile/SKILL.md +148 -0
  24. package/plugin/skills/slm-recall/SKILL.md +46 -10
  25. package/plugin/skills/slm-remember/SKILL.md +48 -1
  26. package/plugin/skills/slm-scope/SKILL.md +176 -0
  27. package/plugin/skills/slm-session/SKILL.md +24 -1
  28. package/plugin/skills/slm-status/SKILL.md +18 -1
  29. package/plugin-src/agents/slm-governance-advisor.md +80 -0
  30. package/plugin-src/agents/slm-loop-runner.md +71 -0
  31. package/plugin-src/agents/slm-memory-advisor.md +10 -5
  32. package/plugin-src/agents/slm-optimize-advisor.md +9 -3
  33. package/plugin-src/commands/slm-loop.md +31 -0
  34. package/plugin-src/hooks/hooks.json +79 -0
  35. package/plugin-src/manifest.json +7 -2
  36. package/plugin-src/requirements.txt +1 -1
  37. package/plugin-src/rules/AGENTS.md +57 -18
  38. package/plugin-src/rules/CLAUDE.md.fragment +8 -8
  39. package/plugin-src/scripts/slm-launch +46 -7
  40. package/plugin-src/settings.json +9 -0
  41. package/plugin-src/skills/slm-cache/SKILL.md +9 -1
  42. package/plugin-src/skills/slm-compress/SKILL.md +8 -1
  43. package/plugin-src/skills/slm-governance/SKILL.md +248 -0
  44. package/plugin-src/skills/slm-graph/SKILL.md +17 -3
  45. package/plugin-src/skills/slm-loop/SKILL.md +99 -0
  46. package/plugin-src/skills/slm-mesh/SKILL.md +282 -0
  47. package/plugin-src/skills/slm-profile/SKILL.md +148 -0
  48. package/plugin-src/skills/slm-recall/SKILL.md +46 -10
  49. package/plugin-src/skills/slm-remember/SKILL.md +48 -1
  50. package/plugin-src/skills/slm-scope/SKILL.md +176 -0
  51. package/plugin-src/skills/slm-session/SKILL.md +24 -1
  52. package/plugin-src/skills/slm-status/SKILL.md +18 -1
  53. package/pyproject.toml +1 -1
  54. package/scripts/postinstall/validation.js +2 -0
  55. package/scripts/postinstall-interactive.js +74 -2
  56. package/src/superlocalmemory/__init__.py +1 -1
  57. package/src/superlocalmemory/access/__init__.py +3 -0
  58. package/src/superlocalmemory/access/rbac.py +477 -0
  59. package/src/superlocalmemory/cli/commands.py +94 -10
  60. package/src/superlocalmemory/cli/compress_cmd.py +17 -7
  61. package/src/superlocalmemory/cli/loop_cmd.py +192 -0
  62. package/src/superlocalmemory/cli/main.py +39 -4
  63. package/src/superlocalmemory/cli/mesh_cmd.py +38 -0
  64. package/src/superlocalmemory/cli/optimize_cmd.py +3 -0
  65. package/src/superlocalmemory/cli/pending_store.py +49 -13
  66. package/src/superlocalmemory/cli/proxy_cmd.py +4 -0
  67. package/src/superlocalmemory/cli/scale_engine_cmd.py +6 -0
  68. package/src/superlocalmemory/cli/setup_wizard.py +22 -13
  69. package/src/superlocalmemory/compliance/audit.py +6 -0
  70. package/src/superlocalmemory/compliance/gdpr.py +128 -138
  71. package/src/superlocalmemory/compliance/retention.py +176 -45
  72. package/src/superlocalmemory/core/backend_orchestrator.py +5 -43
  73. package/src/superlocalmemory/core/community_summary.py +267 -0
  74. package/src/superlocalmemory/core/config.py +216 -3
  75. package/src/superlocalmemory/core/consolidation_engine.py +95 -22
  76. package/src/superlocalmemory/core/context_cache.py +61 -18
  77. package/src/superlocalmemory/core/embedding_worker.py +17 -2
  78. package/src/superlocalmemory/core/embeddings.py +12 -1
  79. package/src/superlocalmemory/core/engine.py +17 -1
  80. package/src/superlocalmemory/core/engine_ingestion.py +29 -0
  81. package/src/superlocalmemory/core/engine_wiring.py +13 -0
  82. package/src/superlocalmemory/core/entity_community.py +178 -0
  83. package/src/superlocalmemory/core/graph_analyzer.py +39 -2
  84. package/src/superlocalmemory/core/graph_pruner.py +13 -8
  85. package/src/superlocalmemory/core/key_expander.py +138 -0
  86. package/src/superlocalmemory/core/maintenance.py +23 -0
  87. package/src/superlocalmemory/core/modes.py +1 -1
  88. package/src/superlocalmemory/core/mutations.py +2 -2
  89. package/src/superlocalmemory/core/pii.py +105 -0
  90. package/src/superlocalmemory/core/progressive_abstraction.py +208 -0
  91. package/src/superlocalmemory/core/recall_pipeline.py +2 -0
  92. package/src/superlocalmemory/core/recall_worker.py +20 -6
  93. package/src/superlocalmemory/core/scale_engine.py +60 -1
  94. package/src/superlocalmemory/core/security_primitives.py +40 -2
  95. package/src/superlocalmemory/core/store_pipeline.py +35 -11
  96. package/src/superlocalmemory/core/worker_pool.py +21 -6
  97. package/src/superlocalmemory/encoding/entity_reflexion.py +200 -0
  98. package/src/superlocalmemory/encoding/entity_resolver.py +34 -24
  99. package/src/superlocalmemory/encoding/fact_extractor.py +26 -1
  100. package/src/superlocalmemory/encoding/temporal_validator.py +64 -1
  101. package/src/superlocalmemory/evolution/evolution_store.py +122 -45
  102. package/src/superlocalmemory/evolution/llm_dispatch.py +12 -1
  103. package/src/superlocalmemory/evolution/model_selection.py +160 -0
  104. package/src/superlocalmemory/evolution/mutation_generator.py +16 -0
  105. package/src/superlocalmemory/evolution/skill_evolver.py +127 -42
  106. package/src/superlocalmemory/evolution/triggers.py +22 -13
  107. package/src/superlocalmemory/graph/cozo_backend.py +43 -20
  108. package/src/superlocalmemory/hooks/adapter_base.py +5 -1
  109. package/src/superlocalmemory/hooks/auto_recall.py +13 -1
  110. package/src/superlocalmemory/hooks/claude_code_hooks.py +11 -0
  111. package/src/superlocalmemory/hooks/codex_assets.py +64 -5
  112. package/src/superlocalmemory/hooks/hook_daemon.py +20 -3
  113. package/src/superlocalmemory/hooks/memory_protocol.py +54 -0
  114. package/src/superlocalmemory/hooks/portable_kit.py +114 -1
  115. package/src/superlocalmemory/infra/backup.py +12 -1
  116. package/src/superlocalmemory/infra/daemon_identity.py +40 -4
  117. package/src/superlocalmemory/infra/data_root.py +43 -4
  118. package/src/superlocalmemory/infra/event_bus.py +107 -24
  119. package/src/superlocalmemory/infra/rate_limiter.py +93 -0
  120. package/src/superlocalmemory/ingestion/adapter_manager.py +4 -1
  121. package/src/superlocalmemory/ingestion/credentials.py +1 -1
  122. package/src/superlocalmemory/learning/cross_project.py +28 -19
  123. package/src/superlocalmemory/learning/reward_proxy.py +42 -9
  124. package/src/superlocalmemory/loops/__init__.py +56 -0
  125. package/src/superlocalmemory/loops/budget.py +58 -0
  126. package/src/superlocalmemory/loops/engine.py +164 -0
  127. package/src/superlocalmemory/loops/ledger.py +243 -0
  128. package/src/superlocalmemory/loops/models.py +152 -0
  129. package/src/superlocalmemory/loops/rules.py +52 -0
  130. package/src/superlocalmemory/mcp/_daemon_proxy.py +3 -0
  131. package/src/superlocalmemory/mcp/_pool_adapter.py +2 -0
  132. package/src/superlocalmemory/mcp/profiles.py +103 -0
  133. package/src/superlocalmemory/mcp/server.py +21 -49
  134. package/src/superlocalmemory/mcp/tools_active.py +4 -7
  135. package/src/superlocalmemory/mcp/tools_code_graph.py +51 -5
  136. package/src/superlocalmemory/mcp/tools_core.py +8 -1
  137. package/src/superlocalmemory/mcp/tools_evolution.py +6 -3
  138. package/src/superlocalmemory/mcp/tools_loops.py +300 -0
  139. package/src/superlocalmemory/mcp/tools_mesh.py +140 -4
  140. package/src/superlocalmemory/mcp/tools_optimize.py +15 -8
  141. package/src/superlocalmemory/mesh/broker.py +237 -129
  142. package/src/superlocalmemory/mesh/remote_sync.py +50 -8
  143. package/src/superlocalmemory/optimize/NOTICE +1 -6
  144. package/src/superlocalmemory/optimize/adapters/anthropic_adapter.py +1 -4
  145. package/src/superlocalmemory/optimize/adapters/openai_adapter.py +1 -4
  146. package/src/superlocalmemory/optimize/cache/semantic.py +27 -19
  147. package/src/superlocalmemory/optimize/compress/align.py +32 -26
  148. package/src/superlocalmemory/optimize/compress/ccr.py +14 -71
  149. package/src/superlocalmemory/optimize/compress/router.py +105 -22
  150. package/src/superlocalmemory/optimize/config/defaults.py +1 -1
  151. package/src/superlocalmemory/optimize/config/schema.py +87 -4
  152. package/src/superlocalmemory/optimize/metrics/counters.py +13 -4
  153. package/src/superlocalmemory/optimize/metrics/estimator.py +0 -3
  154. package/src/superlocalmemory/optimize/proxy/_helpers.py +31 -4
  155. package/src/superlocalmemory/optimize/storage/db.py +38 -9
  156. package/src/superlocalmemory/optimize/storage/schema.py +10 -0
  157. package/src/superlocalmemory/parameterization/pattern_extractor.py +6 -3
  158. package/src/superlocalmemory/retrieval/agentic.py +1 -1
  159. package/src/superlocalmemory/retrieval/bm25_channel.py +68 -10
  160. package/src/superlocalmemory/retrieval/engine.py +168 -26
  161. package/src/superlocalmemory/retrieval/entity_channel.py +7 -5
  162. package/src/superlocalmemory/retrieval/hopfield_channel.py +9 -2
  163. package/src/superlocalmemory/retrieval/semantic_channel.py +114 -21
  164. package/src/superlocalmemory/retrieval/spreading_activation.py +11 -2
  165. package/src/superlocalmemory/retrieval/temporal_channel.py +48 -9
  166. package/src/superlocalmemory/retrieval/temporal_frame.py +102 -0
  167. package/src/superlocalmemory/retrieval/temporal_validity_filter.py +135 -0
  168. package/src/superlocalmemory/retrieval/time_window.py +181 -0
  169. package/src/superlocalmemory/server/api.py +4 -4
  170. package/src/superlocalmemory/server/profile_runtime.py +125 -8
  171. package/src/superlocalmemory/server/rbac_enforce.py +142 -0
  172. package/src/superlocalmemory/server/recall_health.py +24 -3
  173. package/src/superlocalmemory/server/recall_serializer.py +19 -1
  174. package/src/superlocalmemory/server/routes/abstraction.py +115 -0
  175. package/src/superlocalmemory/server/routes/agents.py +128 -38
  176. package/src/superlocalmemory/server/routes/backup.py +34 -10
  177. package/src/superlocalmemory/server/routes/behavioral.py +13 -12
  178. package/src/superlocalmemory/server/routes/brain.py +21 -5
  179. package/src/superlocalmemory/server/routes/chat.py +10 -5
  180. package/src/superlocalmemory/server/routes/compliance.py +171 -21
  181. package/src/superlocalmemory/server/routes/config_api.py +436 -0
  182. package/src/superlocalmemory/server/routes/data_io.py +30 -8
  183. package/src/superlocalmemory/server/routes/entity.py +9 -4
  184. package/src/superlocalmemory/server/routes/events.py +24 -8
  185. package/src/superlocalmemory/server/routes/evolution.py +135 -17
  186. package/src/superlocalmemory/server/routes/helpers.py +16 -1
  187. package/src/superlocalmemory/server/routes/ingest.py +7 -4
  188. package/src/superlocalmemory/server/routes/insights.py +3 -3
  189. package/src/superlocalmemory/server/routes/learning.py +14 -14
  190. package/src/superlocalmemory/server/routes/lifecycle.py +59 -8
  191. package/src/superlocalmemory/server/routes/memories.py +182 -57
  192. package/src/superlocalmemory/server/routes/mesh.py +95 -15
  193. package/src/superlocalmemory/server/routes/optimize.py +33 -1
  194. package/src/superlocalmemory/server/routes/prewarm.py +2 -0
  195. package/src/superlocalmemory/server/routes/profiles.py +63 -17
  196. package/src/superlocalmemory/server/routes/ratelimit.py +124 -0
  197. package/src/superlocalmemory/server/routes/rbac.py +367 -0
  198. package/src/superlocalmemory/server/routes/stats.py +13 -6
  199. package/src/superlocalmemory/server/routes/tiers.py +11 -9
  200. package/src/superlocalmemory/server/routes/v3_api.py +183 -69
  201. package/src/superlocalmemory/server/routes/ws.py +5 -2
  202. package/src/superlocalmemory/server/security_middleware.py +12 -5
  203. package/src/superlocalmemory/server/ui.py +20 -5
  204. package/src/superlocalmemory/server/unified_daemon.py +384 -56
  205. package/src/superlocalmemory/server/write_identity.py +38 -8
  206. package/src/superlocalmemory/storage/database.py +265 -53
  207. package/src/superlocalmemory/storage/migration_runner.py +53 -0
  208. package/src/superlocalmemory/storage/migrations/M021_ingestion_log_profile.py +108 -0
  209. package/src/superlocalmemory/storage/migrations/M022_entity_aliases_profile.py +86 -0
  210. package/src/superlocalmemory/storage/migrations/M023_mesh_profile_isolation.py +194 -0
  211. package/src/superlocalmemory/storage/migrations/M024_rbac_users_roles.py +87 -0
  212. package/src/superlocalmemory/storage/migrations/M025_perf_indexes.py +90 -0
  213. package/src/superlocalmemory/storage/migrations/M026_rbac_memberships_fk.py +136 -0
  214. package/src/superlocalmemory/storage/migrations/M027_transferable_patterns_profile.py +163 -0
  215. package/src/superlocalmemory/storage/models.py +4 -0
  216. package/src/superlocalmemory/storage/schema.py +87 -0
  217. package/src/superlocalmemory/storage/schema_v343.py +24 -12
  218. package/src/superlocalmemory/trust/gate.py +49 -8
  219. package/src/superlocalmemory/ui/assets/slm-icon-white.svg +64 -0
  220. package/src/superlocalmemory/ui/assets/slm-icon.svg +36 -0
  221. package/src/superlocalmemory/ui/css/design-system.css +621 -0
  222. package/src/superlocalmemory/ui/css/neural-glass.css +6 -0
  223. package/src/superlocalmemory/ui/css/od-bridge.css +158 -0
  224. package/src/superlocalmemory/ui/favicon.svg +35 -4
  225. package/src/superlocalmemory/ui/index.html +306 -173
  226. package/src/superlocalmemory/ui/js/brain.js +5 -20
  227. package/src/superlocalmemory/ui/js/core.js +47 -31
  228. package/src/superlocalmemory/ui/js/dashboard.js +314 -63
  229. package/src/superlocalmemory/ui/js/event-delegation.js +102 -0
  230. package/src/superlocalmemory/ui/js/knowledge-graph.js +11 -11
  231. package/src/superlocalmemory/ui/js/math-health.js +1 -1
  232. package/src/superlocalmemory/ui/js/memories.js +15 -4
  233. package/src/superlocalmemory/ui/js/memory-chat.js +7 -7
  234. package/src/superlocalmemory/ui/js/ng-entities.js +6 -8
  235. package/src/superlocalmemory/ui/js/ng-ingestion.js +4 -4
  236. package/src/superlocalmemory/ui/js/ng-mesh.js +4 -9
  237. package/src/superlocalmemory/ui/js/ng-shell.js +8 -8
  238. package/src/superlocalmemory/ui/js/ng-skills.js +54 -2
  239. package/src/superlocalmemory/ui/js/od-agents.js +544 -0
  240. package/src/superlocalmemory/ui/js/od-auth-gate.js +257 -0
  241. package/src/superlocalmemory/ui/js/od-backup.js +780 -0
  242. package/src/superlocalmemory/ui/js/od-brain.js +779 -0
  243. package/src/superlocalmemory/ui/js/od-entities.js +579 -0
  244. package/src/superlocalmemory/ui/js/od-graph.js +593 -0
  245. package/src/superlocalmemory/ui/js/od-health.js +539 -0
  246. package/src/superlocalmemory/ui/js/od-mcp.js +508 -0
  247. package/src/superlocalmemory/ui/js/od-memories.js +887 -0
  248. package/src/superlocalmemory/ui/js/od-mesh.js +539 -0
  249. package/src/superlocalmemory/ui/js/od-operations.js +1250 -0
  250. package/src/superlocalmemory/ui/js/od-optimize.js +787 -0
  251. package/src/superlocalmemory/ui/js/od-settings.js +1053 -0
  252. package/src/superlocalmemory/ui/js/od-shell.js +593 -0
  253. package/src/superlocalmemory/ui/js/od-skills.js +573 -0
  254. package/src/superlocalmemory/ui/js/od-team.js +258 -0
  255. package/src/superlocalmemory/ui/js/profiles.js +159 -46
  256. package/src/superlocalmemory/ui/js/settings.js +2 -2
  257. package/src/superlocalmemory/ui/js/timeline.js +34 -5
  258. package/src/superlocalmemory/ui/js/trust-dashboard.js +2 -2
  259. package/src/superlocalmemory/vector/lancedb_backend.py +8 -6
  260. package/src/superlocalmemory/learning/behavioral_listener.py +0 -94
@@ -17,7 +17,7 @@ import sqlite3
17
17
  import threading
18
18
  import time
19
19
  import uuid
20
- from datetime import datetime, timezone
20
+ from datetime import datetime, timedelta, timezone
21
21
  from pathlib import Path
22
22
  from typing import Any, Callable, TypeVar
23
23
 
@@ -36,12 +36,17 @@ LOCAL_HOSTS = frozenset({"127.0.0.1", "localhost", "::1"})
36
36
 
37
37
  MAX_MESSAGE_SIZE = 4096 # 4KB cap — mesh messages are notifications, not data dumps
38
38
  MESSAGE_TTL_HOURS = 48 # Offline messages expire after 48h
39
+ LOCK_TTL_HOURS = 8 # M-02: file locks auto-expire so a crashed session can't deadlock a path
40
+ _NEVER_EXPIRES = "9999-12-31T23:59:59Z" # legacy sentinel default; treated as stale/free
39
41
  MAX_QUEUED_PER_TARGET = 50 # Max unread messages per broadcast/project target
40
42
 
41
43
  _T = TypeVar("_T")
42
- _WRITE_RETRY_ATTEMPTS = 6
44
+ # Retry budget for a transient writer collision. 250ms was too thin under
45
+ # multi-agent load; 2000ms matches the RBAC store. 4 attempts keeps the total
46
+ # worst-case budget bounded (~2s x backoff) rather than unbounded stalls.
47
+ _WRITE_RETRY_ATTEMPTS = 4
43
48
  _WRITE_RETRY_BASE_SECONDS = 0.025
44
- _WRITE_BUSY_TIMEOUT_MS = 250
49
+ _WRITE_BUSY_TIMEOUT_MS = 2000
45
50
 
46
51
 
47
52
  class MeshBroker:
@@ -64,6 +69,7 @@ class MeshBroker:
64
69
  self._ws_port = int(_os.environ.get("SLM_MESH_WS_PORT", "7900"))
65
70
  self._discovery_enabled = self._is_remote and _os.environ.get("SLM_MESH_DISCOVERY", "on") != "off"
66
71
  self._remote_peers: dict[str, dict] = {}
72
+ self._remote_peers_lock = threading.RLock()
67
73
  self._peer_url: str | None = _os.environ.get("SLM_MESH_PEER_URL", "") or None
68
74
  self._sync_client: Any = None
69
75
  if self._is_remote and not self._shared_secret:
@@ -74,22 +80,33 @@ class MeshBroker:
74
80
 
75
81
  # -- Remote / Multi-Machine support (v3.4.47) --
76
82
 
77
- def get_remote_peers(self) -> list[dict]:
78
- """Return peers from discovered remote brokers."""
79
- return list(self._remote_peers.values())
83
+ def get_remote_peers(self, profile_id: str = "default") -> list[dict]:
84
+ """Return peers from discovered remote brokers for this tenant.
85
+
86
+ Remote peers carry their home ``profile_id`` in the info dict (populated
87
+ by the /mesh/peers sync). A peer with no profile_id is a legacy remote
88
+ and treated as 'default'. Cross-tenant peers are never returned.
89
+ """
90
+ with self._remote_peers_lock:
91
+ return [
92
+ info for info in self._remote_peers.values()
93
+ if info.get("profile_id", "default") == profile_id
94
+ ]
80
95
 
81
96
  def add_remote_peer(self, peer_id: str, info: dict) -> None:
82
97
  """Register a peer from a remote broker."""
83
- self._remote_peers[peer_id] = info
98
+ with self._remote_peers_lock:
99
+ self._remote_peers[peer_id] = info
84
100
 
85
101
  def remove_remote_peer(self, peer_id: str) -> None:
86
102
  """Remove a remote peer."""
87
- self._remote_peers.pop(peer_id, None)
103
+ with self._remote_peers_lock:
104
+ self._remote_peers.pop(peer_id, None)
88
105
 
89
- def list_all_peers(self) -> list[dict]:
90
- """Return local + remote peers merged."""
91
- local = self.list_peers()
92
- remote = self.get_remote_peers()
106
+ def list_all_peers(self, profile_id: str = "default") -> list[dict]:
107
+ """Return local + remote peers for this tenant, merged."""
108
+ local = self.list_peers(profile_id)
109
+ remote = self.get_remote_peers(profile_id)
93
110
  return local + remote
94
111
 
95
112
  def start_cleanup(self) -> None:
@@ -168,59 +185,72 @@ class MeshBroker:
168
185
 
169
186
  def register_peer(self, session_id: str, summary: str = "",
170
187
  host: str = "", port: int = 0,
171
- project_path: str = "", agent_type: str = "unknown") -> dict:
188
+ project_path: str = "", agent_type: str = "unknown",
189
+ profile_id: str = "default") -> dict:
172
190
  def _register(conn: sqlite3.Connection) -> dict:
173
191
  now = datetime.now(timezone.utc).isoformat()
174
192
  effective_host = host or self._host
175
- # Idempotent: update if same session_id exists
193
+ # Idempotent within the tenant: update if same session_id exists
194
+ # under this profile. A session_id in another tenant is a different
195
+ # peer and must not be adopted here.
176
196
  existing = conn.execute(
177
- "SELECT peer_id FROM mesh_peers WHERE session_id = ?",
178
- (session_id,),
197
+ "SELECT peer_id FROM mesh_peers WHERE session_id = ? AND profile_id = ?",
198
+ (session_id, profile_id),
179
199
  ).fetchone()
180
200
  if existing:
181
201
  peer_id = existing["peer_id"]
182
202
  conn.execute(
183
203
  "UPDATE mesh_peers SET summary=?, host=?, port=?, last_heartbeat=?, "
184
- "status='active', project_path=?, agent_type=? WHERE peer_id=?",
185
- (summary, effective_host, port, now, project_path, agent_type, peer_id),
204
+ "status='active', project_path=?, agent_type=? "
205
+ "WHERE peer_id=? AND profile_id=?",
206
+ (summary, effective_host, port, now, project_path, agent_type,
207
+ peer_id, profile_id),
186
208
  )
187
209
  else:
188
210
  peer_id = str(uuid.uuid4())[:12]
189
211
  conn.execute(
190
212
  "INSERT INTO mesh_peers (peer_id, session_id, summary, status, host, port, "
191
- "registered_at, last_heartbeat, project_path, agent_type) "
192
- "VALUES (?, ?, ?, 'active', ?, ?, ?, ?, ?, ?)",
193
- (peer_id, session_id, summary, effective_host, port, now, now, project_path, agent_type),
213
+ "registered_at, last_heartbeat, project_path, agent_type, profile_id) "
214
+ "VALUES (?, ?, ?, 'active', ?, ?, ?, ?, ?, ?, ?)",
215
+ (peer_id, session_id, summary, effective_host, port, now, now,
216
+ project_path, agent_type, profile_id),
194
217
  )
195
218
  self._log_event(conn, "peer_registered", peer_id, {
196
219
  "session_id": session_id, "project_path": project_path,
197
- })
220
+ }, profile_id=profile_id)
198
221
  conn.commit()
199
222
 
200
223
  # v3.4.6: Deliver pending broadcast/project messages on registration
201
- pending = self._get_pending_for_peer(conn, peer_id, project_path)
224
+ pending = self._get_pending_for_peer(conn, peer_id, project_path, profile_id)
202
225
  return {"peer_id": peer_id, "ok": True, "pending_messages": len(pending)}
203
226
 
204
227
  return self._write_with_retry(_register)
205
228
 
206
- def deregister_peer(self, peer_id: str) -> dict:
229
+ def deregister_peer(self, peer_id: str, profile_id: str = "default") -> dict:
207
230
  def _deregister(conn: sqlite3.Connection) -> dict:
208
- row = conn.execute("SELECT 1 FROM mesh_peers WHERE peer_id=?", (peer_id,)).fetchone()
231
+ row = conn.execute(
232
+ "SELECT 1 FROM mesh_peers WHERE peer_id=? AND profile_id=?",
233
+ (peer_id, profile_id),
234
+ ).fetchone()
209
235
  if not row:
210
236
  return {"ok": False, "error": "peer not found"}
211
- conn.execute("DELETE FROM mesh_peers WHERE peer_id=?", (peer_id,))
212
- self._log_event(conn, "peer_deregistered", peer_id)
237
+ conn.execute(
238
+ "DELETE FROM mesh_peers WHERE peer_id=? AND profile_id=?",
239
+ (peer_id, profile_id),
240
+ )
241
+ self._log_event(conn, "peer_deregistered", peer_id, profile_id=profile_id)
213
242
  conn.commit()
214
243
  return {"ok": True}
215
244
 
216
245
  return self._write_with_retry(_deregister)
217
246
 
218
- def heartbeat(self, peer_id: str) -> dict:
247
+ def heartbeat(self, peer_id: str, profile_id: str = "default") -> dict:
219
248
  def _heartbeat(conn: sqlite3.Connection) -> dict:
220
249
  now = datetime.now(timezone.utc).isoformat()
221
250
  cursor = conn.execute(
222
- "UPDATE mesh_peers SET last_heartbeat=?, status='active' WHERE peer_id=?",
223
- (now, peer_id),
251
+ "UPDATE mesh_peers SET last_heartbeat=?, status='active' "
252
+ "WHERE peer_id=? AND profile_id=?",
253
+ (now, peer_id, profile_id),
224
254
  )
225
255
  if cursor.rowcount == 0:
226
256
  return {"ok": False, "error": "peer not found"}
@@ -229,11 +259,12 @@ class MeshBroker:
229
259
 
230
260
  return self._write_with_retry(_heartbeat)
231
261
 
232
- def update_summary(self, peer_id: str, summary: str) -> dict:
262
+ def update_summary(self, peer_id: str, summary: str,
263
+ profile_id: str = "default") -> dict:
233
264
  def _update_summary(conn: sqlite3.Connection) -> dict:
234
265
  cursor = conn.execute(
235
- "UPDATE mesh_peers SET summary=? WHERE peer_id=?",
236
- (summary, peer_id),
266
+ "UPDATE mesh_peers SET summary=? WHERE peer_id=? AND profile_id=?",
267
+ (summary, peer_id, profile_id),
237
268
  )
238
269
  if cursor.rowcount == 0:
239
270
  return {"ok": False, "error": "peer not found"}
@@ -242,13 +273,14 @@ class MeshBroker:
242
273
 
243
274
  return self._write_with_retry(_update_summary)
244
275
 
245
- def list_peers(self) -> list[dict]:
276
+ def list_peers(self, profile_id: str = "default") -> list[dict]:
246
277
  conn = self._conn()
247
278
  try:
248
279
  rows = conn.execute(
249
280
  "SELECT peer_id, session_id, summary, status, host, port, "
250
- "registered_at, last_heartbeat, project_path, agent_type "
251
- "FROM mesh_peers ORDER BY last_heartbeat DESC",
281
+ "registered_at, last_heartbeat, project_path, agent_type, profile_id "
282
+ "FROM mesh_peers WHERE profile_id=? ORDER BY last_heartbeat DESC",
283
+ (profile_id,),
252
284
  ).fetchall()
253
285
  return [dict(r) for r in rows]
254
286
  finally:
@@ -257,7 +289,8 @@ class MeshBroker:
257
289
  # -- Messages --
258
290
 
259
291
  def send_message(self, from_peer: str, to_peer: str, content: str,
260
- msg_type: str = "text", project_path: str = "") -> dict:
292
+ msg_type: str = "text", project_path: str = "",
293
+ profile_id: str = "default") -> dict:
261
294
  # Guard: 4KB message size cap
262
295
  if len(content) > MAX_MESSAGE_SIZE:
263
296
  return {"ok": False, "error": f"message too large ({len(content)} bytes, max {MAX_MESSAGE_SIZE}). "
@@ -266,64 +299,91 @@ class MeshBroker:
266
299
  # Remote delivery is an external side effect, so do it outside the
267
300
  # retry envelope. Local writes below are retried as a whole short
268
301
  # transaction when another daemon-owned operation has SQLite's writer.
269
- if to_peer in self._remote_peers and self._sync_client:
302
+ # Hold the lock only for the membership check, not for the HTTP call.
303
+ # A remote peer only counts if it belongs to this tenant (profile).
304
+ with self._remote_peers_lock:
305
+ remote_info = self._remote_peers.get(to_peer)
306
+ is_remote = (
307
+ remote_info is not None
308
+ and remote_info.get("profile_id", "default") == profile_id
309
+ )
310
+ if is_remote and self._sync_client:
270
311
  return self._sync_client.send_to_remote(to_peer, {
271
312
  "from_peer": from_peer,
272
313
  "to": to_peer,
273
314
  "content": content,
274
315
  "type": msg_type,
316
+ "profile_id": profile_id,
275
317
  })
276
318
 
277
319
  def _send(conn: sqlite3.Connection) -> dict:
278
- nonlocal to_peer, project_path
320
+ # Derive locals fresh on EVERY call. A nonlocal mutation of to_peer
321
+ # persisted across _write_with_retry attempts: a 'project:' address
322
+ # rewritten to 'project' on the first try then misrouted to the
323
+ # direct-peer branch on retry ("recipient peer not found").
324
+ _to_peer = to_peer
325
+ _project_path = project_path
279
326
  now = datetime.now(timezone.utc).isoformat()
280
327
  expires_at = self._compute_expires(now)
281
328
 
282
329
  # Determine target type
283
- if to_peer == "broadcast":
330
+ if _to_peer == "broadcast":
284
331
  target_type = "broadcast"
285
- elif to_peer.startswith("project:"):
332
+ elif _to_peer.startswith("project:"):
286
333
  target_type = "project"
287
- project_path = to_peer[len("project:"):]
288
- to_peer = "project"
334
+ _project_path = _to_peer[len("project:"):]
335
+ _to_peer = "project"
289
336
  else:
290
337
  target_type = "peer"
291
- # Verify recipient exists for direct messages
292
- if not conn.execute("SELECT 1 FROM mesh_peers WHERE peer_id=?", (to_peer,)).fetchone():
338
+ # Verify recipient exists WITHIN this tenant for direct messages.
339
+ # A peer_id in another profile is not a valid recipient here.
340
+ if not conn.execute(
341
+ "SELECT 1 FROM mesh_peers WHERE peer_id=? AND profile_id=?",
342
+ (_to_peer, profile_id),
343
+ ).fetchone():
293
344
  return {"ok": False, "error": "recipient peer not found"}
294
345
 
295
- # Enforce per-target queue cap
346
+ # Enforce per-target queue cap (per tenant)
296
347
  if target_type in ("broadcast", "project"):
297
348
  count = conn.execute(
298
- "SELECT COUNT(*) FROM mesh_messages WHERE target_type=? AND project_path=? AND read=0",
299
- (target_type, project_path),
349
+ "SELECT COUNT(*) FROM mesh_messages "
350
+ "WHERE profile_id=? AND target_type=? AND project_path=? AND read=0",
351
+ (profile_id, target_type, _project_path),
300
352
  ).fetchone()[0]
301
353
  if count >= MAX_QUEUED_PER_TARGET:
302
354
  # Delete oldest to make room
303
355
  conn.execute(
304
356
  "DELETE FROM mesh_messages WHERE id IN ("
305
- " SELECT id FROM mesh_messages WHERE target_type=? AND project_path=? AND read=0 "
357
+ " SELECT id FROM mesh_messages "
358
+ " WHERE profile_id=? AND target_type=? AND project_path=? AND read=0 "
306
359
  " ORDER BY created_at ASC LIMIT ?)",
307
- (target_type, project_path, count - MAX_QUEUED_PER_TARGET + 1),
360
+ (profile_id, target_type, _project_path,
361
+ count - MAX_QUEUED_PER_TARGET + 1),
308
362
  )
309
363
 
310
364
  cursor = conn.execute(
311
365
  "INSERT INTO mesh_messages (from_peer, to_peer, msg_type, content, read, "
312
- "created_at, expires_at, target_type, project_path) "
313
- "VALUES (?, ?, ?, ?, 0, ?, ?, ?, ?)",
314
- (from_peer, to_peer, msg_type, content, now, expires_at, target_type, project_path),
366
+ "created_at, expires_at, target_type, project_path, profile_id) "
367
+ "VALUES (?, ?, ?, ?, 0, ?, ?, ?, ?, ?)",
368
+ (from_peer, _to_peer, msg_type, content, now, expires_at,
369
+ target_type, _project_path, profile_id),
315
370
  )
316
371
  self._log_event(conn, "message_sent", from_peer, {
317
- "to": to_peer, "target_type": target_type, "project": project_path,
318
- })
372
+ "to": _to_peer, "target_type": target_type, "project": _project_path,
373
+ }, profile_id=profile_id)
319
374
  conn.commit()
320
375
  return {"ok": True, "id": cursor.lastrowid, "target_type": target_type,
321
376
  "expires_at": expires_at}
322
377
 
323
378
  return self._write_with_retry(_send)
324
379
 
325
- def get_inbox(self, peer_id: str, project_path: str = "") -> list[dict]:
326
- """Get all messages for this peer: direct + broadcast + project."""
380
+ def get_inbox(self, peer_id: str, project_path: str = "",
381
+ profile_id: str = "default") -> list[dict]:
382
+ """Get all messages for this peer: direct + broadcast + project.
383
+
384
+ Scoped to the peer's tenant (profile_id): a peer never sees another
385
+ tenant's direct, broadcast, or project traffic.
386
+ """
327
387
  conn = self._conn()
328
388
  try:
329
389
  now = datetime.now(timezone.utc).isoformat()
@@ -334,10 +394,11 @@ class MeshBroker:
334
394
  direct = conn.execute(
335
395
  "SELECT id, from_peer, to_peer, msg_type, content, read, created_at, "
336
396
  "target_type, project_path FROM mesh_messages "
337
- "WHERE to_peer=? AND target_type='peer' AND COALESCE(read, 0) = 0 "
397
+ "WHERE profile_id=? AND to_peer=? AND target_type='peer' "
398
+ "AND COALESCE(read, 0) = 0 "
338
399
  "AND (expires_at IS NULL OR expires_at > ?) "
339
400
  "ORDER BY created_at DESC LIMIT 100",
340
- (peer_id, now),
401
+ (profile_id, peer_id, now),
341
402
  ).fetchall()
342
403
 
343
404
  # Broadcast messages not from this peer and not yet read by this peer
@@ -347,11 +408,11 @@ class MeshBroker:
347
408
  "m.created_at, m.target_type, m.project_path "
348
409
  "FROM mesh_messages m "
349
410
  "LEFT JOIN mesh_reads r ON m.id = r.message_id AND r.peer_id = ? "
350
- "WHERE m.target_type='broadcast' AND m.from_peer != ? "
411
+ "WHERE m.profile_id=? AND m.target_type='broadcast' AND m.from_peer != ? "
351
412
  "AND r.peer_id IS NULL "
352
413
  "AND (m.expires_at IS NULL OR m.expires_at > ?) "
353
414
  "ORDER BY m.created_at DESC LIMIT 50",
354
- (peer_id, peer_id, now),
415
+ (peer_id, profile_id, peer_id, now),
355
416
  ).fetchall()
356
417
 
357
418
  # Project messages for my project, not from me, not yet read
@@ -363,11 +424,12 @@ class MeshBroker:
363
424
  "m.created_at, m.target_type, m.project_path "
364
425
  "FROM mesh_messages m "
365
426
  "LEFT JOIN mesh_reads r ON m.id = r.message_id AND r.peer_id = ? "
366
- "WHERE m.target_type='project' AND m.project_path=? AND m.from_peer != ? "
427
+ "WHERE m.profile_id=? AND m.target_type='project' "
428
+ "AND m.project_path=? AND m.from_peer != ? "
367
429
  "AND r.peer_id IS NULL "
368
430
  "AND (m.expires_at IS NULL OR m.expires_at > ?) "
369
431
  "ORDER BY m.created_at DESC LIMIT 50",
370
- (peer_id, project_path, peer_id, now),
432
+ (peer_id, profile_id, project_path, peer_id, now),
371
433
  ).fetchall()
372
434
 
373
435
  all_msgs = [dict(r) for r in direct] + [dict(r) for r in broadcast] + [dict(r) for r in project_msgs]
@@ -377,29 +439,35 @@ class MeshBroker:
377
439
  finally:
378
440
  conn.close()
379
441
 
380
- def mark_read(self, peer_id: str, message_ids: list[int]) -> dict:
442
+ def mark_read(self, peer_id: str, message_ids: list[int],
443
+ profile_id: str = "default") -> dict:
381
444
  def _mark_read(conn: sqlite3.Connection) -> dict:
445
+ if not message_ids:
446
+ return {"ok": True, "marked": 0}
382
447
  now = datetime.now(timezone.utc).isoformat()
383
- for msg_id in message_ids:
384
- # Check if this is a direct message or broadcast/project
385
- row = conn.execute(
386
- "SELECT target_type FROM mesh_messages WHERE id=?", (msg_id,),
387
- ).fetchone()
388
- if not row:
389
- continue
390
- if row["target_type"] == "peer":
391
- # Direct: update read flag on the message itself
392
- conn.execute(
393
- "UPDATE mesh_messages SET read=1 WHERE id=? AND to_peer=?",
394
- (msg_id, peer_id),
395
- )
396
- else:
397
- # Broadcast/project: insert into mesh_reads
398
- conn.execute(
399
- "INSERT OR IGNORE INTO mesh_reads (message_id, peer_id, read_at) "
400
- "VALUES (?, ?, ?)",
401
- (msg_id, peer_id, now),
402
- )
448
+ ph = ",".join("?" * len(message_ids))
449
+ # One batched read of target types (tenant-scoped), then batched
450
+ # writes — was 2N round-trips per N messages.
451
+ rows = conn.execute(
452
+ f"SELECT id, target_type FROM mesh_messages "
453
+ f"WHERE id IN ({ph}) AND profile_id=?",
454
+ (*message_ids, profile_id),
455
+ ).fetchall()
456
+ direct_ids = [r["id"] for r in rows if r["target_type"] == "peer"]
457
+ shared_ids = [r["id"] for r in rows if r["target_type"] != "peer"]
458
+ if direct_ids:
459
+ dph = ",".join("?" * len(direct_ids))
460
+ conn.execute(
461
+ f"UPDATE mesh_messages SET read=1 "
462
+ f"WHERE id IN ({dph}) AND to_peer=? AND profile_id=?",
463
+ (*direct_ids, peer_id, profile_id),
464
+ )
465
+ if shared_ids:
466
+ conn.executemany(
467
+ "INSERT OR IGNORE INTO mesh_reads (message_id, peer_id, read_at) "
468
+ "VALUES (?, ?, ?)",
469
+ [(mid, peer_id, now) for mid in shared_ids],
470
+ )
403
471
  conn.commit()
404
472
  return {"ok": True, "marked": len(message_ids)}
405
473
 
@@ -407,32 +475,40 @@ class MeshBroker:
407
475
 
408
476
  # -- State --
409
477
 
410
- def get_state(self) -> dict:
478
+ def get_state(self, profile_id: str = "default") -> dict:
411
479
  conn = self._conn()
412
480
  try:
413
- rows = conn.execute("SELECT key, value, set_by, updated_at FROM mesh_state").fetchall()
481
+ rows = conn.execute(
482
+ "SELECT key, value, set_by, updated_at FROM mesh_state WHERE profile_id=?",
483
+ (profile_id,),
484
+ ).fetchall()
414
485
  return {r["key"]: {"value": r["value"], "set_by": r["set_by"], "updated_at": r["updated_at"]} for r in rows}
415
486
  finally:
416
487
  conn.close()
417
488
 
418
- def set_state(self, key: str, value: str, set_by: str) -> dict:
489
+ def set_state(self, key: str, value: str, set_by: str,
490
+ profile_id: str = "default") -> dict:
419
491
  def _set_state(conn: sqlite3.Connection) -> dict:
420
492
  now = datetime.now(timezone.utc).isoformat()
421
493
  conn.execute(
422
- "INSERT INTO mesh_state (key, value, set_by, updated_at) VALUES (?, ?, ?, ?) "
423
- "ON CONFLICT(key) DO UPDATE SET value=excluded.value, set_by=excluded.set_by, updated_at=excluded.updated_at",
424
- (key, value, set_by, now),
494
+ "INSERT INTO mesh_state (profile_id, key, value, set_by, updated_at) "
495
+ "VALUES (?, ?, ?, ?, ?) "
496
+ "ON CONFLICT(profile_id, key) DO UPDATE SET value=excluded.value, "
497
+ "set_by=excluded.set_by, updated_at=excluded.updated_at",
498
+ (profile_id, key, value, set_by, now),
425
499
  )
426
500
  conn.commit()
427
501
  return {"ok": True}
428
502
 
429
503
  return self._write_with_retry(_set_state)
430
504
 
431
- def get_state_key(self, key: str) -> dict | None:
505
+ def get_state_key(self, key: str, profile_id: str = "default") -> dict | None:
432
506
  conn = self._conn()
433
507
  try:
434
508
  row = conn.execute(
435
- "SELECT key, value, set_by, updated_at FROM mesh_state WHERE key=?", (key,),
509
+ "SELECT key, value, set_by, updated_at FROM mesh_state "
510
+ "WHERE profile_id=? AND key=?",
511
+ (profile_id, key),
436
512
  ).fetchone()
437
513
  return dict(row) if row else None
438
514
  finally:
@@ -440,13 +516,15 @@ class MeshBroker:
440
516
 
441
517
  # -- Locks --
442
518
 
443
- def lock_action(self, file_path: str, locked_by: str, action: str) -> dict:
519
+ def lock_action(self, file_path: str, locked_by: str, action: str,
520
+ profile_id: str = "default") -> dict:
444
521
  if action == "query":
445
522
  conn = self._conn()
446
523
  try:
447
524
  row = conn.execute(
448
- "SELECT locked_by, locked_at FROM mesh_locks WHERE file_path=?",
449
- (file_path,),
525
+ "SELECT locked_by, locked_at FROM mesh_locks "
526
+ "WHERE profile_id=? AND file_path=?",
527
+ (profile_id, file_path),
450
528
  ).fetchone()
451
529
  if row:
452
530
  return {"locked": True, "by": row["locked_by"], "since": row["locked_at"]}
@@ -462,26 +540,40 @@ class MeshBroker:
462
540
 
463
541
  if action == "acquire":
464
542
  existing = conn.execute(
465
- "SELECT locked_by, locked_at FROM mesh_locks WHERE file_path=?",
466
- (file_path,),
543
+ "SELECT locked_by, locked_at, expires_at FROM mesh_locks "
544
+ "WHERE profile_id=? AND file_path=?",
545
+ (profile_id, file_path),
467
546
  ).fetchone()
547
+ # M-02: another peer's lock only blocks while it is still live.
548
+ # An expired lock — or a legacy never-expires sentinel left by a
549
+ # crashed session — is treated as free, so a stale lock can never
550
+ # deadlock a path forever.
468
551
  if existing and existing["locked_by"] != locked_by:
469
- return {"locked": True, "by": existing["locked_by"], "since": existing["locked_at"]}
552
+ exp = existing["expires_at"]
553
+ still_live = bool(exp) and exp != _NEVER_EXPIRES and exp > now
554
+ if still_live:
555
+ return {"locked": True, "by": existing["locked_by"],
556
+ "since": existing["locked_at"]}
557
+ lock_expires = (
558
+ datetime.fromisoformat(now) + timedelta(hours=LOCK_TTL_HOURS)
559
+ ).isoformat()
470
560
  conn.execute(
471
- "INSERT INTO mesh_locks (file_path, locked_by, locked_at) VALUES (?, ?, ?) "
472
- "ON CONFLICT(file_path) DO UPDATE SET locked_by=excluded.locked_by, locked_at=excluded.locked_at",
473
- (file_path, locked_by, now),
561
+ "INSERT INTO mesh_locks (profile_id, file_path, locked_by, locked_at, expires_at) "
562
+ "VALUES (?, ?, ?, ?, ?) "
563
+ "ON CONFLICT(profile_id, file_path) DO UPDATE SET locked_by=excluded.locked_by, "
564
+ "locked_at=excluded.locked_at, expires_at=excluded.expires_at",
565
+ (profile_id, file_path, locked_by, now, lock_expires),
474
566
  )
475
567
  conn.commit()
476
- return {"ok": True, "action": "acquired"}
568
+ return {"ok": True, "action": "acquired", "expires_at": lock_expires}
477
569
 
478
570
  elif action == "release":
479
571
  # v3.6.12 (mesh-2): report whether we actually released. The
480
572
  # DELETE is correctly owner-scoped, but it previously returned
481
573
  # released=ok:true even when a NON-owner released nothing.
482
574
  cur = conn.execute(
483
- "DELETE FROM mesh_locks WHERE file_path=? AND locked_by=?",
484
- (file_path, locked_by),
575
+ "DELETE FROM mesh_locks WHERE profile_id=? AND file_path=? AND locked_by=?",
576
+ (profile_id, file_path, locked_by),
485
577
  )
486
578
  conn.commit()
487
579
  if cur.rowcount and cur.rowcount > 0:
@@ -503,59 +595,67 @@ class MeshBroker:
503
595
  return (now + timedelta(hours=MESSAGE_TTL_HOURS)).isoformat()
504
596
 
505
597
  def _get_pending_for_peer(self, conn: sqlite3.Connection,
506
- peer_id: str, project_path: str) -> list[dict]:
507
- """Get unread broadcast/project messages for a newly registered peer."""
598
+ peer_id: str, project_path: str,
599
+ profile_id: str = "default") -> list[dict]:
600
+ """Get unread broadcast/project messages for a newly registered peer
601
+ within this tenant."""
508
602
  now = datetime.now(timezone.utc).isoformat()
509
603
  rows = conn.execute(
510
604
  "SELECT m.id, m.from_peer, m.content, m.target_type, m.project_path, m.created_at "
511
605
  "FROM mesh_messages m "
512
606
  "LEFT JOIN mesh_reads r ON m.id = r.message_id AND r.peer_id = ? "
513
- "WHERE r.peer_id IS NULL AND m.from_peer != ? "
607
+ "WHERE m.profile_id = ? AND r.peer_id IS NULL AND m.from_peer != ? "
514
608
  "AND (m.expires_at IS NULL OR m.expires_at > ?) "
515
609
  "AND (m.target_type = 'broadcast' "
516
610
  " OR (m.target_type = 'project' AND m.project_path = ?)) "
517
611
  "ORDER BY m.created_at DESC LIMIT 50",
518
- (peer_id, peer_id, now, project_path),
612
+ (peer_id, profile_id, peer_id, now, project_path),
519
613
  ).fetchall()
520
614
  return [dict(r) for r in rows]
521
615
 
522
- def get_pending(self, peer_id: str, project_path: str = "") -> list[dict]:
616
+ def get_pending(self, peer_id: str, project_path: str = "",
617
+ profile_id: str = "default") -> list[dict]:
523
618
  """Public API to get pending broadcast/project messages."""
524
619
  conn = self._conn()
525
620
  try:
526
- return self._get_pending_for_peer(conn, peer_id, project_path)
621
+ return self._get_pending_for_peer(conn, peer_id, project_path, profile_id)
527
622
  finally:
528
623
  conn.close()
529
624
 
530
625
  # -- Events --
531
626
 
532
- def get_events(self, limit: int = 100) -> list[dict]:
627
+ def get_events(self, limit: int = 100, profile_id: str = "default") -> list[dict]:
533
628
  conn = self._conn()
534
629
  try:
535
630
  rows = conn.execute(
536
631
  "SELECT id, event_type, payload, emitted_by, created_at "
537
- "FROM mesh_events ORDER BY id DESC LIMIT ?",
538
- (limit,),
632
+ "FROM mesh_events WHERE profile_id=? ORDER BY id DESC LIMIT ?",
633
+ (profile_id, limit),
539
634
  ).fetchall()
540
635
  return [dict(r) for r in rows]
541
636
  finally:
542
637
  conn.close()
543
638
 
544
639
  def _log_event(self, conn: sqlite3.Connection, event_type: str,
545
- emitted_by: str, payload: dict | None = None) -> None:
640
+ emitted_by: str, payload: dict | None = None,
641
+ profile_id: str = "default") -> None:
546
642
  import json as _json
547
643
  now = datetime.now(timezone.utc).isoformat()
548
644
  conn.execute(
549
- "INSERT INTO mesh_events (event_type, payload, emitted_by, created_at) VALUES (?, ?, ?, ?)",
550
- (event_type, _json.dumps(payload or {}), emitted_by, now),
645
+ "INSERT INTO mesh_events (event_type, payload, emitted_by, created_at, profile_id) "
646
+ "VALUES (?, ?, ?, ?, ?)",
647
+ (event_type, _json.dumps(payload or {}), emitted_by, now, profile_id),
551
648
  )
552
649
 
553
650
  # -- Status --
554
651
 
555
- def get_status(self) -> dict:
652
+ def get_status(self, profile_id: str = "default") -> dict:
556
653
  conn = self._conn()
557
654
  try:
558
- peer_count = conn.execute("SELECT COUNT(*) FROM mesh_peers WHERE status='active'").fetchone()[0]
655
+ peer_count = conn.execute(
656
+ "SELECT COUNT(*) FROM mesh_peers WHERE profile_id=? AND status='active'",
657
+ (profile_id,),
658
+ ).fetchone()[0]
559
659
  return {
560
660
  "broker_up": True,
561
661
  "peer_count": peer_count,
@@ -579,31 +679,39 @@ class MeshBroker:
579
679
 
580
680
  def _run_cleanup(self) -> None:
581
681
  def _cleanup(conn: sqlite3.Connection) -> None:
682
+ # Precompute ISO cutoffs in Python and compare the stored ISO strings
683
+ # directly. ISO-8601 UTC timestamps sort lexicographically, so a bare
684
+ # `col < ?` is both correct AND sargable — the datetime() wrapper
685
+ # previously forced a full table scan on every 5-minute cleanup.
582
686
  now = datetime.now(timezone.utc)
583
687
  now_iso = now.isoformat()
688
+ five_min = (now - timedelta(minutes=5)).isoformat()
689
+ thirty_min = (now - timedelta(minutes=30)).isoformat()
690
+ day_ago = (now - timedelta(hours=24)).isoformat()
691
+ week_ago = (now - timedelta(days=7)).isoformat()
584
692
  # Mark stale peers (no heartbeat for 5 min)
585
693
  conn.execute(
586
694
  "UPDATE mesh_peers SET status='stale' "
587
- "WHERE status='active' AND datetime(last_heartbeat) < datetime(?, '-5 minutes')",
588
- (now_iso,),
695
+ "WHERE status='active' AND last_heartbeat < ?",
696
+ (five_min,),
589
697
  )
590
698
  # Delete dead peers (stale > 30 min)
591
699
  conn.execute(
592
700
  "UPDATE mesh_peers SET status='dead' "
593
- "WHERE status='stale' AND datetime(last_heartbeat) < datetime(?, '-30 minutes')",
594
- (now_iso,),
701
+ "WHERE status='stale' AND last_heartbeat < ?",
702
+ (thirty_min,),
595
703
  )
596
704
  conn.execute("DELETE FROM mesh_peers WHERE status='dead'")
597
705
  # Delete read direct messages > 24hr old
598
706
  conn.execute(
599
707
  "DELETE FROM mesh_messages WHERE target_type='peer' AND read=1 "
600
- "AND datetime(created_at) < datetime(?, '-24 hours')",
601
- (now_iso,),
708
+ "AND created_at < ?",
709
+ (day_ago,),
602
710
  )
603
711
  # v3.4.6: Delete EXPIRED messages (48h TTL for broadcast/project)
604
712
  conn.execute(
605
713
  "DELETE FROM mesh_messages WHERE expires_at IS NOT NULL "
606
- "AND datetime(expires_at) < datetime(?)",
714
+ "AND expires_at < ?",
607
715
  (now_iso,),
608
716
  )
609
717
  # v3.4.6: Clean up orphaned mesh_reads entries
@@ -611,15 +719,15 @@ class MeshBroker:
611
719
  "DELETE FROM mesh_reads WHERE message_id NOT IN "
612
720
  "(SELECT id FROM mesh_messages)",
613
721
  )
614
- # Delete expired locks
722
+ # Delete expired locks (the 9999-… sentinel sorts after any real now)
615
723
  conn.execute(
616
- "DELETE FROM mesh_locks WHERE datetime(expires_at) < datetime(?)",
724
+ "DELETE FROM mesh_locks WHERE expires_at < ?",
617
725
  (now_iso,),
618
726
  )
619
727
  # v3.4.6: Delete old events (keep last 7 days)
620
728
  conn.execute(
621
- "DELETE FROM mesh_events WHERE datetime(created_at) < datetime(?, '-7 days')",
622
- (now_iso,),
729
+ "DELETE FROM mesh_events WHERE created_at < ?",
730
+ (week_ago,),
623
731
  )
624
732
  conn.commit()
625
733