superlocalmemory 4.0.10 → 4.1.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 (143) hide show
  1. package/.claude-plugin/marketplace.json +1 -1
  2. package/CHANGELOG.md +170 -0
  3. package/README.md +7 -7
  4. package/package.json +4 -2
  5. package/plugin/.claude-plugin/plugin.json +2 -2
  6. package/plugin/CLAUDE.md +3 -3
  7. package/plugin/agents/slm-governance-advisor.md +1 -1
  8. package/plugin/agents/slm-loop-runner.md +4 -4
  9. package/plugin/agents/slm-memory-advisor.md +1 -1
  10. package/plugin/agents/slm-optimize-advisor.md +1 -1
  11. package/plugin/requirements.txt +1 -1
  12. package/plugin/skills/slm-cache/SKILL.md +1 -1
  13. package/plugin/skills/slm-compress/SKILL.md +1 -1
  14. package/plugin/skills/slm-governance/SKILL.md +1 -1
  15. package/plugin/skills/slm-graph/SKILL.md +1 -1
  16. package/plugin/skills/slm-loop/SKILL.md +2 -2
  17. package/plugin/skills/slm-mesh/SKILL.md +1 -1
  18. package/plugin/skills/slm-profile/SKILL.md +5 -5
  19. package/plugin/skills/slm-recall/SKILL.md +102 -15
  20. package/plugin/skills/slm-remember/SKILL.md +35 -3
  21. package/plugin/skills/slm-scope/SKILL.md +1 -1
  22. package/plugin/skills/slm-session/SKILL.md +29 -3
  23. package/plugin/skills/slm-status/SKILL.md +1 -1
  24. package/plugin-src/rules/AGENTS.md +16 -8
  25. package/plugin-src/skills/slm-cache/SKILL.md +1 -1
  26. package/plugin-src/skills/slm-compress/SKILL.md +1 -1
  27. package/plugin-src/skills/slm-governance/SKILL.md +1 -1
  28. package/plugin-src/skills/slm-graph/SKILL.md +1 -1
  29. package/plugin-src/skills/slm-loop/SKILL.md +2 -2
  30. package/plugin-src/skills/slm-mesh/SKILL.md +1 -1
  31. package/plugin-src/skills/slm-profile/SKILL.md +5 -5
  32. package/plugin-src/skills/slm-recall/SKILL.md +102 -15
  33. package/plugin-src/skills/slm-remember/SKILL.md +35 -3
  34. package/plugin-src/skills/slm-scope/SKILL.md +1 -1
  35. package/plugin-src/skills/slm-session/SKILL.md +29 -3
  36. package/plugin-src/skills/slm-status/SKILL.md +1 -1
  37. package/pyproject.toml +1 -1
  38. package/src/superlocalmemory/__init__.py +1 -1
  39. package/src/superlocalmemory/cli/commands.py +263 -18
  40. package/src/superlocalmemory/cli/daemon.py +30 -0
  41. package/src/superlocalmemory/cli/db_migrate.py +71 -1
  42. package/src/superlocalmemory/cli/gdpr_cmd.py +15 -2
  43. package/src/superlocalmemory/cli/main.py +24 -2
  44. package/src/superlocalmemory/code_graph/database.py +44 -0
  45. package/src/superlocalmemory/compliance/gdpr.py +449 -39
  46. package/src/superlocalmemory/core/admission.py +231 -11
  47. package/src/superlocalmemory/core/backend_orchestrator.py +190 -84
  48. package/src/superlocalmemory/core/config.py +90 -11
  49. package/src/superlocalmemory/core/consolidation_engine.py +34 -0
  50. package/src/superlocalmemory/core/engine.py +140 -11
  51. package/src/superlocalmemory/core/graph_analyzer.py +76 -112
  52. package/src/superlocalmemory/core/graph_metrics.py +597 -0
  53. package/src/superlocalmemory/core/graph_pruner.py +121 -0
  54. package/src/superlocalmemory/core/maintenance_scheduler.py +205 -0
  55. package/src/superlocalmemory/core/mode_capability.py +111 -0
  56. package/src/superlocalmemory/core/ollama_validator.py +315 -0
  57. package/src/superlocalmemory/core/projection_drain.py +380 -0
  58. package/src/superlocalmemory/core/recall_pipeline.py +390 -3
  59. package/src/superlocalmemory/core/recall_worker.py +6 -3
  60. package/src/superlocalmemory/core/scale_autopromote.py +196 -0
  61. package/src/superlocalmemory/core/scale_engine.py +16 -2
  62. package/src/superlocalmemory/core/score_contract.py +21 -1
  63. package/src/superlocalmemory/core/session_identity.py +85 -0
  64. package/src/superlocalmemory/core/status_contract.py +108 -0
  65. package/src/superlocalmemory/core/worker_pool.py +4 -4
  66. package/src/superlocalmemory/core/working_memory.py +288 -0
  67. package/src/superlocalmemory/encoding/cognitive_consolidator.py +36 -6
  68. package/src/superlocalmemory/encoding/context_generator.py +1 -1
  69. package/src/superlocalmemory/encoding/entity_resolver.py +38 -0
  70. package/src/superlocalmemory/encoding/fact_extractor.py +18 -14
  71. package/src/superlocalmemory/encoding/prospective_markers.py +262 -0
  72. package/src/superlocalmemory/encoding/type_router.py +12 -12
  73. package/src/superlocalmemory/evolution/mutation_generator.py +30 -4
  74. package/src/superlocalmemory/graph/cozo_adjacency.py +122 -0
  75. package/src/superlocalmemory/graph/cozo_backend.py +103 -138
  76. package/src/superlocalmemory/hooks/portable_kit.py +10 -2
  77. package/src/superlocalmemory/learning/bandit.py +43 -0
  78. package/src/superlocalmemory/learning/consolidation_worker.py +54 -0
  79. package/src/superlocalmemory/learning/database.py +60 -3
  80. package/src/superlocalmemory/learning/entity_compiler.py +21 -58
  81. package/src/superlocalmemory/learning/feedback.py +3 -1
  82. package/src/superlocalmemory/learning/outcomes.py +47 -16
  83. package/src/superlocalmemory/learning/pattern_miner.py +28 -3
  84. package/src/superlocalmemory/learning/pattern_miner_constants.py +43 -0
  85. package/src/superlocalmemory/learning/pcos.py +291 -0
  86. package/src/superlocalmemory/learning/reward_from_outcomes.py +365 -0
  87. package/src/superlocalmemory/learning/reward_proxy.py +100 -10
  88. package/src/superlocalmemory/learning/signal_kinds.py +79 -0
  89. package/src/superlocalmemory/mcp/profiles.py +14 -2
  90. package/src/superlocalmemory/mcp/tools_active.py +2 -1
  91. package/src/superlocalmemory/mcp/tools_core.py +31 -3
  92. package/src/superlocalmemory/mcp/tools_v28.py +20 -1
  93. package/src/superlocalmemory/parameterization/pattern_extractor.py +14 -1
  94. package/src/superlocalmemory/parameterization/soft_prompt_generator.py +98 -0
  95. package/src/superlocalmemory/retrieval/bm25_channel.py +64 -3
  96. package/src/superlocalmemory/retrieval/channel_status.py +117 -0
  97. package/src/superlocalmemory/retrieval/engine.py +106 -11
  98. package/src/superlocalmemory/retrieval/entity_channel.py +210 -256
  99. package/src/superlocalmemory/retrieval/graph_adjacency.py +219 -0
  100. package/src/superlocalmemory/retrieval/scope_policy.py +20 -0
  101. package/src/superlocalmemory/retrieval/semantic_channel.py +47 -5
  102. package/src/superlocalmemory/retrieval/spreading.py +288 -0
  103. package/src/superlocalmemory/server/api.py +24 -5
  104. package/src/superlocalmemory/server/bandit_loops.py +17 -1
  105. package/src/superlocalmemory/server/rbac_enforce.py +26 -6
  106. package/src/superlocalmemory/server/recall_serializer.py +9 -0
  107. package/src/superlocalmemory/server/routes/behavioral.py +75 -10
  108. package/src/superlocalmemory/server/routes/compliance.py +98 -18
  109. package/src/superlocalmemory/server/routes/config_api.py +186 -4
  110. package/src/superlocalmemory/server/routes/evolution.py +178 -0
  111. package/src/superlocalmemory/server/routes/ingest.py +8 -0
  112. package/src/superlocalmemory/server/routes/learning_telemetry.py +2 -1
  113. package/src/superlocalmemory/server/routes/memories.py +49 -7
  114. package/src/superlocalmemory/server/routes/timeline.py +4 -0
  115. package/src/superlocalmemory/server/routes/v3_api.py +191 -15
  116. package/src/superlocalmemory/server/ui.py +20 -4
  117. package/src/superlocalmemory/server/unified_daemon.py +186 -5
  118. package/src/superlocalmemory/storage/_migration_internals.py +31 -0
  119. package/src/superlocalmemory/storage/_schema_version.py +24 -3
  120. package/src/superlocalmemory/storage/database.py +477 -59
  121. package/src/superlocalmemory/storage/embedding_codec.py +71 -0
  122. package/src/superlocalmemory/storage/lineage_retention.py +236 -0
  123. package/src/superlocalmemory/storage/logical_edges.py +43 -2
  124. package/src/superlocalmemory/storage/migration_runner.py +119 -0
  125. package/src/superlocalmemory/storage/migrations/M044_play_carries_its_own_evidence.py +127 -0
  126. package/src/superlocalmemory/storage/migrations/M045_fact_outcome_score.py +158 -0
  127. package/src/superlocalmemory/storage/migrations/M046_prospective_memory_has_its_own_name.py +620 -0
  128. package/src/superlocalmemory/storage/migrations/M047_fisher_vectors_are_stored_like_every_other_vector.py +306 -0
  129. package/src/superlocalmemory/storage/migrations/M048_upcoming_holds_only_what_is_upcoming.py +207 -0
  130. package/src/superlocalmemory/storage/migrations/M049_a_schema_version_marker_is_one_row.py +201 -0
  131. package/src/superlocalmemory/storage/migrations.py +18 -2
  132. package/src/superlocalmemory/storage/models.py +40 -1
  133. package/src/superlocalmemory/storage/projection_outbox.py +346 -0
  134. package/src/superlocalmemory/storage/retention_policy.py +860 -0
  135. package/src/superlocalmemory/storage/schema.py +12 -1
  136. package/src/superlocalmemory/storage/write_coordinator.py +19 -2
  137. package/src/superlocalmemory/trust/scorer.py +43 -1
  138. package/src/superlocalmemory/ui/index.html +9 -18
  139. package/src/superlocalmemory/ui/js/event-delegation.js +12 -1
  140. package/src/superlocalmemory/ui/js/od-health.js +28 -6
  141. package/src/superlocalmemory/ui/js/od-memories.js +19 -0
  142. package/src/superlocalmemory/ui/js/od-settings.js +87 -1
  143. package/src/superlocalmemory/ui/js/recall-lab.js +78 -3
@@ -1804,6 +1804,41 @@ def _stop_deployment_retention(application) -> bool:
1804
1804
  return True
1805
1805
 
1806
1806
 
1807
+ def _start_embedder_warmup(engine: object) -> "threading.Thread | None":
1808
+ """Load the embedding model in the background. Never blocks startup.
1809
+
1810
+ Returns the thread so a test can join it; ``None`` when there is nothing to
1811
+ warm. Every failure is "not warmed", never a failed startup: a daemon that
1812
+ cannot embed still serves keyword recall and still stores memories, and the
1813
+ materializer fills the vectors in afterwards either way.
1814
+ """
1815
+ embedder = getattr(engine, "_embedder", None)
1816
+ if embedder is None or not hasattr(embedder, "embed"):
1817
+ return None
1818
+
1819
+ from superlocalmemory.core.engine import _is_remote_embedder
1820
+
1821
+ if _is_remote_embedder(embedder):
1822
+ # A hosted embedder has no model to load and warming it would spend a
1823
+ # request, and money, on a sentence nobody asked about.
1824
+ return None
1825
+
1826
+ def _warm() -> None:
1827
+ started = time.time()
1828
+ try:
1829
+ embedder.embed("slm embedder warm-up")
1830
+ except Exception as exc: # pragma: no cover — warming is best effort
1831
+ logger.debug("embedder warm-up failed (%s) — writes will defer", exc)
1832
+ return
1833
+ logger.info(
1834
+ "Embedding model warm and ready (%.1fs)", time.time() - started,
1835
+ )
1836
+
1837
+ thread = threading.Thread(target=_warm, daemon=True, name="slm-embed-warmup")
1838
+ thread.start()
1839
+ return thread
1840
+
1841
+
1807
1842
  @asynccontextmanager
1808
1843
  async def lifespan(application: FastAPI):
1809
1844
  """Initialize engine, workers, and optional services on startup."""
@@ -2088,6 +2123,40 @@ async def lifespan(application: FastAPI):
2088
2123
  engine = MemoryEngine(config)
2089
2124
  engine.initialize()
2090
2125
 
2126
+ # Load the embedding model now, off the request path, the way the
2127
+ # cross-encoder is already warmed at startup.
2128
+ #
2129
+ # A write embeds inline so the memory it stores can be found by asking a
2130
+ # question rather than only by quoting its own words, and it gives that
2131
+ # one second before deferring to the materializer. Loading the model
2132
+ # takes 9.9-11.0 s here; once loaded an embed is 42 ms. So on a daemon
2133
+ # that had not embedded yet, the first writes each waited the full second
2134
+ # and stored no vector regardless -- and the model only ever loaded
2135
+ # because some *recall* eventually paid for it. Whoever recalled first
2136
+ # wore the cold start.
2137
+ #
2138
+ # This belongs to the daemon and not to engine wiring: the worker is a
2139
+ # subprocess per engine, so warming from wiring would have every `slm
2140
+ # status` spawn one and load a model it will never use.
2141
+ _start_embedder_warmup(engine)
2142
+
2143
+ # Tell the hook subprocesses that skill evolution is on. The hook reads
2144
+ # this env var as its fast-path signal and nothing ever set it, so the
2145
+ # feature was off for everyone who had switched it on in config: the
2146
+ # hook checked, found nothing, and returned False. Hooks are launched as
2147
+ # children of this process and inherit its environment, which is the
2148
+ # only channel between the two.
2149
+ try:
2150
+ if getattr(getattr(config, "evolution", None), "enabled", False):
2151
+ os.environ["SLM_EVOLUTION_ENABLED"] = "1"
2152
+ else:
2153
+ # Cleared as well as set: a daemon restarted with the setting
2154
+ # turned off must not leave the previous run's answer behind in
2155
+ # an environment the next hook inherits.
2156
+ os.environ.pop("SLM_EVOLUTION_ENABLED", None)
2157
+ except Exception as _evo_exc: # pragma: no cover — never block startup
2158
+ logger.debug("evolution flag not exported: %s", _evo_exc)
2159
+
2091
2160
  # Refresh migration state now that the engine is initialised. Any
2092
2161
  # schema work the engine's own bootstrap may have applied (e.g.
2093
2162
  # runtime-table creation) is captured here so the dashboard and
@@ -2175,6 +2244,30 @@ async def lifespan(application: FastAPI):
2175
2244
  "deferred migration runner crashed (non-fatal): %s", _dexc,
2176
2245
  )
2177
2246
 
2247
+ # Move an existing store onto the graph and vector backends on the
2248
+ # first start after an upgrade. They have shipped as required
2249
+ # dependencies since 3.7 and sat unused, because building the
2250
+ # projections was three manual commands almost nobody ran. Runs after
2251
+ # the deferred migrations so it projects the converted store, and never
2252
+ # fatal: if the libraries will not import or the projection does not
2253
+ # match, the daemon serves from SQLite and says so.
2254
+ try:
2255
+ from superlocalmemory.core.scale_autopromote import (
2256
+ auto_promote_scale_backends,
2257
+ )
2258
+ _promotion = auto_promote_scale_backends(config)
2259
+ application.state.scale_autopromotion = _promotion.as_dict()
2260
+ if _promotion.promoted and _promotion.restart_required:
2261
+ logger.info(
2262
+ "graph and vector backends are promoted and serve after the "
2263
+ "next restart",
2264
+ )
2265
+ except Exception as _pexc: # pragma: no cover — defensive
2266
+ logger.warning("automatic backend promotion crashed (non-fatal): %s", _pexc)
2267
+ application.state.scale_autopromotion = {
2268
+ "attempted": True, "promoted": False, "reason": str(_pexc),
2269
+ }
2270
+
2178
2271
  # S9-DASH-02: start the outcome-queue worker so recall →
2179
2272
  # pending_outcomes is actually produced. Before v3.4.22 this
2180
2273
  # producer had zero callers and the closed-loop pipeline was
@@ -3041,6 +3134,18 @@ async def lifespan(application: FastAPI):
3041
3134
  await _cancel_fact_entity_association_repair(application)
3042
3135
  await _cancel_source_quality_repair(application)
3043
3136
 
3137
+ # Stop the projection drain. Its queue is durable, so an interrupted pass
3138
+ # costs a repeat of idempotent work and nothing else — but a live worker
3139
+ # writing into RocksDB and Lance while the process tears down around it has
3140
+ # no upside.
3141
+ try:
3142
+ from superlocalmemory.core.backend_orchestrator import get_orchestrator
3143
+ _orch = get_orchestrator()
3144
+ if _orch is not None:
3145
+ _orch.stop()
3146
+ except Exception as exc: # pragma: no cover — defensive
3147
+ logger.warning("projection drain shutdown failed: %s", exc)
3148
+
3044
3149
  # Cancel the cross-platform sync loop (H-CONC-2) so adapter file I/O does
3045
3150
  # not outlive the daemon.
3046
3151
  try:
@@ -4072,12 +4177,26 @@ def _register_dashboard_routes(application: FastAPI) -> None:
4072
4177
  # 4.0.10: asset ?v= strings are now derived from file content instead of
4073
4178
  # being hand-written literals that tracked nothing. See
4074
4179
  # server/asset_versions.py — including what that does and does not fix.
4075
- from superlocalmemory.server.asset_versions import render_index
4180
+ # Asset versioning is cosmetic. It must never be why this page 500s.
4181
+ #
4182
+ # The import is deferred (house style, keeps startup lean), which means
4183
+ # it resolves at REQUEST time — so when `pip install -e .` replaced the
4184
+ # installed package underneath a running daemon, this route began
4185
+ # answering "Internal Server Error" on the dashboard while every other
4186
+ # endpoint was fine. A stale hand-written version string is a trifle; a
4187
+ # blank page is not. Fall back to the file as written.
4188
+ try:
4189
+ from superlocalmemory.server.asset_versions import render_index
4076
4190
 
4077
- return render_index(
4078
- index_path, UI_DIR,
4079
- substitutions={"__SLM_VERSION__": _SLM_VERSION},
4080
- )
4191
+ return render_index(
4192
+ index_path, UI_DIR, substitutions={"__SLM_VERSION__": _SLM_VERSION},
4193
+ )
4194
+ except Exception as exc: # noqa: BLE001 — serve the page regardless
4195
+ logger.warning(
4196
+ "asset version rewrite unavailable, serving index.html as "
4197
+ "written: %s: %s", type(exc).__name__, exc,
4198
+ )
4199
+ return index_path.read_text().replace("__SLM_VERSION__", _SLM_VERSION)
4081
4200
 
4082
4201
  @application.get("/favicon.ico", include_in_schema=False)
4083
4202
  async def favicon():
@@ -4177,6 +4296,20 @@ def _register_daemon_routes(application: FastAPI) -> None:
4177
4296
  "embedding": embedding_ready,
4178
4297
  "recall_health": recall_health.get("recall_healthy") is True,
4179
4298
  "migration_failures": migration_failures,
4299
+ # WHY each one failed, not just which. The runner already produces
4300
+ # a precise sentence per migration -- "safe repair did not restore
4301
+ # M043_...", "schema verification failed ... : <sqlite error>" --
4302
+ # and this endpoint computed it and then dropped it on the floor.
4303
+ # A migration recorded ``complete`` in migration_log can still be
4304
+ # reported failed here, because a completed migration is re-checked
4305
+ # by its own verify() on every start; with only a name to go on,
4306
+ # that reads as the health check contradicting the database. It is
4307
+ # not: they are answering different questions, and this is the
4308
+ # sentence that says which. Reported as #125.
4309
+ "migration_failure_reasons": {
4310
+ name: str(migration_details.get(name, "(no detail recorded)"))
4311
+ for name in migration_failures
4312
+ },
4180
4313
  }
4181
4314
  readiness["retrieval"] = bool(
4182
4315
  readiness["embedding"] and readiness["recall_health"]
@@ -4255,6 +4388,12 @@ def _register_daemon_routes(application: FastAPI) -> None:
4255
4388
  # reveal staleness on its own. Loopback-only, alongside the other
4256
4389
  # operational metadata.
4257
4390
  "version_integrity": _version_integrity_payload(),
4391
+ # How far behind the second graph store is. A drain that stops
4392
+ # advancing is the failure that does not announce itself: every
4393
+ # other signal here stays green while the graph quietly diverges
4394
+ # from the record, and the only symptom is answers that are subtly
4395
+ # worse. A depth that does not fall is the thing to alert on.
4396
+ "projection": _projection_health(),
4258
4397
  }
4259
4398
 
4260
4399
  @application.get("/recall")
@@ -4909,6 +5048,7 @@ def _register_daemon_routes(application: FastAPI) -> None:
4909
5048
  fact_count = 0
4910
5049
  entity_count = 0
4911
5050
  edge_count = 0
5051
+ projection_queue_depth = 0
4912
5052
  if engine is not None:
4913
5053
  try:
4914
5054
  fact_count = engine._db.get_fact_count(profile_snapshot.profile_id)
@@ -4925,6 +5065,11 @@ def _register_daemon_routes(application: FastAPI) -> None:
4925
5065
  edge_count = int(dict(edges[0])["c"]) if edges else 0
4926
5066
  except Exception:
4927
5067
  logger.debug("daemon status count query failed", exc_info=True)
5068
+ try:
5069
+ from superlocalmemory.storage import projection_outbox
5070
+ projection_queue_depth = projection_outbox.depth(engine._db)
5071
+ except Exception:
5072
+ logger.debug("projection queue depth unavailable", exc_info=True)
4928
5073
  db_path = getattr(config, "db_path", None)
4929
5074
  db_size_mb = (
4930
5075
  round(db_path.stat().st_size / 1024 / 1024, 2)
@@ -4950,6 +5095,10 @@ def _register_daemon_routes(application: FastAPI) -> None:
4950
5095
  "legacy_port": _LEGACY_PORT,
4951
5096
  "profile": profile_snapshot.profile_id,
4952
5097
  "profile_generation": profile_snapshot.generation,
5098
+ # Facts stored but not yet in the graph and vector projections. The
5099
+ # CLI and MCP read their own status from here, so this is where the
5100
+ # number has to be for all three surfaces to agree.
5101
+ "projection_queue_depth": projection_queue_depth,
4953
5102
  # F2 fix: expose M028 backfill progress so operators can monitor
4954
5103
  # the post-upgrade fact/entity association repair state.
4955
5104
  "m028_backfill": getattr(
@@ -5561,6 +5710,38 @@ def _terminalize_orphan_operation(engine, operation_id: str) -> None:
5561
5710
  )
5562
5711
 
5563
5712
 
5713
+ def _projection_health() -> dict:
5714
+ """Queue depth, stall count, and whether the worker is running.
5715
+
5716
+ Never raises: a health endpoint that fails because one of its fields could
5717
+ not be computed is worse than the missing field.
5718
+
5719
+ Takes no application. It used to accept one and never read it -- the
5720
+ orchestrator is a process singleton -- which made the signature claim a
5721
+ dependency the body did not have, and made a test that handed it a broken
5722
+ application look like it was exercising the failure path when it was only
5723
+ observing whatever the process had already built. Compare
5724
+ ``_ops_failure_counts`` directly below, which takes an application because it
5725
+ genuinely reads one.
5726
+ """
5727
+ try:
5728
+ from superlocalmemory.core.backend_orchestrator import get_orchestrator
5729
+
5730
+ orchestrator = get_orchestrator()
5731
+ if orchestrator is None:
5732
+ return {"available": False}
5733
+ health = dict(orchestrator.outbox_health())
5734
+ health["available"] = True
5735
+ # One boolean an alert can key on without knowing what a healthy depth
5736
+ # looks like on this store.
5737
+ health["behind"] = bool(health.get("depth", 0)) or bool(
5738
+ health.get("stalled", 0)
5739
+ )
5740
+ return health
5741
+ except Exception as exc: # noqa: BLE001
5742
+ return {"available": False, "error": str(exc)[:120]}
5743
+
5744
+
5564
5745
  def _ops_failure_counts(engine, application) -> dict:
5565
5746
  """Return operational failure counts for /status and /health.
5566
5747
 
@@ -156,6 +156,16 @@ from superlocalmemory.storage.migrations import (
156
156
  from superlocalmemory.storage.migrations import (
157
157
  M043_quarantine_display_summaries as _M043,
158
158
  )
159
+ from superlocalmemory.storage.migrations import (
160
+ M044_play_carries_its_own_evidence as _M044,
161
+ )
162
+ from superlocalmemory.storage.migrations import (
163
+ M045_fact_outcome_score as _M045,
164
+ M046_prospective_memory_has_its_own_name as _M046,
165
+ M047_fisher_vectors_are_stored_like_every_other_vector as _M047,
166
+ M048_upcoming_holds_only_what_is_upcoming as _M048,
167
+ M049_a_schema_version_marker_is_one_row as _M049,
168
+ )
159
169
 
160
170
  # Emit under the runner's logger name so operational log filters that key on
161
171
  # "superlocalmemory.storage.migration_runner" keep matching after this split.
@@ -207,6 +217,12 @@ _MODULES = {
207
217
  _M041.NAME: _M041,
208
218
  _M042.NAME: _M042,
209
219
  _M043.NAME: _M043,
220
+ _M044.NAME: _M044,
221
+ _M045.NAME: _M045,
222
+ _M046.NAME: _M046,
223
+ _M047.NAME: _M047,
224
+ _M048.NAME: _M048,
225
+ _M049.NAME: _M049,
210
226
  }
211
227
 
212
228
  # Exact historical DDL fingerprints whose resulting schema is intentionally
@@ -245,9 +261,24 @@ def _ddl_hash(ddl: str) -> str:
245
261
  return hashlib.sha256(ddl.encode("utf-8")).hexdigest()
246
262
 
247
263
 
264
+ #: How long a migration waits for a database another process is holding.
265
+ #:
266
+ #: Without this, SQLite raises SQLITE_BUSY the instant a write lock is taken,
267
+ #: and a migration that runs while the daemon happens to be writing is recorded
268
+ #: as failed rather than retried. A migration that rebuilds a table takes an
269
+ #: exclusive lock, so it is exactly the one most likely to collide — and its
270
+ #: failure leaves an upgrade wedged until someone notices.
271
+ #:
272
+ #: Fifteen seconds is longer than any single write this codebase performs and
273
+ #: short enough that a genuinely stuck lock still surfaces as a failure rather
274
+ #: than a hang.
275
+ _MIGRATION_BUSY_TIMEOUT_MS = 15_000
276
+
277
+
248
278
  def _connect(db_path: Path) -> sqlite3.Connection:
249
279
  # isolation_level=None → we manage transactions explicitly via DDL.
250
280
  conn = sqlite3.connect(db_path, isolation_level=None)
281
+ conn.execute(f"PRAGMA busy_timeout = {_MIGRATION_BUSY_TIMEOUT_MS};")
251
282
  conn.execute("PRAGMA foreign_keys = OFF;")
252
283
  return conn
253
284
 
@@ -20,10 +20,31 @@ from __future__ import annotations
20
20
  import sqlite3
21
21
  from pathlib import Path
22
22
 
23
- #: Highest schema_version this runner can write. Matches the trailing serial
24
- #: of the latest migration (M042). Increment when adding new migrations or
23
+ #: Highest schema_version this runner can write. Matches the trailing serial of
24
+ #: the latest migration (M049). Increment when adding new migrations or
25
25
  #: table-level breaking changes.
26
- SUPPORTED_SCHEMA_VERSION: int = 42
26
+ #:
27
+ #: This sat at 42 while M043, M044 and M045 shipped, so for three migrations the
28
+ #: ceiling did not move and nothing prevented an older build from opening a
29
+ #: newer store. That was tolerable only because those three were additive: a
30
+ #: build that did not know about a new column or table simply never read it.
31
+ #:
32
+ #: M046 is not additive. It rebuilds ``atomic_facts`` with a constraint that
33
+ #: rejects the value an older build classifies planned events as, so an older
34
+ #: writer against a migrated store fails its INSERT. The ceiling is what turns
35
+ #: that from a lost memory into a refusal to start, which is why it moves here
36
+ #: and why it moves to the trailing serial rather than to 43.
37
+ #:
38
+ #: M049 is additive — a unique index on ``schema_version`` plus the removal of
39
+ #: the duplicate rows that index could not otherwise be created over — so an
40
+ #: older build could in principle read a store it has touched. The ceiling
41
+ #: still moves, because the convention is "trailing serial, always": the cost of
42
+ #: moving it for an additive migration is an older build declining a store it
43
+ #: could have read, and the cost of NOT moving it for one that turns out not to
44
+ #: be additive is a silent bad write. Those are not comparable, and judging
45
+ #: additivity per migration is exactly the judgement that let it fall three
46
+ #: behind.
47
+ SUPPORTED_SCHEMA_VERSION: int = 49
27
48
 
28
49
 
29
50
  class SchemaVersionError(RuntimeError):