superlocalmemory 3.8.13 → 4.0.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 (212) hide show
  1. package/ATTRIBUTION.md +4 -4
  2. package/CHANGELOG.md +113 -121
  3. package/README.md +65 -63
  4. package/docs/pi-dev-integration.md +1 -1
  5. package/package.json +6 -1
  6. package/plugin/.claude-plugin/plugin.json +1 -1
  7. package/plugin/CLAUDE.md +3 -3
  8. package/plugin/agents/slm-governance-advisor.md +1 -1
  9. package/plugin/agents/slm-loop-runner.md +1 -1
  10. package/plugin/agents/slm-memory-advisor.md +1 -1
  11. package/plugin/agents/slm-optimize-advisor.md +1 -1
  12. package/plugin/requirements.txt +1 -1
  13. package/plugin/skills/slm-cache/SKILL.md +1 -1
  14. package/plugin/skills/slm-compress/SKILL.md +1 -1
  15. package/plugin/skills/slm-governance/SKILL.md +1 -1
  16. package/plugin/skills/slm-graph/SKILL.md +1 -1
  17. package/plugin/skills/slm-loop/SKILL.md +1 -1
  18. package/plugin/skills/slm-mesh/SKILL.md +1 -1
  19. package/plugin/skills/slm-profile/SKILL.md +1 -1
  20. package/plugin/skills/slm-recall/SKILL.md +1 -1
  21. package/plugin/skills/slm-remember/SKILL.md +1 -1
  22. package/plugin/skills/slm-scope/SKILL.md +1 -1
  23. package/plugin/skills/slm-session/SKILL.md +1 -1
  24. package/plugin/skills/slm-status/SKILL.md +1 -1
  25. package/plugin-src/rules/AGENTS.md +1 -1
  26. package/plugin-src/skills/slm-cache/SKILL.md +1 -1
  27. package/plugin-src/skills/slm-compress/SKILL.md +1 -1
  28. package/plugin-src/skills/slm-governance/SKILL.md +248 -0
  29. package/plugin-src/skills/slm-graph/SKILL.md +1 -1
  30. package/plugin-src/skills/slm-loop/SKILL.md +99 -0
  31. package/plugin-src/skills/slm-mesh/SKILL.md +282 -0
  32. package/plugin-src/skills/slm-profile/SKILL.md +148 -0
  33. package/plugin-src/skills/slm-recall/SKILL.md +1 -1
  34. package/plugin-src/skills/slm-remember/SKILL.md +1 -1
  35. package/plugin-src/skills/slm-scope/SKILL.md +176 -0
  36. package/plugin-src/skills/slm-session/SKILL.md +1 -1
  37. package/plugin-src/skills/slm-status/SKILL.md +1 -1
  38. package/pyproject.toml +11 -4
  39. package/src/superlocalmemory/__init__.py +1 -1
  40. package/src/superlocalmemory/cli/commands.py +125 -11
  41. package/src/superlocalmemory/cli/daemon.py +5 -1
  42. package/src/superlocalmemory/cli/main.py +35 -2
  43. package/src/superlocalmemory/cli/ops_cmd.py +281 -0
  44. package/src/superlocalmemory/cli/setup_wizard.py +1 -1
  45. package/src/superlocalmemory/compliance/audit.py +65 -0
  46. package/src/superlocalmemory/compliance/eu_ai_act.py +27 -57
  47. package/src/superlocalmemory/compliance/gdpr.py +416 -20
  48. package/src/superlocalmemory/compliance/retention.py +74 -22
  49. package/src/superlocalmemory/compliance/scheduler.py +78 -9
  50. package/src/superlocalmemory/core/actor_context.py +166 -0
  51. package/src/superlocalmemory/core/admission.py +549 -0
  52. package/src/superlocalmemory/core/backend_orchestrator.py +23 -10
  53. package/src/superlocalmemory/core/config.py +202 -24
  54. package/src/superlocalmemory/core/consolidation_engine.py +13 -13
  55. package/src/superlocalmemory/core/context_cache.py +28 -0
  56. package/src/superlocalmemory/core/embeddings.py +64 -2
  57. package/src/superlocalmemory/core/engine.py +7 -2
  58. package/src/superlocalmemory/core/engine_ingestion.py +65 -3
  59. package/src/superlocalmemory/core/engine_wiring.py +36 -9
  60. package/src/superlocalmemory/core/ingest_policy.py +38 -0
  61. package/src/superlocalmemory/core/maintenance.py +255 -0
  62. package/src/superlocalmemory/core/modes.py +40 -13
  63. package/src/superlocalmemory/core/mutations.py +437 -44
  64. package/src/superlocalmemory/core/operation_policy.py +92 -0
  65. package/src/superlocalmemory/core/operation_policy_registry.py +542 -0
  66. package/src/superlocalmemory/core/operation_request.py +127 -0
  67. package/src/superlocalmemory/core/ops_remediation.py +542 -0
  68. package/src/superlocalmemory/core/recall_pipeline.py +7 -0
  69. package/src/superlocalmemory/core/remember_runtime.py +202 -4
  70. package/src/superlocalmemory/core/remote_mode.py +20 -5
  71. package/src/superlocalmemory/core/store_pipeline.py +150 -0
  72. package/src/superlocalmemory/core/topic_signature.py +19 -4
  73. package/src/superlocalmemory/core/transactions/__init__.py +78 -0
  74. package/src/superlocalmemory/core/transactions/concrete_owners.py +597 -0
  75. package/src/superlocalmemory/core/transactions/erasure.py +825 -0
  76. package/src/superlocalmemory/core/transactions/manifest.py +255 -0
  77. package/src/superlocalmemory/core/transactions/manifest_key.py +155 -0
  78. package/src/superlocalmemory/core/transactions/obligations.py +272 -0
  79. package/src/superlocalmemory/core/transactions/owners.py +114 -0
  80. package/src/superlocalmemory/core/transactions/reconciler.py +285 -0
  81. package/src/superlocalmemory/core/transactions/service.py +330 -0
  82. package/src/superlocalmemory/core/worker_pool.py +33 -5
  83. package/src/superlocalmemory/encoding/cognitive_consolidator.py +70 -28
  84. package/src/superlocalmemory/encoding/emotional.py +75 -14
  85. package/src/superlocalmemory/encoding/scene_builder.py +115 -13
  86. package/src/superlocalmemory/encoding/temporal_parser.py +4 -0
  87. package/src/superlocalmemory/evolution/blind_verifier.py +11 -4
  88. package/src/superlocalmemory/evolution/evolution_store.py +244 -4
  89. package/src/superlocalmemory/evolution/llm_dispatch.py +40 -0
  90. package/src/superlocalmemory/evolution/model_selection.py +18 -3
  91. package/src/superlocalmemory/evolution/mutation_generator.py +3 -0
  92. package/src/superlocalmemory/evolution/skill_activator.py +270 -0
  93. package/src/superlocalmemory/evolution/skill_evolver.py +281 -59
  94. package/src/superlocalmemory/evolution/types.py +30 -8
  95. package/src/superlocalmemory/graph/cozo_backend.py +17 -9
  96. package/src/superlocalmemory/hooks/auto_invoker.py +2 -1
  97. package/src/superlocalmemory/hooks/auto_recall.py +64 -30
  98. package/src/superlocalmemory/hooks/codex_assets.py +14 -1
  99. package/src/superlocalmemory/infra/backup.py +434 -7
  100. package/src/superlocalmemory/infra/process_reaper.py +18 -0
  101. package/src/superlocalmemory/infra/self_heal.py +401 -0
  102. package/src/superlocalmemory/learning/feedback.py +52 -9
  103. package/src/superlocalmemory/loops/engine.py +10 -0
  104. package/src/superlocalmemory/mcp/_daemon_proxy.py +3 -0
  105. package/src/superlocalmemory/mcp/http_transport.py +30 -331
  106. package/src/superlocalmemory/mcp/profiles.py +5 -0
  107. package/src/superlocalmemory/mcp/resources.py +8 -0
  108. package/src/superlocalmemory/mcp/server.py +51 -4
  109. package/src/superlocalmemory/mcp/shared.py +19 -0
  110. package/src/superlocalmemory/mcp/tools_active.py +25 -4
  111. package/src/superlocalmemory/mcp/tools_code_graph.py +26 -18
  112. package/src/superlocalmemory/mcp/tools_context.py +50 -8
  113. package/src/superlocalmemory/mcp/tools_core.py +69 -21
  114. package/src/superlocalmemory/mcp/tools_evolution.py +9 -2
  115. package/src/superlocalmemory/mcp/tools_learning.py +21 -10
  116. package/src/superlocalmemory/mcp/tools_loops.py +29 -18
  117. package/src/superlocalmemory/mcp/tools_mesh.py +8 -0
  118. package/src/superlocalmemory/mcp/tools_ops.py +115 -0
  119. package/src/superlocalmemory/mcp/tools_optimize.py +4 -0
  120. package/src/superlocalmemory/mcp/tools_v28.py +10 -3
  121. package/src/superlocalmemory/mcp/tools_v3.py +34 -14
  122. package/src/superlocalmemory/mcp/tools_v33.py +18 -33
  123. package/src/superlocalmemory/mesh/broker.py +124 -46
  124. package/src/superlocalmemory/mesh/broker_security.py +470 -0
  125. package/src/superlocalmemory/mesh/discovery.py +365 -0
  126. package/src/superlocalmemory/mesh/lock_protocol.py +313 -0
  127. package/src/superlocalmemory/mesh/node_identity.py +97 -0
  128. package/src/superlocalmemory/mesh/outbox_remote.py +429 -0
  129. package/src/superlocalmemory/mesh/remote_sync.py +511 -28
  130. package/src/superlocalmemory/mesh/state_sync.py +286 -0
  131. package/src/superlocalmemory/optimize/config/store.py +45 -0
  132. package/src/superlocalmemory/parameterization/cross_project.py +12 -0
  133. package/src/superlocalmemory/parameterization/prompt_injector.py +13 -11
  134. package/src/superlocalmemory/parameterization/prompt_lifecycle.py +8 -2
  135. package/src/superlocalmemory/parameterization/workflow_miner.py +17 -0
  136. package/src/superlocalmemory/retrieval/ann_index.py +5 -0
  137. package/src/superlocalmemory/retrieval/bm25_channel.py +49 -2
  138. package/src/superlocalmemory/retrieval/engine.py +19 -4
  139. package/src/superlocalmemory/retrieval/fusion.py +4 -1
  140. package/src/superlocalmemory/retrieval/hopfield_channel.py +9 -3
  141. package/src/superlocalmemory/retrieval/remote_reranker.py +47 -22
  142. package/src/superlocalmemory/retrieval/reranker.py +32 -1
  143. package/src/superlocalmemory/retrieval/temporal_channel.py +16 -3
  144. package/src/superlocalmemory/retrieval/temporal_utils.py +107 -0
  145. package/src/superlocalmemory/retrieval/temporal_validity_filter.py +155 -42
  146. package/src/superlocalmemory/retrieval/vector_store.py +214 -8
  147. package/src/superlocalmemory/server/api.py +5 -5
  148. package/src/superlocalmemory/server/egress_policy.py +258 -0
  149. package/src/superlocalmemory/server/rbac_enforce.py +32 -0
  150. package/src/superlocalmemory/server/route_mutations.py +20 -0
  151. package/src/superlocalmemory/server/routes/compliance.py +153 -7
  152. package/src/superlocalmemory/server/routes/data_io.py +43 -2
  153. package/src/superlocalmemory/server/routes/events.py +15 -0
  154. package/src/superlocalmemory/server/routes/memories.py +56 -3
  155. package/src/superlocalmemory/server/routes/mesh.py +82 -1
  156. package/src/superlocalmemory/server/routes/mesh_lock.py +54 -0
  157. package/src/superlocalmemory/server/routes/mesh_state.py +63 -0
  158. package/src/superlocalmemory/server/routes/v3_api.py +50 -27
  159. package/src/superlocalmemory/server/routes/ws.py +86 -0
  160. package/src/superlocalmemory/server/ui.py +6 -6
  161. package/src/superlocalmemory/server/unified_daemon.py +942 -119
  162. package/src/superlocalmemory/storage/_migration_internals.py +568 -0
  163. package/src/superlocalmemory/storage/_schema_version.py +110 -0
  164. package/src/superlocalmemory/storage/database.py +329 -24
  165. package/src/superlocalmemory/storage/embedding_migrator.py +246 -51
  166. package/src/superlocalmemory/storage/erasure_fence.py +45 -0
  167. package/src/superlocalmemory/storage/generation_fence.py +63 -0
  168. package/src/superlocalmemory/storage/migration_runner.py +140 -417
  169. package/src/superlocalmemory/storage/migrations/M009_model_lineage.py +40 -0
  170. package/src/superlocalmemory/storage/migrations/M033_projection_transactions.py +148 -0
  171. package/src/superlocalmemory/storage/migrations/M034_obligation_integrity.py +58 -0
  172. package/src/superlocalmemory/storage/migrations/M035_erasure_receipts.py +113 -0
  173. package/src/superlocalmemory/storage/migrations/M036_vector_row_map.py +107 -0
  174. package/src/superlocalmemory/storage/migrations/M037_manifest_hmac_version.py +162 -0
  175. package/src/superlocalmemory/storage/migrations/{M033_learning_feedback_channel.py → M038_learning_feedback_channel.py} +3 -3
  176. package/src/superlocalmemory/storage/migrations/M039_scene_fact_members.py +137 -0
  177. package/src/superlocalmemory/storage/migrations/__init__.py +4 -2
  178. package/src/superlocalmemory/storage/schema.py +67 -0
  179. package/src/superlocalmemory/storage/write_coordinator.py +125 -0
  180. package/src/superlocalmemory/trust/scorer.py +28 -4
  181. package/src/superlocalmemory/ui/index.html +14 -3
  182. package/src/superlocalmemory/ui/js/auto-settings.js +12 -1
  183. package/src/superlocalmemory/ui/js/brain.js +6 -4
  184. package/src/superlocalmemory/ui/js/compliance.js +66 -12
  185. package/src/superlocalmemory/ui/js/dashboard.js +13 -3
  186. package/src/superlocalmemory/ui/js/feedback.js +8 -2
  187. package/src/superlocalmemory/ui/js/lifecycle.js +7 -1
  188. package/src/superlocalmemory/ui/js/modal.js +272 -5
  189. package/src/superlocalmemory/ui/js/od-backup.js +9 -2
  190. package/src/superlocalmemory/ui/js/od-compliance-ext.js +301 -0
  191. package/src/superlocalmemory/ui/js/od-operations.js +154 -23
  192. package/src/superlocalmemory/ui/js/od-ops-health.js +417 -0
  193. package/src/superlocalmemory/ui/js/od-optimize.js +35 -21
  194. package/src/superlocalmemory/ui/js/od-team.js +9 -2
  195. package/src/superlocalmemory/ui/js/optimize.js +13 -16
  196. package/src/superlocalmemory/ui/js/profiles.js +7 -3
  197. package/src/superlocalmemory/ui/js/settings.js +7 -1
  198. package/src/superlocalmemory/vector/lancedb_backend.py +19 -9
  199. package/src/superlocalmemory/attribution/mathematical_dna.py +0 -235
  200. package/src/superlocalmemory/cli/post_install.py +0 -114
  201. package/src/superlocalmemory/core/clock_monitor.py +0 -45
  202. package/src/superlocalmemory/core/db_pool.py +0 -80
  203. package/src/superlocalmemory/core/error_catalog.py +0 -113
  204. package/src/superlocalmemory/core/loop_watchdog.py +0 -56
  205. package/src/superlocalmemory/core/priority_queue.py +0 -61
  206. package/src/superlocalmemory/core/pruning_engine.py +0 -216
  207. package/src/superlocalmemory/core/queue_dispatcher.py +0 -73
  208. package/src/superlocalmemory/core/slmignore.py +0 -125
  209. package/src/superlocalmemory/infra/heartbeat_monitor.py +0 -140
  210. package/src/superlocalmemory/infra/webhook_dispatcher.py +0 -247
  211. package/src/superlocalmemory/learning/quantization_scheduler.py +0 -320
  212. package/src/superlocalmemory/storage/access_control.py +0 -182
@@ -23,6 +23,8 @@ from mcp.types import ToolAnnotations
23
23
 
24
24
  logger = logging.getLogger(__name__)
25
25
 
26
+ from superlocalmemory.core.admission import admits
27
+ from superlocalmemory.core.operation_request import OperationKind
26
28
  from superlocalmemory.infra.data_root import state_path
27
29
  from superlocalmemory.mcp.shared import authorize_mcp_mutation
28
30
 
@@ -75,8 +77,8 @@ def register_v33_tools(server, get_engine: Callable) -> None:
75
77
  # 1. forget — Ebbinghaus forgetting decay cycle
76
78
  # ------------------------------------------------------------------
77
79
  @server.tool(annotations=ToolAnnotations(destructiveHint=True))
80
+ @admits(OperationKind.FORGET)
78
81
  async def forget(
79
- profile_id: str = "",
80
82
  dry_run: bool = True,
81
83
  ) -> dict:
82
84
  """Run Ebbinghaus forgetting decay cycle.
@@ -88,12 +90,11 @@ def register_v33_tools(server, get_engine: Callable) -> None:
88
90
  Run with dry_run=True first to preview changes.
89
91
 
90
92
  Args:
91
- profile_id: Profile to process (default: active profile).
92
93
  dry_run: If True, compute stats but don't apply transitions.
93
94
  """
94
95
  try:
95
96
  engine = get_engine()
96
- pid = profile_id or engine.profile_id
97
+ pid = engine.profile_id
97
98
 
98
99
  from superlocalmemory.math.ebbinghaus import EbbinghausCurve
99
100
  from superlocalmemory.learning.forgetting_scheduler import (
@@ -147,8 +148,8 @@ def register_v33_tools(server, get_engine: Callable) -> None:
147
148
  # 2. quantize — EAP embedding quantization cycle
148
149
  # ------------------------------------------------------------------
149
150
  @server.tool()
151
+ @admits(OperationKind.CONSOLIDATE)
150
152
  async def quantize(
151
- profile_id: str = "",
152
153
  dry_run: bool = True,
153
154
  ) -> dict:
154
155
  """Run EAP quantization cycle.
@@ -160,12 +161,11 @@ def register_v33_tools(server, get_engine: Callable) -> None:
160
161
  Run with dry_run=True first to preview changes.
161
162
 
162
163
  Args:
163
- profile_id: Profile to process (default: active profile).
164
164
  dry_run: If True, compute stats but don't apply changes.
165
165
  """
166
166
  try:
167
167
  engine = get_engine()
168
- pid = profile_id or engine.profile_id
168
+ pid = engine.profile_id
169
169
 
170
170
  from superlocalmemory.math.ebbinghaus import EbbinghausCurve
171
171
  from superlocalmemory.math.polar_quant import PolarQuantEncoder
@@ -218,21 +218,17 @@ def register_v33_tools(server, get_engine: Callable) -> None:
218
218
  # 3. consolidate_cognitive — CCQ cognitive consolidation
219
219
  # ------------------------------------------------------------------
220
220
  @server.tool()
221
- async def consolidate_cognitive(
222
- profile_id: str = "",
223
- ) -> dict:
221
+ @admits(OperationKind.CONSOLIDATE)
222
+ async def consolidate_cognitive() -> dict:
224
223
  """Run CCQ cognitive consolidation pipeline.
225
224
 
226
225
  Extracts patterns from cold/archive memories by clustering
227
226
  related facts, generating gist summaries, and compressing
228
227
  source embeddings. Like sleep-time memory consolidation.
229
-
230
- Args:
231
- profile_id: Profile to process (default: active profile).
232
228
  """
233
229
  try:
234
230
  engine = get_engine()
235
- pid = profile_id or engine.profile_id
231
+ pid = engine.profile_id
236
232
 
237
233
  # v3.4.26: prefer the daemon's /consolidate/cognitive endpoint
238
234
  # so the heavy CognitiveConsolidator import stays out of the
@@ -292,21 +288,16 @@ def register_v33_tools(server, get_engine: Callable) -> None:
292
288
  # 4. get_soft_prompts — Retrieve active soft prompts
293
289
  # ------------------------------------------------------------------
294
290
  @server.tool(annotations=ToolAnnotations(readOnlyHint=True))
295
- async def get_soft_prompts(
296
- profile_id: str = "",
297
- ) -> dict:
291
+ async def get_soft_prompts() -> dict:
298
292
  """Get active soft prompts (auto-learned user patterns).
299
293
 
300
294
  Returns soft prompt templates generated from behavioral
301
295
  patterns. These are injected into conversation context to
302
296
  personalize AI responses.
303
-
304
- Args:
305
- profile_id: Profile to query (default: active profile).
306
297
  """
307
298
  try:
308
299
  engine = get_engine()
309
- pid = profile_id or engine.profile_id
300
+ pid = engine.profile_id
310
301
 
311
302
  rows = engine._db.execute(
312
303
  "SELECT prompt_id, category, content, confidence, "
@@ -346,6 +337,7 @@ def register_v33_tools(server, get_engine: Callable) -> None:
346
337
  # 5. reap_processes — Find and kill orphaned SLM processes
347
338
  # ------------------------------------------------------------------
348
339
  @server.tool()
340
+ @admits(OperationKind.CONSOLIDATE)
349
341
  async def reap_processes(
350
342
  dry_run: bool = True,
351
343
  ) -> dict:
@@ -394,22 +386,17 @@ def register_v33_tools(server, get_engine: Callable) -> None:
394
386
  # ------------------------------------------------------------------
395
387
  # 6. get_retention_stats — Memory retention zone distribution
396
388
  # ------------------------------------------------------------------
397
- @server.tool()
398
- async def get_retention_stats(
399
- profile_id: str = "",
400
- ) -> dict:
389
+ @server.tool(annotations=ToolAnnotations(readOnlyHint=True))
390
+ async def get_retention_stats() -> dict:
401
391
  """Get memory retention statistics (zone distribution, decay rates).
402
392
 
403
393
  Queries the fact_retention table for zone counts and average
404
394
  retention scores per zone. Shows how memories are distributed
405
395
  across the Ebbinghaus decay lifecycle.
406
-
407
- Args:
408
- profile_id: Profile to query (default: active profile).
409
396
  """
410
397
  try:
411
398
  engine = get_engine()
412
- pid = profile_id or engine.profile_id
399
+ pid = engine.profile_id
413
400
 
414
401
  # Zone distribution counts
415
402
  rows = engine._db.execute(
@@ -451,19 +438,17 @@ def register_v33_tools(server, get_engine: Callable) -> None:
451
438
  # 7. run_maintenance — V3.3.12: Combined periodic maintenance cycle
452
439
  # ------------------------------------------------------------------
453
440
  @server.tool()
454
- async def run_maintenance(profile_id: str = "") -> dict:
441
+ @admits(OperationKind.CONSOLIDATE)
442
+ async def run_maintenance() -> dict:
455
443
  """Run all periodic maintenance tasks in a single call.
456
444
 
457
445
  Combines Langevin dynamics stepping, Ebbinghaus forgetting decay,
458
446
  and behavioral pattern mining into one convenient maintenance cycle.
459
447
  Clients should call this periodically (e.g., at session end).
460
-
461
- Args:
462
- profile_id: Profile to maintain (default: active profile).
463
448
  """
464
449
  try:
465
450
  engine = get_engine()
466
- pid = profile_id or engine.profile_id
451
+ pid = engine.profile_id
467
452
 
468
453
  # v3.4.26: prefer the daemon so ForgettingScheduler /
469
454
  # ConsolidationWorker / EbbinghausCurve don't load inside
@@ -24,6 +24,10 @@ from typing import Any, Callable, TypeVar
24
24
  logger = logging.getLogger("superlocalmemory.mesh")
25
25
  import os as _os
26
26
 
27
+ from .broker_security import ( # noqa: E501
28
+ apply_security_schema, check_cross_profile_sender, ensure_db_healthy, get_or_create_peer_key, reject_secret_state, scrub_message_content, seed_fencing_counter, _set_nonce_db_path, validate_lock_fence_query, # noqa: E501
29
+ )
30
+
27
31
  # Remote sync support (optional, try/except to avoid import issues)
28
32
  try:
29
33
  from .remote_sync import RemoteSyncClient
@@ -32,8 +36,6 @@ except ImportError:
32
36
 
33
37
  LOCAL_HOSTS = frozenset({"127.0.0.1", "localhost", "::1"})
34
38
 
35
-
36
-
37
39
  MAX_MESSAGE_SIZE = 4096 # 4KB cap — mesh messages are notifications, not data dumps
38
40
  MESSAGE_TTL_HOURS = 48 # Offline messages expire after 48h
39
41
  LOCK_TTL_HOURS = 8 # M-02: file locks auto-expire so a crashed session can't deadlock a path
@@ -50,13 +52,7 @@ _WRITE_BUSY_TIMEOUT_MS = 2000
50
52
 
51
53
 
52
54
  class MeshBroker:
53
- """Lightweight mesh broker for SLM's unified daemon.
54
-
55
- Provides peer management, messaging, state, locks, and events.
56
- v3.4.6: broadcast, project-based routing, offline message queue.
57
- All methods are synchronous (called from FastAPI via run_in_executor
58
- or directly for quick operations).
59
- """
55
+ """Lightweight mesh broker peer lifecycle, messaging, state, locks, events."""
60
56
 
61
57
  def __init__(self, db_path: str | Path):
62
58
  self._db_path = str(db_path)
@@ -72,11 +68,17 @@ class MeshBroker:
72
68
  self._remote_peers_lock = threading.RLock()
73
69
  self._peer_url: str | None = _os.environ.get("SLM_MESH_PEER_URL", "") or None
74
70
  self._sync_client: Any = None
71
+ self._degraded: bool = False
72
+ self._fencing_lock = threading.Lock()
73
+ self._fencing_counter: int = 0 # seeded below after schema is applied
75
74
  if self._is_remote and not self._shared_secret:
76
75
  raise RuntimeError(
77
76
  "SLM_MESH_SHARED_SECRET is required when SLM_MESH_HOST is not localhost"
78
77
  )
79
-
78
+ self._ensure_db_healthy()
79
+ self._apply_schema_updates()
80
+ _set_nonce_db_path(self._db_path) # SEC-3: wire SQLite nonce store for durability
81
+ self._fencing_counter = seed_fencing_counter(self._db_path) # 3a-3: restart-safe fence
80
82
 
81
83
  # -- Remote / Multi-Machine support (v3.4.47) --
82
84
 
@@ -128,13 +130,31 @@ class MeshBroker:
128
130
  if self._sync_client:
129
131
  self._sync_client.stop()
130
132
 
133
+ # -- Startup safety helpers --
134
+
135
+ def _ensure_db_healthy(self) -> None:
136
+ """Quarantine a corrupt DB; set _degraded if recovery was needed."""
137
+ self._degraded = ensure_db_healthy(self._db_path)
138
+
139
+ def _apply_schema_updates(self) -> None:
140
+ """Apply idempotent schema additions (delegated to broker_security)."""
141
+ conn = self._conn()
142
+ try:
143
+ apply_security_schema(conn)
144
+ except sqlite3.Error as exc:
145
+ logger.debug("mesh schema update skipped: %s", exc)
146
+ finally:
147
+ conn.close()
148
+
149
+ def _next_fencing_token(self) -> int:
150
+ """Return the next monotonically-increasing fencing token."""
151
+ with self._fencing_lock:
152
+ self._fencing_counter += 1
153
+ return self._fencing_counter
154
+
131
155
  # -- Connection helper --
132
156
 
133
157
  def _conn(self) -> sqlite3.Connection:
134
- # WAL is configured during database initialization and persists with the
135
- # database. Reissuing journal_mode=WAL for every short-lived mesh
136
- # connection is itself a schema-level write that can contend with the
137
- # daemon. Mesh writes below use bounded whole-transaction retries.
138
158
  conn = sqlite3.connect(
139
159
  self._db_path,
140
160
  timeout=_WRITE_BUSY_TIMEOUT_MS / 1000,
@@ -148,17 +168,8 @@ class MeshBroker:
148
168
  message = str(exc).lower()
149
169
  return "database is locked" in message or "database is busy" in message
150
170
 
151
- def _write_with_retry(
152
- self,
153
- operation: Callable[[sqlite3.Connection], _T],
154
- ) -> _T:
155
- """Run one idempotent mesh mutation with a bounded SQLite retry budget.
156
-
157
- SQLite WAL lets reads continue while a write is in progress, but it
158
- still permits a single writer. Retrying the entire short transaction on
159
- a fresh connection avoids leaking a transient writer collision to an
160
- agent heartbeat or mesh command.
161
- """
171
+ def _write_with_retry(self, operation: Callable[[sqlite3.Connection], _T]) -> _T:
172
+ """Run one idempotent mesh mutation with a bounded SQLite retry budget."""
162
173
  last_error: sqlite3.OperationalError | None = None
163
174
  for attempt in range(_WRITE_RETRY_ATTEMPTS):
164
175
  conn = self._conn()
@@ -199,6 +210,7 @@ class MeshBroker:
199
210
  ).fetchone()
200
211
  if existing:
201
212
  peer_id = existing["peer_id"]
213
+ is_new_peer = False
202
214
  conn.execute(
203
215
  "UPDATE mesh_peers SET summary=?, host=?, port=?, last_heartbeat=?, "
204
216
  "status='active', project_path=?, agent_type=? "
@@ -208,6 +220,7 @@ class MeshBroker:
208
220
  )
209
221
  else:
210
222
  peer_id = str(uuid.uuid4())[:12]
223
+ is_new_peer = True
211
224
  conn.execute(
212
225
  "INSERT INTO mesh_peers (peer_id, session_id, summary, status, host, port, "
213
226
  "registered_at, last_heartbeat, project_path, agent_type, profile_id) "
@@ -220,9 +233,17 @@ class MeshBroker:
220
233
  }, profile_id=profile_id)
221
234
  conn.commit()
222
235
 
236
+ # SEC-4 + P0: mint & RETURN the per-peer HMAC key ONLY on first
237
+ # registration. Re-registration MUST NOT re-export the key — otherwise
238
+ # a fleet-secret holder who learns a session_id (via /mesh/peers) could
239
+ # re-register as the victim and steal their peer_key (identity theft).
240
+ # The legitimate owner keeps the key it received at first registration.
223
241
  # v3.4.6: Deliver pending broadcast/project messages on registration
224
242
  pending = self._get_pending_for_peer(conn, peer_id, project_path, profile_id)
225
- return {"peer_id": peer_id, "ok": True, "pending_messages": len(pending)}
243
+ result = {"peer_id": peer_id, "ok": True, "pending_messages": len(pending)}
244
+ if is_new_peer:
245
+ result["peer_key"] = get_or_create_peer_key(conn, peer_id, profile_id)
246
+ return result
226
247
 
227
248
  return self._write_with_retry(_register)
228
249
 
@@ -290,7 +311,8 @@ class MeshBroker:
290
311
 
291
312
  def send_message(self, from_peer: str, to_peer: str, content: str,
292
313
  msg_type: str = "text", project_path: str = "",
293
- profile_id: str = "default") -> dict:
314
+ profile_id: str = "default",
315
+ operation_id: str | None = None) -> dict:
294
316
  # Guard: 4KB message size cap
295
317
  if len(content) > MAX_MESSAGE_SIZE:
296
318
  return {"ok": False, "error": f"message too large ({len(content)} bytes, max {MAX_MESSAGE_SIZE}). "
@@ -326,6 +348,20 @@ class MeshBroker:
326
348
  now = datetime.now(timezone.utc).isoformat()
327
349
  expires_at = self._compute_expires(now)
328
350
 
351
+ # Idempotency: return original result for a repeated operation_id.
352
+ if operation_id:
353
+ op = conn.execute(
354
+ "SELECT message_id FROM mesh_sent_ops WHERE operation_id=?", (operation_id,)
355
+ ).fetchone()
356
+ if op:
357
+ return {"ok": True, "id": op["message_id"],
358
+ "idempotent": True, "operation_id": operation_id}
359
+
360
+ # Identity binding: cross-profile impersonation guard.
361
+ cross_profile_err = check_cross_profile_sender(conn, from_peer, profile_id)
362
+ if cross_profile_err is not None:
363
+ return cross_profile_err
364
+
329
365
  # Determine target type
330
366
  if _to_peer == "broadcast":
331
367
  target_type = "broadcast"
@@ -361,18 +397,24 @@ class MeshBroker:
361
397
  count - MAX_QUEUED_PER_TARGET + 1),
362
398
  )
363
399
 
400
+ _content = scrub_message_content(content) # 3a-2: redact before storage
364
401
  cursor = conn.execute(
365
402
  "INSERT INTO mesh_messages (from_peer, to_peer, msg_type, content, read, "
366
403
  "created_at, expires_at, target_type, project_path, profile_id) "
367
404
  "VALUES (?, ?, ?, ?, 0, ?, ?, ?, ?, ?)",
368
- (from_peer, _to_peer, msg_type, content, now, expires_at,
405
+ (from_peer, _to_peer, msg_type, _content, now, expires_at,
369
406
  target_type, _project_path, profile_id),
370
407
  )
371
- self._log_event(conn, "message_sent", from_peer, {
408
+ msg_id = cursor.lastrowid
409
+
410
+ if operation_id:
411
+ conn.execute("INSERT OR IGNORE INTO mesh_sent_ops (operation_id, message_id, created_at) VALUES (?, ?, ?)", (operation_id, msg_id, now))
412
+
413
+ self._log_event(conn, "message_sent", from_peer or "system", {
372
414
  "to": _to_peer, "target_type": target_type, "project": _project_path,
373
415
  }, profile_id=profile_id)
374
416
  conn.commit()
375
- return {"ok": True, "id": cursor.lastrowid, "target_type": target_type,
417
+ return {"ok": True, "id": msg_id, "target_type": target_type,
376
418
  "expires_at": expires_at}
377
419
 
378
420
  return self._write_with_retry(_send)
@@ -487,14 +529,36 @@ class MeshBroker:
487
529
  conn.close()
488
530
 
489
531
  def set_state(self, key: str, value: str, set_by: str,
490
- profile_id: str = "default") -> dict:
532
+ profile_id: str = "default",
533
+ expected_revision: int | None = None) -> dict:
534
+ """Set coordination state; rejects secrets and enforces optimistic revision."""
535
+ secret_err = reject_secret_state(key, value)
536
+ if secret_err is not None:
537
+ return secret_err
538
+
491
539
  def _set_state(conn: sqlite3.Connection) -> dict:
492
540
  now = datetime.now(timezone.utc).isoformat()
541
+ if expected_revision is not None:
542
+ row = conn.execute(
543
+ "SELECT revision FROM mesh_state WHERE profile_id=? AND key=?",
544
+ (profile_id, key),
545
+ ).fetchone()
546
+ current = row["revision"] if row else 0
547
+ if current != expected_revision:
548
+ return {"ok": False,
549
+ "error": f"revision mismatch: expected {expected_revision}, current {current}"}
493
550
  conn.execute(
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",
551
+ "INSERT INTO mesh_state"
552
+ " (profile_id, key, value, set_by, updated_at, revision, origin_node)"
553
+ " VALUES (?, ?, ?, ?, ?, 1, '')"
554
+ " ON CONFLICT(profile_id, key) DO UPDATE SET value=excluded.value,"
555
+ " set_by=excluded.set_by, updated_at=excluded.updated_at,"
556
+ " revision=COALESCE(mesh_state.revision, 0) + 1,"
557
+ # 3c-1: a local write resets provenance to THIS node ('' = local).
558
+ # Without this, a local set_state after a remote merge would keep
559
+ # the remote's origin_node and re-export under the wrong identity
560
+ # → permanent same-(revision,node) divergence (audit P0-1).
561
+ " origin_node=''",
498
562
  (profile_id, key, value, set_by, now),
499
563
  )
500
564
  conn.commit()
@@ -506,8 +570,9 @@ class MeshBroker:
506
570
  conn = self._conn()
507
571
  try:
508
572
  row = conn.execute(
509
- "SELECT key, value, set_by, updated_at FROM mesh_state "
510
- "WHERE profile_id=? AND key=?",
573
+ "SELECT key, value, set_by, updated_at, "
574
+ "COALESCE(revision, 0) AS revision "
575
+ "FROM mesh_state WHERE profile_id=? AND key=?",
511
576
  (profile_id, key),
512
577
  ).fetchone()
513
578
  return dict(row) if row else None
@@ -522,12 +587,13 @@ class MeshBroker:
522
587
  conn = self._conn()
523
588
  try:
524
589
  row = conn.execute(
525
- "SELECT locked_by, locked_at FROM mesh_locks "
526
- "WHERE profile_id=? AND file_path=?",
590
+ "SELECT locked_by, locked_at, COALESCE(fencing_token, 0) AS fencing_token"
591
+ " FROM mesh_locks WHERE profile_id=? AND file_path=?",
527
592
  (profile_id, file_path),
528
593
  ).fetchone()
529
594
  if row:
530
- return {"locked": True, "by": row["locked_by"], "since": row["locked_at"]}
595
+ return {"locked": True, "by": row["locked_by"],
596
+ "since": row["locked_at"], "fencing_token": row["fencing_token"]}
531
597
  return {"locked": False}
532
598
  finally:
533
599
  conn.close()
@@ -557,15 +623,18 @@ class MeshBroker:
557
623
  lock_expires = (
558
624
  datetime.fromisoformat(now) + timedelta(hours=LOCK_TTL_HOURS)
559
625
  ).isoformat()
626
+ token = self._next_fencing_token()
560
627
  conn.execute(
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),
628
+ "INSERT INTO mesh_locks (profile_id, file_path, locked_by, locked_at,"
629
+ " expires_at, fencing_token) VALUES (?, ?, ?, ?, ?, ?)"
630
+ " ON CONFLICT(profile_id, file_path) DO UPDATE SET locked_by=excluded.locked_by,"
631
+ " locked_at=excluded.locked_at, expires_at=excluded.expires_at,"
632
+ " fencing_token=excluded.fencing_token",
633
+ (profile_id, file_path, locked_by, now, lock_expires, token),
566
634
  )
567
635
  conn.commit()
568
- return {"ok": True, "action": "acquired", "expires_at": lock_expires}
636
+ return {"ok": True, "action": "acquired",
637
+ "expires_at": lock_expires, "fencing_token": token}
569
638
 
570
639
  elif action == "release":
571
640
  # v3.6.12 (mesh-2): report whether we actually released. The
@@ -585,6 +654,15 @@ class MeshBroker:
585
654
 
586
655
  return self._write_with_retry(_lock_action)
587
656
 
657
+ def validate_lock_fence(self, file_path: str, fencing_token: int,
658
+ profile_id: str = "default") -> dict:
659
+ """Reject a stale fencing token; allow the current one."""
660
+ conn = self._conn()
661
+ try:
662
+ return validate_lock_fence_query(conn, file_path, fencing_token, profile_id)
663
+ finally:
664
+ conn.close()
665
+
588
666
  # -- Helpers (v3.4.6) --
589
667
 
590
668
  @staticmethod