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
@@ -15,6 +15,7 @@ from __future__ import annotations
15
15
 
16
16
  import hashlib
17
17
  import json
18
+ import logging
18
19
  import re
19
20
  import threading
20
21
  import uuid
@@ -36,6 +37,14 @@ from superlocalmemory.core.remember_admission import (
36
37
  RememberReceipt,
37
38
  RememberService,
38
39
  )
40
+ from superlocalmemory.core.transactions.concrete_owners import (
41
+ REQUIRED_ADMISSION_OWNERS,
42
+ )
43
+ from superlocalmemory.core.transactions.obligations import ObligationLedger
44
+ from superlocalmemory.core.transactions.owners import (
45
+ ObligationKind,
46
+ OperationContext,
47
+ )
39
48
  from superlocalmemory.storage.admission_codec import MachineKeyCommandCodec
40
49
  from superlocalmemory.storage.admission_journal import (
41
50
  Actor,
@@ -47,6 +56,11 @@ from superlocalmemory.storage.admission_journal import (
47
56
  TerminalAdmissionError,
48
57
  )
49
58
  from superlocalmemory.storage.database import DatabaseManager
59
+ from superlocalmemory.storage.generation_fence import (
60
+ admitted_epoch,
61
+ clear_admission_epoch,
62
+ record_admission_epoch,
63
+ )
50
64
  from superlocalmemory.storage.write_coordinator import (
51
65
  CommandConflictError,
52
66
  CommandKind,
@@ -64,11 +78,33 @@ Materializer = Callable[
64
78
  list[str] | tuple[str, ...] | MaterializationResult,
65
79
  ]
66
80
 
81
+ logger = logging.getLogger("superlocalmemory.core.remember_runtime")
82
+ _OBLIGATION_LEDGER = ObligationLedger()
83
+
84
+
85
+ def _obligation_schema_present(conn) -> bool:
86
+ row = conn.execute(
87
+ "SELECT 1 FROM sqlite_master WHERE type='table' AND name='projection_obligations'"
88
+ ).fetchone()
89
+ if row is None:
90
+ return False
91
+ columns = {r[1] for r in conn.execute("PRAGMA table_info(projection_obligations)")}
92
+ return "context_digest" in columns
93
+
67
94
 
68
95
  class CanonicalRememberUnavailable(RuntimeError):
69
96
  """The daemon cannot accept a bounded canonical remember request."""
70
97
 
71
98
 
99
+ class DaemonAlreadyServing(RuntimeError):
100
+ """A healthy SLM daemon is already serving; this instance should exit 0.
101
+
102
+ Raised by ``CanonicalRememberRuntime.start()`` (H2) when the writer claim
103
+ fails AND a health-verified daemon is responding on the configured port.
104
+ Caught by ``unified_daemon.py`` lifespan → ``sys.exit(0)``.
105
+ """
106
+
107
+
72
108
  class CanonicalMutationConflict(ValueError):
73
109
  """A mutation retry key was reused for different immutable input."""
74
110
 
@@ -102,6 +138,54 @@ def validate_deterministic_admission(
102
138
  raise AdmissionPayloadError("content rejected by deterministic ingest policy")
103
139
 
104
140
 
141
+ # ---------------------------------------------------------------------------
142
+ # H2 — graceful single-instance helpers (keep all I/O in stdlib; no imports
143
+ # from the server layer to avoid circular dependencies with core).
144
+ # ---------------------------------------------------------------------------
145
+
146
+ def _get_daemon_port() -> int:
147
+ """Return the HTTP port this daemon instance listens on.
148
+
149
+ Reads ``SLM_DAEMON_PORT`` from the environment (set by ``unified_daemon``
150
+ at startup) and falls back to the conventional default of 8765.
151
+ """
152
+ import os
153
+
154
+ raw = os.environ.get("SLM_DAEMON_PORT", "")
155
+ try:
156
+ return int(raw)
157
+ except (ValueError, TypeError):
158
+ return 8765
159
+
160
+
161
+ def _slm_health_check(port: int) -> bool:
162
+ """Return True iff a healthy SLM daemon responds on *port* within 2 s."""
163
+ import urllib.request
164
+
165
+ try:
166
+ with urllib.request.urlopen(
167
+ f"http://127.0.0.1:{port}/health", timeout=2
168
+ ) as resp:
169
+ return int(resp.status) == 200
170
+ except Exception:
171
+ return False
172
+
173
+
174
+ def _boot_self_heal(data_dir: Path) -> None:
175
+ """Run the H1 stale-artifact reaper. Fail-soft — never blocks startup."""
176
+ try:
177
+ from superlocalmemory.infra.self_heal import reap_stale_artifacts
178
+
179
+ report = reap_stale_artifacts(data_dir)
180
+ if report["removed"]:
181
+ logger.info(
182
+ "remember_runtime self-heal: removed %d stale artifact(s)",
183
+ len(report["removed"]),
184
+ )
185
+ except Exception as exc:
186
+ logger.debug("remember_runtime self-heal failed (non-fatal): %s", exc)
187
+
188
+
105
189
  class _CoordinatorAdapter:
106
190
  """Translate the journal service's narrow protocol into typed commands."""
107
191
 
@@ -152,6 +236,7 @@ class CanonicalRememberRuntime:
152
236
  self._writer = writer
153
237
  self._materialize = materialize or _materialization_is_not_available
154
238
  self._binding_lock = threading.RLock()
239
+ self._generation = 0
155
240
  self.coordinator = WriteCoordinator(db.db_path, owner_id=owner_id)
156
241
  self.journal = AdmissionJournal(
157
242
  journal_path,
@@ -159,6 +244,7 @@ class CanonicalRememberRuntime:
159
244
  )
160
245
  self._service = RememberService(self.journal, _CoordinatorAdapter(self.coordinator))
161
246
  self._started = False
247
+ self._obligation_schema_ok: bool | None = None
162
248
 
163
249
  @classmethod
164
250
  def for_engine(cls, engine: Any) -> "CanonicalRememberRuntime":
@@ -181,13 +267,72 @@ class CanonicalRememberRuntime:
181
267
  )
182
268
 
183
269
  def start(self) -> None:
184
- """Claim writer ownership, install the handler, then recover journal work."""
270
+ """Claim writer ownership, install the handler, then recover journal work.
271
+
272
+ H2 / G-01+G-05 — bounded graceful single-instance:
273
+
274
+ When ``claim_ownership()`` fails (another process holds the portalocker
275
+ flock), we enter a bounded retry loop (≤ 5 attempts, ~1 s between each,
276
+ total ≤ ~6 s) rather than immediately crashing or silently giving up.
277
+ This handles the race between a healthy daemon's HTTP-server bind and our
278
+ health check — the holder may be alive but not yet responding.
279
+
280
+ Per-iteration logic:
281
+ (a) ``claim_ownership()`` succeeds → break and proceed (holder died).
282
+ (b) ``_slm_health_check(port)`` is True → raise ``DaemonAlreadyServing``
283
+ (caught by ``unified_daemon.py`` lifespan → ``sys.exit(0)``).
284
+ (c) First iteration only → run ``_boot_self_heal()`` to clear
285
+ provably-dead metadata artifacts, then continue.
286
+
287
+ After the loop exhausts all attempts without claiming the lock, we know
288
+ a live process is holding the portalocker flock. Raise
289
+ ``DaemonAlreadyServing`` (NOT ``CanonicalRememberUnavailable``) so the
290
+ daemon exits cleanly rather than crashing with a traceback.
291
+
292
+ INVARIANT: ``claim_ownership()`` (portalocker OS flock) is the sole
293
+ writer-integrity gate. We never bypass it, never signal or kill a live
294
+ process.
295
+ """
296
+ import time as _time
297
+
185
298
  if self._started:
186
299
  return
187
300
  if not self.coordinator.claim_ownership():
188
- raise CanonicalRememberUnavailable(
189
- "another daemon owns the canonical memory writer"
190
- )
301
+ port = _get_daemon_port()
302
+ _self_heal_done = False
303
+ _MAX_ATTEMPTS = 5
304
+
305
+ for _attempt in range(_MAX_ATTEMPTS):
306
+ if _attempt > 0:
307
+ _time.sleep(1.0)
308
+
309
+ # (a) Re-try the claim — holder may have died in the gap.
310
+ if self.coordinator.claim_ownership():
311
+ break # claimed → proceed to handler registration
312
+
313
+ # (b) Health-verified owner → exit gracefully.
314
+ if _slm_health_check(port):
315
+ raise DaemonAlreadyServing(
316
+ f"another healthy SLM daemon is already serving"
317
+ f" on port {port}"
318
+ )
319
+
320
+ # (c) First iteration only: clear provably-dead stale artifacts.
321
+ if not _self_heal_done:
322
+ logger.info(
323
+ "remember_runtime: writer claim failed, no healthy"
324
+ " daemon on port %d; running self-heal (attempt %d/%d)",
325
+ port, _attempt + 1, _MAX_ATTEMPTS,
326
+ )
327
+ _boot_self_heal(self.coordinator.db_path.parent)
328
+ _self_heal_done = True
329
+ else:
330
+ # Loop exhausted: a live process holds the portalocker flock.
331
+ # Exit cleanly — never crash with a traceback.
332
+ raise DaemonAlreadyServing(
333
+ f"another SLM daemon holds the writer lock after"
334
+ f" {_MAX_ATTEMPTS} attempts on port {port}"
335
+ )
191
336
  try:
192
337
  self.coordinator.register_handler(CommandKind.ADMISSION, self._handle_admission)
193
338
  self.coordinator.register_handler(CommandKind.DELETE_FACT, self._handle_mutation)
@@ -241,11 +386,13 @@ class CanonicalRememberRuntime:
241
386
  self._db = db
242
387
  self._profile_id = profile_id
243
388
  self._writer = writer
389
+ self._generation += 1
244
390
  try:
245
391
  self.replay_pending()
246
392
  except BaseException:
247
393
  with self._binding_lock:
248
394
  self._db, self._profile_id, self._writer = previous
395
+ self._generation -= 1
249
396
  raise
250
397
 
251
398
  def remember(
@@ -256,6 +403,9 @@ class CanonicalRememberRuntime:
256
403
  raise CanonicalRememberUnavailable("canonical remember writer is not ready")
257
404
  if deadline_ms < 1 or deadline_ms > 2_000:
258
405
  raise ValueError("deadline_ms must be between 1 and 2000")
406
+ with self._binding_lock:
407
+ admitted = self._generation
408
+ record_admission_epoch(request.profile_id, request.idempotency_key, admitted)
259
409
  try:
260
410
  return self._service.remember(request, actor, deadline_ms=deadline_ms)
261
411
  except (
@@ -266,6 +416,8 @@ class CanonicalRememberRuntime:
266
416
  raise CanonicalRememberUnavailable(
267
417
  "canonical remember is temporarily unavailable"
268
418
  ) from exc
419
+ finally:
420
+ clear_admission_epoch(request.profile_id, request.idempotency_key)
269
421
 
270
422
  def replay_pending(self) -> int:
271
423
  """Finish prepared/dispatched journal entries before publishing readiness."""
@@ -442,6 +594,9 @@ class CanonicalRememberRuntime:
442
594
  db = self._db
443
595
  if request.profile_id != self._profile_id:
444
596
  raise ValueError("admission command targets a different profile")
597
+ expected = admitted_epoch(request.profile_id, request.idempotency_key)
598
+ if expected is not None and expected != self._generation:
599
+ raise ValueError("admission command epoch is stale")
445
600
  ingestion_request = IngestionRequest(
446
601
  content=request.content,
447
602
  profile_id=request.profile_id,
@@ -469,6 +624,7 @@ class CanonicalRememberRuntime:
469
624
  receipt = command_impl.submit(ingestion_request)
470
625
  except IngestionRejectedError as exc:
471
626
  raise CommandRejectedError() from exc
627
+ self._record_projection_obligations(conn, request, receipt)
472
628
  return WriteResult.from_receipt(
473
629
  command,
474
630
  {
@@ -481,6 +637,47 @@ class CanonicalRememberRuntime:
481
637
  },
482
638
  )
483
639
 
640
+ def _record_projection_obligations(self, conn, request, receipt) -> None:
641
+ """Record per-owner APPLY obligations for the admitted facts.
642
+
643
+ Fail-closed: raises RuntimeError if M033 is absent. This is
644
+ intentional — a successful remember() without a corresponding
645
+ obligation record would make projection audits permanently incomplete.
646
+ Back-compat: installs without M033 must run migrations first.
647
+
648
+ Schema negative cache: ``_obligation_schema_ok`` is re-checked on
649
+ every call while False so that hot migrations (M033 applied while the
650
+ runtime is running) are detected without a restart. Once the schema
651
+ is confirmed present the value is cached True for the lifetime of the
652
+ runtime instance.
653
+
654
+ Cross-process erasure fence: distributed, multi-writer erasure fencing
655
+ is intentionally SCOPED OUT of V4. Single-process SQLite WAL provides
656
+ adequate isolation for the current deployment model. See
657
+ docs/architecture/erasure-fence-deferred.md for the deferred design.
658
+ """
659
+ fact_ids = tuple(getattr(receipt, "fact_ids", ()) or ())
660
+ if not fact_ids:
661
+ return
662
+ # Re-check while False so a hot M033 migration is picked up without
663
+ # requiring a daemon restart. Cache True permanently once confirmed.
664
+ if not self._obligation_schema_ok:
665
+ self._obligation_schema_ok = _obligation_schema_present(conn)
666
+ if not self._obligation_schema_ok:
667
+ raise RuntimeError(
668
+ "projection_obligations schema is absent; "
669
+ "run migrations (M033) before ingesting facts"
670
+ )
671
+ context = OperationContext(
672
+ operation_id=receipt.operation_id,
673
+ profile_id=request.profile_id,
674
+ subject_id=receipt.operation_id,
675
+ fact_ids=fact_ids,
676
+ )
677
+ _OBLIGATION_LEDGER.record_many(
678
+ conn, context, REQUIRED_ADMISSION_OWNERS, ObligationKind.APPLY,
679
+ )
680
+
484
681
  def _handle_mutation(self, conn, capability, command: WriteCommand) -> WriteResult:
485
682
  """Run only deterministic SQLite mutation statements under the writer."""
486
683
  payload = command.payload
@@ -708,5 +905,6 @@ def _thaw_command_value(value: Any) -> Any:
708
905
  __all__ = [
709
906
  "CanonicalRememberRuntime",
710
907
  "CanonicalRememberUnavailable",
908
+ "DaemonAlreadyServing",
711
909
  "validate_deterministic_admission",
712
910
  ]
@@ -49,13 +49,28 @@ def is_remote_mode() -> bool:
49
49
 
50
50
 
51
51
  def mcp_stateless() -> bool:
52
- """True iff the MCP transport should run stateless (no session id required).
52
+ """True iff the MCP Streamable-HTTP transport should run fully stateless.
53
53
 
54
- Enabled by ``SLM_REMOTE=1`` (umbrella) or ``SLM_MCP_STATELESS=1`` (granular).
55
- Stateless mode lets any gateway/hub forward ``tools/call`` without replaying
56
- the ``Mcp-Session-Id`` handshake — the fix for issue #39 Issue 3.
54
+ **Default is True** (mcp 2.0.0 fully-stateless contract). Stateless mode
55
+ lets any gateway/hub forward ``tools/call`` without replaying the
56
+ ``Mcp-Session-Id`` handshake (issue #39 Issue 3) and is required for
57
+ ``Client(mode="auto")`` discover probes to stay connection-safe.
58
+
59
+ Opt-out: set ``SLM_MCP_STATEFUL=1`` to force stateful Streamable-HTTP
60
+ (session IDs; event store still unused unless the caller passes one).
61
+ ``SLM_REMOTE`` / ``SLM_MCP_STATELESS`` remain recognized as positive
62
+ signals but are no longer required — the default is already stateless.
63
+
64
+ Application-level ``session_init`` / ``close_session`` are orthogonal
65
+ (session_id is a string parameter persisted in the memories table).
57
66
  """
58
- return is_remote_mode() or _is_truthy(os.environ.get("SLM_MCP_STATELESS"))
67
+ if _is_truthy(os.environ.get("SLM_MCP_STATEFUL")):
68
+ return False
69
+ # Explicit SLM_MCP_STATELESS=0/false still honoured as opt-out when set.
70
+ raw = os.environ.get("SLM_MCP_STATELESS")
71
+ if raw is not None and raw.strip() != "":
72
+ return _is_truthy(raw)
73
+ return True
59
74
 
60
75
 
61
76
  def _allowlist_entries() -> list[str]:
@@ -22,6 +22,7 @@ if TYPE_CHECKING:
22
22
  from superlocalmemory.core.hooks import HookRegistry
23
23
  from superlocalmemory.storage.database import DatabaseManager
24
24
 
25
+ from superlocalmemory.storage.erasure_fence import is_erasing
25
26
  from superlocalmemory.storage.models import (
26
27
  AtomicFact,
27
28
  FactType,
@@ -271,6 +272,111 @@ def _upsert_fact_vectors(fact, profile_id, ann_index, vector_store, embedder=Non
271
272
  )
272
273
 
273
274
 
275
+ class _TombstoneReadError(Exception):
276
+ pass
277
+
278
+
279
+ def _fact_is_tombstoned(db: DatabaseManager, profile_id: str, fact_id: str) -> bool:
280
+ try:
281
+ rows = db.execute(
282
+ "SELECT 1 FROM projection_tombstones "
283
+ "WHERE profile_id = ? AND fact_id = ? LIMIT 1",
284
+ (profile_id, fact_id),
285
+ )
286
+ return bool(rows)
287
+ except Exception as exc:
288
+ raise _TombstoneReadError(
289
+ f"tombstone check failed for {fact_id[:16]}: {type(exc).__name__}: {exc}"
290
+ ) from exc
291
+
292
+
293
+ def _residue_table_exists(db: DatabaseManager, name: str) -> bool:
294
+ try:
295
+ return bool(db.execute(
296
+ "SELECT 1 FROM sqlite_master WHERE type='table' AND name = ? LIMIT 1",
297
+ (name,),
298
+ ))
299
+ except Exception:
300
+ # Cannot determine existence: treat as present so the residue probe runs
301
+ # and fails closed rather than silently reporting "no residue".
302
+ return True
303
+
304
+
305
+ def _read_tombstoned_with_retry(
306
+ db: DatabaseManager, profile_id: str, fact_id: str, attempts: int = 3,
307
+ ) -> bool:
308
+ last: _TombstoneReadError | None = None
309
+ for _ in range(max(1, attempts)):
310
+ try:
311
+ return _fact_is_tombstoned(db, profile_id, fact_id)
312
+ except _TombstoneReadError as exc:
313
+ last = exc
314
+ raise last if last is not None else _TombstoneReadError("tombstone read failed")
315
+
316
+
317
+ def _vector_residue_present(db: DatabaseManager, fact_id: str) -> bool:
318
+ uncertain = False
319
+ for table in ("embedding_metadata", "vector_row_map"):
320
+ if not _residue_table_exists(db, table):
321
+ continue
322
+ try:
323
+ if db.execute(
324
+ f"SELECT 1 FROM {table} WHERE fact_id = ? LIMIT 1", (fact_id,)
325
+ ):
326
+ return True
327
+ except Exception:
328
+ uncertain = True
329
+ return uncertain
330
+
331
+
332
+ def _drop_resurrected_facts(
333
+ db: DatabaseManager,
334
+ profile_id: str,
335
+ stored_ids: list[str],
336
+ vector_store: Any,
337
+ ann_index: Any,
338
+ retrieval_engine: Any,
339
+ ) -> list[str]:
340
+ survivors: list[str] = []
341
+ for fid in stored_ids:
342
+ try:
343
+ tombstoned = _read_tombstoned_with_retry(db, profile_id, fid)
344
+ except _TombstoneReadError as exc:
345
+ if is_erasing(profile_id, fid):
346
+ # Tombstone unreadable but the in-process fence confirms an
347
+ # erasure is in flight — clean up rather than leave residue.
348
+ tombstoned = True
349
+ else:
350
+ logger.error(
351
+ "Tombstone read error in drop_resurrected for %s, deferring: %s",
352
+ fid[:16], exc,
353
+ )
354
+ continue
355
+ if not tombstoned:
356
+ survivors.append(fid)
357
+ continue
358
+ vec_store_ok = vector_store is not None and getattr(vector_store, "available", False)
359
+ try:
360
+ if vec_store_ok:
361
+ vector_store.delete(fid)
362
+ if ann_index is not None and hasattr(ann_index, "remove"):
363
+ ann_index.remove(fid)
364
+ bm25 = getattr(retrieval_engine, "_bm25", None) if retrieval_engine else None
365
+ if bm25 is not None and hasattr(bm25, "remove_fact"):
366
+ bm25.remove_fact(fid)
367
+ db.delete_bm25_tokens_for_fact(fid)
368
+ db.delete_fact(fid, profile_id=profile_id)
369
+ except Exception as exc:
370
+ logger.warning("resurrection undo failed for %s: %s", fid[:16], exc)
371
+ if _vector_residue_present(db, fid):
372
+ logger.error(
373
+ "resurrection undo incomplete for %s: vector residue remains "
374
+ "(vec_store_available=%s); tombstone preserved",
375
+ fid[:16], vec_store_ok,
376
+ )
377
+ return survivors
378
+
379
+
274
380
  # ---------------------------------------------------------------------------
275
381
  # run_store (was MemoryEngine.store)
276
382
  # ---------------------------------------------------------------------------
@@ -533,6 +639,17 @@ def run_store(
533
639
 
534
640
  stored_ids: list[str] = []
535
641
  for fact in facts:
642
+ try:
643
+ if _fact_is_tombstoned(db, profile_id, fact.fact_id):
644
+ continue
645
+ except _TombstoneReadError as _tse:
646
+ logger.error(
647
+ "Tombstone read error for %s, deferring ingestion: %s",
648
+ fact.fact_id[:16], _tse,
649
+ )
650
+ continue
651
+ if is_erasing(profile_id, fact.fact_id):
652
+ continue
536
653
  fact = enrich_fact(
537
654
  fact, record, profile_id,
538
655
  embedder=embedder,
@@ -852,6 +969,10 @@ def run_store(
852
969
  except Exception:
853
970
  provenance_complete = False
854
971
 
972
+ stored_ids = _drop_resurrected_facts(
973
+ db, profile_id, stored_ids, vector_store, ann_index, retrieval_engine,
974
+ )
975
+
855
976
  logger.info("Stored %d facts (session=%s)", len(stored_ids), session_id)
856
977
 
857
978
  if derivation_report is not None:
@@ -981,6 +1102,25 @@ def run_store_fact_direct(
981
1102
  # run_close_session (was MemoryEngine.close_session)
982
1103
  # ---------------------------------------------------------------------------
983
1104
 
1105
+ def _session_already_summarised(
1106
+ db: DatabaseManager,
1107
+ profile_id: str,
1108
+ session_id: str,
1109
+ ) -> bool:
1110
+ """True if close_session already wrote temporal summaries for this session."""
1111
+ if not session_id:
1112
+ return False
1113
+ try:
1114
+ rows = db.execute(
1115
+ "SELECT 1 FROM temporal_events "
1116
+ "WHERE profile_id = ? AND description LIKE ? LIMIT 1",
1117
+ (profile_id, f"Session {session_id}:%"),
1118
+ )
1119
+ return bool(rows)
1120
+ except Exception:
1121
+ return False
1122
+
1123
+
984
1124
  def run_close_session(
985
1125
  session_id: str,
986
1126
  profile_id: str,
@@ -993,10 +1133,20 @@ def run_close_session(
993
1133
  with session scope. Enables temporal queries like "What happened
994
1134
  in session 3?"
995
1135
 
1136
+ Idempotent: if temporal summaries for this session already exist,
1137
+ returns 0 without writing duplicates.
1138
+
996
1139
  Returns number of session summary events created.
997
1140
  """
998
1141
  from superlocalmemory.storage.models import TemporalEvent
999
1142
 
1143
+ if not session_id:
1144
+ return 0
1145
+
1146
+ if _session_already_summarised(db, profile_id, session_id):
1147
+ logger.debug("Session %s already summarised — skip close", session_id)
1148
+ return 0
1149
+
1000
1150
  facts = db.get_all_facts(profile_id)
1001
1151
  session_facts = [f for f in facts if f.session_id == session_id]
1002
1152
  if not session_facts:
@@ -119,6 +119,25 @@ def compute_topic_signature(
119
119
  words = _WORD.findall(lowered)
120
120
  content_words = [w for w in words if w not in _STOPWORDS and len(w) >= 3]
121
121
 
122
+ # LLD-13 Track C.1 — merge entity names into content_words before
123
+ # computing bigrams. An entity ID of the form "category-name" (e.g.
124
+ # "entity-qualixar") contributes its *name* part ("qualixar"); an ID
125
+ # without a hyphen (e.g. "e001") contributes the whole ID. Words
126
+ # already present in content_words are silently deduplicated, so a
127
+ # prewarm caller and the UserPromptSubmit hook produce byte-identical
128
+ # signatures when the entity is already named in the prompt text.
129
+ # Backward-compatible: when entity_hits is empty/None the output is
130
+ # unchanged from the pre-LLD-13 signature.
131
+ if entity_hits:
132
+ _seen = set(content_words)
133
+ for _eid in entity_hits:
134
+ _eid_str = str(_eid)
135
+ _name = _eid_str.split("-", 1)[1] if "-" in _eid_str else _eid_str
136
+ _name_lc = _name.lower()
137
+ if _name_lc not in _seen and len(_name_lc) >= 3:
138
+ content_words.append(_name_lc)
139
+ _seen.add(_name_lc)
140
+
122
141
  # 5. Bigrams from the ORIGINAL token stream order. Preserves "foo bar"
123
142
  # vs "bar foo" distinction and resists stopword-only differentiation.
124
143
  bigrams = [f"{a}_{b}" for a, b in zip(content_words, content_words[1:])]
@@ -132,10 +151,6 @@ def compute_topic_signature(
132
151
  _canon(content_words),
133
152
  _canon(bigrams),
134
153
  ]
135
- # LLD-13: append entity-hits group ONLY when non-empty. Empty/missing
136
- # preserves the byte-identical v3.4.22 pre-Living-Brain signature.
137
- if entity_hits:
138
- groups.append(_canon([str(e) for e in entity_hits]))
139
154
  material = "\0\0".join(groups)
140
155
  return hashlib.sha256(material.encode("utf-8")).hexdigest()[:_SIG_LEN]
141
156
 
@@ -0,0 +1,78 @@
1
+ # Copyright (c) 2026 Varun Pratap Bhardwaj / Qualixar
2
+ # Licensed under AGPL-3.0-or-later - see LICENSE file
3
+
4
+ from __future__ import annotations
5
+
6
+ from superlocalmemory.core.transactions.erasure import (
7
+ ErasureProofRecord,
8
+ ErasureReceipt,
9
+ ErasureService,
10
+ ErasureState,
11
+ RemoveResult,
12
+ compute_erasure_hash,
13
+ fetch_receipt,
14
+ is_tombstoned,
15
+ tombstone_memory_id,
16
+ verify_receipt,
17
+ write_tombstones,
18
+ )
19
+ from superlocalmemory.core.transactions.manifest import (
20
+ CompletionManifest,
21
+ ManifestState,
22
+ OwnerEvidence,
23
+ build_evidence,
24
+ compute_envelope_hash,
25
+ derive_state,
26
+ hash_envelope_fields,
27
+ )
28
+ from superlocalmemory.core.transactions.obligations import (
29
+ Obligation,
30
+ ObligationConflictError,
31
+ ObligationLedger,
32
+ )
33
+ from superlocalmemory.core.transactions.owners import (
34
+ ObligationKind,
35
+ ObligationState,
36
+ OperationContext,
37
+ OwnerErasureProof,
38
+ OwnerHealth,
39
+ OwnerResult,
40
+ ProjectionOwner,
41
+ is_terminal_success,
42
+ )
43
+ from superlocalmemory.core.transactions.reconciler import Reconciler
44
+ from superlocalmemory.core.transactions.service import MemoryTransactionService
45
+
46
+ __all__ = [
47
+ "CompletionManifest",
48
+ "ErasureProofRecord",
49
+ "ErasureReceipt",
50
+ "ErasureService",
51
+ "ErasureState",
52
+ "ManifestState",
53
+ "MemoryTransactionService",
54
+ "Obligation",
55
+ "ObligationConflictError",
56
+ "ObligationKind",
57
+ "ObligationLedger",
58
+ "ObligationState",
59
+ "OperationContext",
60
+ "OwnerErasureProof",
61
+ "OwnerEvidence",
62
+ "OwnerHealth",
63
+ "OwnerResult",
64
+ "ProjectionOwner",
65
+ "Reconciler",
66
+ "RemoveResult",
67
+ "build_evidence",
68
+ "compute_envelope_hash",
69
+ "compute_erasure_hash",
70
+ "derive_state",
71
+ "fetch_receipt",
72
+ "hash_envelope_fields",
73
+ "is_terminal_success",
74
+ "is_tombstoned",
75
+ "tombstone_memory_id",
76
+ "verify_receipt",
77
+ "write_tombstones",
78
+ ]