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
@@ -114,6 +114,48 @@ def _mutation_runtime_or_missing_fact(
114
114
  raise HTTPException(503, detail="canonical mutation writer is not ready; retry shortly")
115
115
 
116
116
 
117
+ def _admit_http_mutation(request: Request, operation: str) -> None:
118
+ """Route a memory HTTP mutation through OperationPolicyRegistry.evaluate().
119
+
120
+ Called from _authorize_memory_mutation after RBAC passes. Raises HTTP 403
121
+ if the policy registry denies the actor. Maps "delete" → FORGET,
122
+ "update" → CORRECT. Uses the server-derived principal and roles.
123
+ """
124
+ from fastapi import HTTPException as _HTTPException
125
+
126
+ from superlocalmemory.core.actor_context import Transport
127
+ from superlocalmemory.core.admission import AdmissionDenied, admit, resolve_actor
128
+ from superlocalmemory.core.operation_request import OperationKind
129
+ from superlocalmemory.server.rbac_enforce import (
130
+ resolve_actor_roles,
131
+ resolve_principal,
132
+ )
133
+
134
+ deployment = getattr(request.app.state, "deployment", None)
135
+ is_enterprise = bool(deployment and deployment.is_enterprise)
136
+ tier = "enterprise" if is_enterprise else "personal"
137
+ mode = "company" if is_enterprise else "local"
138
+
139
+ principal_info = resolve_principal(request)
140
+ principal = str(principal_info.get("user_id") or "")
141
+ actor_roles = resolve_actor_roles(request)
142
+ actor = resolve_actor(
143
+ Transport.HTTP,
144
+ tier=tier,
145
+ mode=mode,
146
+ principal=principal,
147
+ roles=actor_roles,
148
+ )
149
+ kind = OperationKind.FORGET if operation == "delete" else OperationKind.CORRECT
150
+ try:
151
+ admit(kind, actor, mode=mode)
152
+ except AdmissionDenied as exc:
153
+ raise _HTTPException(
154
+ status_code=403,
155
+ detail=f"Operation denied: {exc.decision.reason}",
156
+ ) from exc
157
+
158
+
117
159
  def _authorize_memory_mutation(
118
160
  request: Request,
119
161
  operation: str,
@@ -131,14 +173,15 @@ def _authorize_memory_mutation(
131
173
  actor_kind="dashboard",
132
174
  )
133
175
  # RBAC (C3): on top of machine auth, enforce the caller's role on the active
134
- # profile. delete → DELETE; every other mutation → WRITE. A viewer (or an
135
- # owner in require_login mode) is rejected here; owner/admin/member pass.
176
+ # profile. delete → DELETE; every other mutation → WRITE.
136
177
  from superlocalmemory.access.rbac import Permission as _Perm
137
178
  from superlocalmemory.server.rbac_enforce import require_permission as _rbac_require
138
179
  _rbac_require(
139
180
  request,
140
181
  _Perm.DELETE if operation == "delete" else _Perm.WRITE,
141
182
  )
183
+ # Phase 1: admission gateway — policy registry decision for this route.
184
+ _admit_http_mutation(request, operation)
142
185
  engine = _get_engine(request)
143
186
  if engine is None:
144
187
  raise HTTPException(503, detail="Engine not initialized")
@@ -1045,8 +1088,18 @@ async def delete_memory(request: Request, fact_id: str):
1045
1088
  idempotency_key=_mutation_idempotency_key(request),
1046
1089
  )
1047
1090
  if not result.get("ok"):
1091
+ if result.get("retryable"):
1092
+ raise HTTPException(
1093
+ status_code=503,
1094
+ detail="Erasure incomplete (projection residue); retry shortly",
1095
+ )
1048
1096
  raise HTTPException(status_code=404, detail="Memory not found")
1049
- return {"success": True, "deleted": fact_id}
1097
+ return {
1098
+ "success": True,
1099
+ "deleted": fact_id,
1100
+ "erasure_verified": bool(result.get("erasure_verified", False)),
1101
+ "erasure_state": result.get("erasure_state", "FAILED"),
1102
+ }
1050
1103
  except HTTPException:
1051
1104
  raise
1052
1105
  except Exception as exc:
@@ -112,7 +112,11 @@ def _get_broker(request: Request):
112
112
  )
113
113
  if not presented or not hmac.compare_digest(presented, secret):
114
114
  raise HTTPException(401, detail="invalid or missing credential")
115
- return broker
115
+ # TEST-1/SEC: When a fleet secret is configured, it is the primary auth
116
+ # mechanism. Loopback callers are trusted by transport and need no further
117
+ # proof. Only fall through to require_write_actor when NO secret is configured
118
+ # (install-token-only, single-machine mode).
119
+ return broker
116
120
 
117
121
  # Loopback is a transport property, not an identity. Every mesh read and
118
122
  # write must prove the install/API/process capability because peer inboxes,
@@ -346,6 +350,83 @@ def send(req: SendRequest, request: Request):
346
350
  if not to_target:
347
351
  raise HTTPException(400, detail="'to' or 'to_peer' required")
348
352
  profile = _active_profile()
353
+
354
+ # 3a-1 + 3a-2: Apply signature verification and admission gate for non-loopback.
355
+ client_host = request.client.host if request.client else "127.0.0.1"
356
+ from superlocalmemory.server.loopback import is_loopback as _is_loopback_host
357
+ _is_lb = _is_loopback_host(client_host)
358
+
359
+ if not _is_lb:
360
+ from superlocalmemory.mesh.broker_security import (
361
+ check_mesh_message_signature,
362
+ is_strict_identity,
363
+ )
364
+ config = getattr(request.app.state, "config", None)
365
+ strict = is_strict_identity(config)
366
+ fleet_secret = getattr(broker, "_shared_secret", None)
367
+
368
+ # SEC-4 (hardened): identity resolution per mode.
369
+ # - COMPAT (default): fleet secret verifies (or unsigned legacy accepted).
370
+ # - STRICT: the sender MUST be a registered peer with its own per-peer key.
371
+ # There is NO fleet-secret fallback in strict mode — unregistered
372
+ # from_peer, a NULL peer_key, or a DB error all FAIL CLOSED. (The old
373
+ # fleet fallback was an impersonation escape hatch: any fleet-secret
374
+ # holder could claim an unregistered/legacy from_peer.)
375
+ verify_secret = fleet_secret
376
+ if strict:
377
+ if not req.from_peer:
378
+ raise HTTPException(401, detail="strict identity: from_peer required")
379
+ import sqlite3 as _sqlite3
380
+ try:
381
+ _conn = _sqlite3.connect(broker._db_path, timeout=3)
382
+ _conn.row_factory = _sqlite3.Row
383
+ _row = _conn.execute(
384
+ "SELECT peer_key FROM mesh_peers WHERE peer_id=? LIMIT 1",
385
+ (req.from_peer,),
386
+ ).fetchone()
387
+ _conn.close()
388
+ except _sqlite3.Error:
389
+ raise HTTPException(503, detail="strict identity: peer key lookup failed")
390
+ if not _row or not _row["peer_key"]:
391
+ raise HTTPException(
392
+ 401,
393
+ detail="strict identity: from_peer is not a registered peer with a per-peer key",
394
+ )
395
+ verify_secret = str(_row["peer_key"])
396
+
397
+ sig_err = check_mesh_message_signature(
398
+ verify_secret,
399
+ req.from_peer,
400
+ to_target,
401
+ req.content,
402
+ request.headers.get("x-mesh-sig"),
403
+ request.headers.get("x-mesh-nonce"),
404
+ request.headers.get("x-mesh-ts"),
405
+ is_loopback=False,
406
+ strict=strict,
407
+ )
408
+ if sig_err is not None:
409
+ raise HTTPException(401, detail=sig_err.get("error", "signature error"))
410
+
411
+ # Admission gate parity (closes Wave-1 P1 bypass for inbound remote send).
412
+ try:
413
+ from superlocalmemory.core.admission import (
414
+ AdmissionDenied,
415
+ admit,
416
+ resolve_actor,
417
+ )
418
+ from superlocalmemory.core.actor_context import Transport
419
+ from superlocalmemory.core.operation_request import OperationKind
420
+ except ImportError:
421
+ AdmissionDenied = None # type: ignore[assignment,misc]
422
+
423
+ if AdmissionDenied is not None:
424
+ try:
425
+ actor = resolve_actor(Transport.HTTP, client_host=client_host)
426
+ admit(OperationKind.MESH_SEND, actor)
427
+ except AdmissionDenied as exc:
428
+ raise HTTPException(403, detail=str(exc))
429
+
349
430
  # This sync FastAPI route already runs in the worker thread pool, so the
350
431
  # broker's SQLite retries and optional remote HTTP delivery cannot block
351
432
  # the daemon event loop.
@@ -0,0 +1,54 @@
1
+ # Copyright (c) 2026 Varun Pratap Bhardwaj / Qualixar
2
+ # Licensed under AGPL-3.0-or-later - see LICENSE file
3
+ # Part of SuperLocalMemory V4 | https://qualixar.com | https://varunpratap.com
4
+
5
+ """SLM Mesh — lock-delta HTTP route (3c-2).
6
+
7
+ Exposes ``GET /mesh/lock/delta`` so remote peers can fetch this node's
8
+ live advisory locks for convergence via ``LockCoordinator.resolve()``.
9
+
10
+ Auth and broker resolution mirror ``routes/mesh.py``.
11
+ Do NOT mount this router directly — the delivery lead wires it via
12
+ ``app.include_router(mesh_lock_routes.router)``.
13
+
14
+ Part of Qualixar | Author: Varun Pratap Bhardwaj
15
+ """
16
+
17
+ from __future__ import annotations
18
+
19
+ from fastapi import APIRouter, Request
20
+
21
+ from superlocalmemory.mesh.lock_protocol import LockCoordinator
22
+ from superlocalmemory.mesh.node_identity import get_node_id
23
+ from superlocalmemory.server.routes.mesh import _get_broker
24
+ from superlocalmemory.server.routes.helpers import get_active_profile
25
+
26
+ router = APIRouter(prefix="/mesh/lock", tags=["mesh-lock"])
27
+
28
+
29
+ @router.get("/delta")
30
+ def lock_delta(
31
+ profile: str = "",
32
+ request: Request = None, # type: ignore[assignment]
33
+ ) -> dict:
34
+ """Return this node's live advisory locks for a given tenant profile.
35
+
36
+ The response is designed for consumption by a remote peer's
37
+ ``LockCoordinator.resolve()`` call: it includes the raw lock records
38
+ plus the local ``node_id`` so the remote can apply the total-order
39
+ ``(fencing_token, node_id)`` comparison without a separate handshake.
40
+
41
+ Query params:
42
+ profile: Tenant profile id. Falls back to the active profile
43
+ from the request context var when omitted or blank.
44
+
45
+ Returns:
46
+ ``{"locks": [...], "node_id": "<hex>"}``
47
+ ``locks`` is the output of ``LockCoordinator.local_lock_delta()``.
48
+ """
49
+ broker = _get_broker(request)
50
+ profile_id: str = profile.strip() or get_active_profile()
51
+ coordinator = LockCoordinator(broker)
52
+ locks = coordinator.local_lock_delta(profile_id)
53
+ node_id = get_node_id(broker._db_path)
54
+ return {"locks": locks, "node_id": node_id}
@@ -0,0 +1,63 @@
1
+ # Copyright (c) 2026 Varun Pratap Bhardwaj / Qualixar
2
+ # Licensed under AGPL-3.0-or-later - see LICENSE file
3
+ # Part of SuperLocalMemory V4 | https://qualixar.com | https://varunpratap.com
4
+
5
+ """SLM Mesh — state-sync delta endpoint (3c-1).
6
+
7
+ Exposes a single GET route that returns the local ``mesh_state`` rows whose
8
+ revision exceeds ``since``, suitable for pull-based LWW convergence.
9
+
10
+ Mounted by the delivery lead (unified_daemon.py) via ``include_router``.
11
+ Auth, broker access, and profile resolution all mirror ``routes/mesh.py``
12
+ exactly — no new patterns are introduced.
13
+
14
+ Author: Varun Pratap Bhardwaj
15
+ """
16
+
17
+ from __future__ import annotations
18
+
19
+ from fastapi import APIRouter, Request
20
+
21
+ from superlocalmemory.mesh.state_sync import StateSyncer
22
+
23
+ # Re-use auth + broker helpers verbatim from the main mesh module so that
24
+ # security properties (shared-secret, loopback trust, write-identity) apply
25
+ # identically to this new endpoint.
26
+ from superlocalmemory.server.routes.mesh import _active_profile, _get_broker
27
+
28
+ router = APIRouter(prefix="/mesh", tags=["mesh-state-sync"])
29
+
30
+
31
+ @router.get("/state/delta")
32
+ def state_delta(
33
+ request: Request,
34
+ profile: str = "",
35
+ since: int = 0,
36
+ ) -> dict:
37
+ """Return local mesh_state rows with revision > ``since`` for LWW sync.
38
+
39
+ Query parameters
40
+ ----------------
41
+ profile : str, optional
42
+ Tenant profile id. Defaults to the currently active profile when
43
+ omitted or empty.
44
+ since : int, optional
45
+ Only rows whose ``revision`` strictly exceeds this value are returned.
46
+ Pass ``0`` (default) to retrieve all rows.
47
+
48
+ Response
49
+ --------
50
+ ``{"entries": [...], "node_id": "<local_node_id>"}``
51
+
52
+ Each entry carries ``{key, value, set_by, updated_at, revision, node_id}``
53
+ where ``node_id`` is the effective origin node (resolved from
54
+ ``origin_node`` column; BC rows with ``origin_node=''`` resolve to the
55
+ local node's id).
56
+ """
57
+ broker = _get_broker(request)
58
+ resolved_profile = profile if profile else _active_profile()
59
+ syncer = StateSyncer(broker)
60
+ return {
61
+ "entries": syncer.local_delta(resolved_profile, since),
62
+ "node_id": syncer._node_id,
63
+ }
@@ -134,7 +134,10 @@ async def dashboard(request: Request):
134
134
  except Exception:
135
135
  pass
136
136
 
137
- return {
137
+ from superlocalmemory.core.modes import dashboard_mode_fields
138
+
139
+ # Mode record is the single source of truth for locality claims (F-03).
140
+ payload = {
138
141
  "mode": config.mode.value,
139
142
  "mode_name": {"a": "Local Guardian", "b": "Smart Local", "c": "Full Power"}.get(config.mode.value, "Unknown"),
140
143
  "provider": config.llm.provider or "none",
@@ -145,6 +148,8 @@ async def dashboard(request: Request):
145
148
  "base_dir": str(config.base_dir),
146
149
  "version": SLM_VERSION,
147
150
  }
151
+ payload.update(dashboard_mode_fields(config.mode))
152
+ return payload
148
153
  except Exception as e:
149
154
  return _internal_error()
150
155
 
@@ -589,41 +594,45 @@ def _validate_provider_url(url: str, client_host: str) -> str | None:
589
594
  are allowed for it. A NON-loopback caller may not make the server fetch
590
595
  private/loopback/link-local/reserved targets — that is the SSRF abuse.
591
596
  """
592
- from urllib.parse import urlparse
593
- import ipaddress
594
- import socket
595
- p = urlparse(url)
596
- if p.scheme not in ("http", "https"):
597
- return "Only http/https endpoints are supported"
598
- host = p.hostname or ""
599
- if host.lower() in ("169.254.169.254", "metadata.google.internal", "metadata"):
600
- return "Cloud metadata endpoints are not allowed"
597
+ from superlocalmemory.server.egress_policy import (
598
+ EgressActor,
599
+ EgressVerdict,
600
+ validate_egress_url,
601
+ )
601
602
  from superlocalmemory.server.loopback import is_loopback as _is_loopback_host
602
603
 
603
- if _is_loopback_host(client_host):
604
- return None # local dashboard may target its own local/LAN endpoints
604
+ is_local = _is_loopback_host(client_host)
605
605
  # SLM_REMOTE residue (#40): an allowlisted LAN dashboard is trusted exactly
606
606
  # like the loopback one and may probe its own LAN LLM endpoint. This does
607
607
  # NOT relax the SSRF guard for arbitrary remote callers —
608
608
  # is_lan_client_allowed is False unless remote mode is ON *and* the client
609
609
  # IP is in SLM_MCP_ALLOWED_HOSTS.
610
+ is_lan = False
610
611
  try:
611
612
  from superlocalmemory.core.remote_mode import is_lan_client_allowed
612
- if is_lan_client_allowed(client_host):
613
- return None
613
+ is_lan = bool(is_lan_client_allowed(client_host))
614
614
  except Exception: # pragma: no cover — defensive, never weaken on import error
615
- pass
616
- try:
617
- ip = ipaddress.ip_address(host)
618
- except ValueError:
619
- try:
620
- ip = ipaddress.ip_address(socket.gethostbyname(host))
621
- except Exception:
622
- return None # unresolvable — let the HTTP client fail normally
623
- if (ip.is_private or ip.is_loopback or ip.is_link_local
624
- or ip.is_reserved or ip.is_multicast):
625
- return "Internal/private endpoints are not allowed from a remote client"
626
- return None
615
+ is_lan = False
616
+
617
+ result = validate_egress_url(
618
+ url, EgressActor(is_local=is_local, is_lan=is_lan)
619
+ )
620
+
621
+ if result.verdict is EgressVerdict.ALLOW:
622
+ return None
623
+ if result.verdict is EgressVerdict.DENY_SCHEME:
624
+ return "Only http/https endpoints are supported"
625
+ if result.verdict is EgressVerdict.DENY_METADATA:
626
+ return "Cloud metadata endpoints are not allowed"
627
+ if result.verdict in (
628
+ EgressVerdict.DENY_CREDENTIALS,
629
+ EgressVerdict.DENY_FRAGMENT,
630
+ ):
631
+ return "Endpoint URL must not embed credentials or fragments"
632
+ if result.verdict is EgressVerdict.DENY_DNS_FAILURE:
633
+ return "Endpoint host could not be resolved"
634
+ # DENY_PRIVATE / DENY_MIXED_DNS / DENY_HOST
635
+ return "Internal/private endpoints are not allowed from a remote client"
627
636
 
628
637
 
629
638
  @router.post("/provider/test")
@@ -820,6 +829,17 @@ async def recall_trace(request: Request):
820
829
  query = body.get("query", "")
821
830
  limit = body.get("limit", 10)
822
831
  window = body.get("window", "") or ""
832
+ as_of_raw = (body.get("as_of", "") or "").strip()
833
+
834
+ # Normalize as_of at HTTP boundary. Invalid → 400.
835
+ _as_of: str | None = None
836
+ if as_of_raw:
837
+ from superlocalmemory.retrieval.temporal_utils import normalize_as_of
838
+ _as_of = normalize_as_of(as_of_raw)
839
+ if _as_of is None:
840
+ return JSONResponse(
841
+ {"error": "invalid_as_of", "raw": as_of_raw}, status_code=400
842
+ )
823
843
 
824
844
  # Use daemon engine — already loaded, shares warm page cache.
825
845
  # run_in_executor keeps event loop alive so browser doesn't abort.
@@ -832,7 +852,10 @@ async def recall_trace(request: Request):
832
852
  t0 = _time.monotonic()
833
853
  response = await loop.run_in_executor(
834
854
  None,
835
- lambda: engine.recall(query, limit=limit, fast=False, window=window or None),
855
+ lambda: engine.recall(
856
+ query, limit=limit, fast=False,
857
+ window=window or None, as_of=_as_of,
858
+ ),
836
859
  )
837
860
  elapsed_ms = round((_time.monotonic() - t0) * 1000, 1)
838
861
 
@@ -42,9 +42,95 @@ class ConnectionManager:
42
42
  manager = ConnectionManager()
43
43
 
44
44
 
45
+ def _ws_origin_allowed(websocket: WebSocket) -> bool:
46
+ """Return True when the WebSocket upgrade Origin is permitted.
47
+
48
+ Non-browser clients (CLI, MCP tools) carry no Origin header and are
49
+ always allowed. Browser-originated connections must come from a
50
+ loopback address or a host in the configured remote allowlist.
51
+ """
52
+ origin = websocket.headers.get("origin", "")
53
+ if not origin:
54
+ return True # non-browser caller (CLI/MCP) — no origin to validate
55
+
56
+ try:
57
+ from superlocalmemory.server.origin import origin_is_loopback
58
+ if origin_is_loopback(origin):
59
+ return True
60
+ except Exception:
61
+ # If the helper is unavailable, fall through to the remote check.
62
+ if "127.0.0.1" in origin or "::1" in origin or "localhost" in origin:
63
+ return True
64
+
65
+ try:
66
+ from superlocalmemory.core.remote_mode import is_remote_origin_allowed
67
+ if is_remote_origin_allowed(origin):
68
+ return True
69
+ except Exception:
70
+ pass
71
+
72
+ return False
73
+
74
+
75
+ def _ws_rbac_allowed(websocket: WebSocket, app_state) -> bool:
76
+ """Return True when the caller is authorised to open a WS connection.
77
+
78
+ Mirrors _rbac_read_gate in unified_daemon.py: no-op in single-operator
79
+ installations (zero RBAC users), requires a valid session in company /
80
+ require_login mode. Fails closed on RBAC errors.
81
+ """
82
+ rbac = getattr(app_state, "rbac", None)
83
+ if rbac is None:
84
+ return True # single-operator mode — no auth layer active
85
+
86
+ try:
87
+ active = rbac.user_count() > 0
88
+ except Exception:
89
+ logger.warning("ws: RBAC state unavailable — rejecting handshake (fail closed)")
90
+ return False # fail closed
91
+
92
+ if not active:
93
+ return True # no users registered — single-operator mode
94
+
95
+ token = (
96
+ websocket.headers.get("x-slm-user-session", "")
97
+ or websocket.cookies.get("slm_session", "")
98
+ )
99
+ user = rbac.resolve_session(token) if token else None
100
+
101
+ if user is None:
102
+ if rbac.require_login():
103
+ return False # login required and no valid session
104
+ return True # personal / owner mode — no session needed
105
+
106
+ try:
107
+ from superlocalmemory.access.rbac import Permission
108
+ from superlocalmemory.server.routes.helpers import get_active_profile
109
+ return rbac.has_permission(user["user_id"], get_active_profile(), Permission.READ)
110
+ except Exception:
111
+ logger.warning("ws: permission check failed — rejecting handshake (fail closed)")
112
+ return False
113
+
114
+
45
115
  @router.websocket("/ws/updates")
46
116
  async def websocket_updates(websocket: WebSocket):
47
117
  """WebSocket endpoint for real-time memory updates."""
118
+ app_state = getattr(websocket, "app", None)
119
+ app_state = getattr(app_state, "state", None)
120
+
121
+ # --- Reject disallowed origins before accepting the handshake. ---
122
+ if not _ws_origin_allowed(websocket):
123
+ logger.info("ws: rejected handshake from disallowed origin %s",
124
+ websocket.headers.get("origin", ""))
125
+ await websocket.close(code=1008) # 1008 = Policy Violation
126
+ return
127
+
128
+ # --- Reject unauthenticated callers when login is required. ---
129
+ if not _ws_rbac_allowed(websocket, app_state):
130
+ logger.info("ws: rejected unauthenticated or unauthorised handshake")
131
+ await websocket.close(code=4001) # 4001 = custom: Unauthorized
132
+ return
133
+
48
134
  await manager.connect(websocket)
49
135
 
50
136
  try:
@@ -72,8 +72,8 @@ UI_DIR = Path(__file__).resolve().parent.parent / "ui"
72
72
  def create_app() -> FastAPI:
73
73
  """Create and configure the FastAPI application."""
74
74
  application = FastAPI(
75
- title="SuperLocalMemory V3 UI Server",
76
- description="Memory Dashboard with V3 Engine, Trust, Learning, and Compliance",
75
+ title="SuperLocalMemory V4 UI Server",
76
+ description="Memory Dashboard with V4 Engine, Trust, Learning, and Compliance",
77
77
  version=SLM_VERSION,
78
78
  docs_url="/api/docs",
79
79
  redoc_url="/api/redoc",
@@ -224,9 +224,9 @@ def create_app() -> FastAPI:
224
224
  if not index_path.exists():
225
225
  return (
226
226
  "<!DOCTYPE html><html><head>"
227
- "<title>SuperLocalMemory V3</title></head>"
227
+ "<title>SuperLocalMemory V4</title></head>"
228
228
  "<body style='font-family:Arial;padding:40px'>"
229
- "<h1>SuperLocalMemory V3 UI Server Running</h1>"
229
+ "<h1>SuperLocalMemory V4 UI Server Running</h1>"
230
230
  "<p>UI not found. Check ui/index.html</p>"
231
231
  "<p><a href='/api/docs'>API Documentation</a></p>"
232
232
  "</body></html>"
@@ -290,7 +290,7 @@ if __name__ == "__main__":
290
290
  import argparse
291
291
  import socket
292
292
 
293
- parser = argparse.ArgumentParser(description="SuperLocalMemory V3 - Web Dashboard")
293
+ parser = argparse.ArgumentParser(description="SuperLocalMemory V4 - Web Dashboard")
294
294
  parser.add_argument("--port", type=int, default=8765, help="Port (default 8765)")
295
295
  parser.add_argument("--profile", type=str, default=None, help="Memory profile")
296
296
  args = parser.parse_args()
@@ -310,7 +310,7 @@ if __name__ == "__main__":
310
310
  print(f"\n Port {args.port} in use -- using {ui_port} instead\n")
311
311
 
312
312
  print("=" * 70)
313
- print(" SuperLocalMemory V3 - Web Dashboard")
313
+ print(" SuperLocalMemory V4 - Web Dashboard")
314
314
  print(" Copyright (c) 2026 Varun Pratap Bhardwaj / Qualixar")
315
315
  print("=" * 70)
316
316
  print(f" Database: {DB_PATH}")