superlocalmemory 4.0.9 → 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 (165) hide show
  1. package/.claude-plugin/marketplace.json +1 -1
  2. package/CHANGELOG.md +245 -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 +308 -20
  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 +26 -4
  44. package/src/superlocalmemory/code_graph/bridge/maintenance.py +8 -0
  45. package/src/superlocalmemory/code_graph/database.py +44 -0
  46. package/src/superlocalmemory/compliance/gdpr.py +449 -39
  47. package/src/superlocalmemory/core/admission.py +231 -11
  48. package/src/superlocalmemory/core/backend_orchestrator.py +190 -84
  49. package/src/superlocalmemory/core/config.py +90 -11
  50. package/src/superlocalmemory/core/consolidation_engine.py +34 -0
  51. package/src/superlocalmemory/core/engine.py +140 -11
  52. package/src/superlocalmemory/core/fact_consolidator.py +316 -125
  53. package/src/superlocalmemory/core/graph_analyzer.py +76 -112
  54. package/src/superlocalmemory/core/graph_metrics.py +597 -0
  55. package/src/superlocalmemory/core/graph_pruner.py +121 -0
  56. package/src/superlocalmemory/core/maintenance.py +44 -6
  57. package/src/superlocalmemory/core/maintenance_scheduler.py +205 -0
  58. package/src/superlocalmemory/core/memory_health.py +266 -0
  59. package/src/superlocalmemory/core/mode_capability.py +111 -0
  60. package/src/superlocalmemory/core/ollama_validator.py +315 -0
  61. package/src/superlocalmemory/core/operation_policy_registry.py +1 -1
  62. package/src/superlocalmemory/core/operation_request.py +1 -1
  63. package/src/superlocalmemory/core/ops_remediation.py +2 -2
  64. package/src/superlocalmemory/core/projection_drain.py +380 -0
  65. package/src/superlocalmemory/core/recall_pipeline.py +390 -3
  66. package/src/superlocalmemory/core/recall_worker.py +6 -3
  67. package/src/superlocalmemory/core/scale_autopromote.py +196 -0
  68. package/src/superlocalmemory/core/scale_engine.py +16 -2
  69. package/src/superlocalmemory/core/score_contract.py +21 -1
  70. package/src/superlocalmemory/core/session_identity.py +85 -0
  71. package/src/superlocalmemory/core/status_contract.py +108 -0
  72. package/src/superlocalmemory/core/store_pipeline.py +78 -3
  73. package/src/superlocalmemory/core/worker_pool.py +4 -4
  74. package/src/superlocalmemory/core/working_memory.py +288 -0
  75. package/src/superlocalmemory/encoding/cognitive_consolidator.py +51 -7
  76. package/src/superlocalmemory/encoding/context_generator.py +1 -1
  77. package/src/superlocalmemory/encoding/entity_resolver.py +38 -0
  78. package/src/superlocalmemory/encoding/fact_extractor.py +18 -14
  79. package/src/superlocalmemory/encoding/prospective_markers.py +262 -0
  80. package/src/superlocalmemory/encoding/type_router.py +12 -12
  81. package/src/superlocalmemory/evolution/mutation_generator.py +30 -4
  82. package/src/superlocalmemory/graph/cozo_adjacency.py +122 -0
  83. package/src/superlocalmemory/graph/cozo_backend.py +103 -138
  84. package/src/superlocalmemory/hooks/portable_kit.py +10 -2
  85. package/src/superlocalmemory/learning/bandit.py +43 -0
  86. package/src/superlocalmemory/learning/consolidation_worker.py +54 -0
  87. package/src/superlocalmemory/learning/database.py +60 -3
  88. package/src/superlocalmemory/learning/entity_compiler.py +21 -58
  89. package/src/superlocalmemory/learning/feedback.py +3 -1
  90. package/src/superlocalmemory/learning/outcomes.py +47 -16
  91. package/src/superlocalmemory/learning/pattern_miner.py +28 -3
  92. package/src/superlocalmemory/learning/pattern_miner_constants.py +43 -0
  93. package/src/superlocalmemory/learning/pcos.py +291 -0
  94. package/src/superlocalmemory/learning/reward_from_outcomes.py +365 -0
  95. package/src/superlocalmemory/learning/reward_proxy.py +100 -10
  96. package/src/superlocalmemory/learning/signal_kinds.py +79 -0
  97. package/src/superlocalmemory/mcp/profiles.py +14 -2
  98. package/src/superlocalmemory/mcp/server.py +1 -1
  99. package/src/superlocalmemory/mcp/session_binding.py +92 -0
  100. package/src/superlocalmemory/mcp/tools_active.py +2 -1
  101. package/src/superlocalmemory/mcp/tools_core.py +71 -42
  102. package/src/superlocalmemory/mcp/tools_ops.py +2 -2
  103. package/src/superlocalmemory/mcp/tools_v28.py +20 -1
  104. package/src/superlocalmemory/parameterization/pattern_extractor.py +14 -1
  105. package/src/superlocalmemory/parameterization/soft_prompt_generator.py +98 -0
  106. package/src/superlocalmemory/retrieval/bm25_channel.py +68 -11
  107. package/src/superlocalmemory/retrieval/channel_status.py +117 -0
  108. package/src/superlocalmemory/retrieval/engine.py +106 -11
  109. package/src/superlocalmemory/retrieval/entity_channel.py +217 -257
  110. package/src/superlocalmemory/retrieval/graph_adjacency.py +219 -0
  111. package/src/superlocalmemory/retrieval/scope_policy.py +42 -1
  112. package/src/superlocalmemory/retrieval/semantic_channel.py +47 -5
  113. package/src/superlocalmemory/retrieval/spreading.py +288 -0
  114. package/src/superlocalmemory/retrieval/temporal_channel.py +13 -1
  115. package/src/superlocalmemory/retrieval/vector_store.py +63 -0
  116. package/src/superlocalmemory/server/api.py +26 -2
  117. package/src/superlocalmemory/server/asset_versions.py +171 -0
  118. package/src/superlocalmemory/server/bandit_loops.py +17 -1
  119. package/src/superlocalmemory/server/rbac_enforce.py +26 -6
  120. package/src/superlocalmemory/server/recall_serializer.py +9 -0
  121. package/src/superlocalmemory/server/routes/abstraction.py +201 -0
  122. package/src/superlocalmemory/server/routes/behavioral.py +75 -10
  123. package/src/superlocalmemory/server/routes/compliance.py +98 -18
  124. package/src/superlocalmemory/server/routes/config_api.py +186 -4
  125. package/src/superlocalmemory/server/routes/data_io.py +29 -1
  126. package/src/superlocalmemory/server/routes/entity.py +13 -1
  127. package/src/superlocalmemory/server/routes/evolution.py +178 -0
  128. package/src/superlocalmemory/server/routes/ingest.py +8 -0
  129. package/src/superlocalmemory/server/routes/learning_telemetry.py +2 -1
  130. package/src/superlocalmemory/server/routes/memories.py +49 -7
  131. package/src/superlocalmemory/server/routes/mesh.py +1 -1
  132. package/src/superlocalmemory/server/routes/timeline.py +4 -0
  133. package/src/superlocalmemory/server/routes/v3_api.py +193 -17
  134. package/src/superlocalmemory/server/ui.py +24 -1
  135. package/src/superlocalmemory/server/unified_daemon.py +292 -9
  136. package/src/superlocalmemory/storage/_migration_internals.py +35 -0
  137. package/src/superlocalmemory/storage/_schema_version.py +24 -3
  138. package/src/superlocalmemory/storage/database.py +598 -82
  139. package/src/superlocalmemory/storage/embedding_codec.py +71 -0
  140. package/src/superlocalmemory/storage/lineage_retention.py +236 -0
  141. package/src/superlocalmemory/storage/logical_edges.py +43 -2
  142. package/src/superlocalmemory/storage/migration_runner.py +130 -0
  143. package/src/superlocalmemory/storage/migrations/M043_quarantine_display_summaries.py +488 -0
  144. package/src/superlocalmemory/storage/migrations/M044_play_carries_its_own_evidence.py +127 -0
  145. package/src/superlocalmemory/storage/migrations/M045_fact_outcome_score.py +158 -0
  146. package/src/superlocalmemory/storage/migrations/M046_prospective_memory_has_its_own_name.py +620 -0
  147. package/src/superlocalmemory/storage/migrations/M047_fisher_vectors_are_stored_like_every_other_vector.py +306 -0
  148. package/src/superlocalmemory/storage/migrations/M048_upcoming_holds_only_what_is_upcoming.py +207 -0
  149. package/src/superlocalmemory/storage/migrations/M049_a_schema_version_marker_is_one_row.py +201 -0
  150. package/src/superlocalmemory/storage/migrations.py +18 -2
  151. package/src/superlocalmemory/storage/models.py +40 -1
  152. package/src/superlocalmemory/storage/projection_outbox.py +346 -0
  153. package/src/superlocalmemory/storage/retention_policy.py +860 -0
  154. package/src/superlocalmemory/storage/schema.py +110 -1
  155. package/src/superlocalmemory/storage/write_coordinator.py +19 -2
  156. package/src/superlocalmemory/summaries/base.py +1 -1
  157. package/src/superlocalmemory/summaries/non_answer.py +223 -0
  158. package/src/superlocalmemory/trust/scorer.py +43 -1
  159. package/src/superlocalmemory/ui/index.html +10 -19
  160. package/src/superlocalmemory/ui/js/event-delegation.js +12 -1
  161. package/src/superlocalmemory/ui/js/od-health.js +28 -6
  162. package/src/superlocalmemory/ui/js/od-memories.js +209 -1
  163. package/src/superlocalmemory/ui/js/od-ops-health.js +1 -1
  164. package/src/superlocalmemory/ui/js/od-settings.js +87 -1
  165. package/src/superlocalmemory/ui/js/recall-lab.js +78 -3
@@ -231,7 +231,30 @@ def create_app() -> FastAPI:
231
231
  "<p><a href='/api/docs'>API Documentation</a></p>"
232
232
  "</body></html>"
233
233
  )
234
- return index_path.read_text()
234
+ from superlocalmemory import __version__ as _v
235
+
236
+ # __SLM_VERSION__ was substituted only by the unified daemon, so the
237
+ # dashboard's upgrade detector did nothing when served from here.
238
+ # Asset versioning is cosmetic. It must never be why this page 500s.
239
+ #
240
+ # The import is deferred (house style, keeps startup lean), which means
241
+ # it resolves at REQUEST time — so when `pip install -e .` replaced the
242
+ # installed package underneath a running daemon, this route began
243
+ # answering "Internal Server Error" on the dashboard while every other
244
+ # endpoint was fine. A stale hand-written version string is a trifle; a
245
+ # blank page is not. Fall back to the file as written.
246
+ try:
247
+ from superlocalmemory.server.asset_versions import render_index
248
+
249
+ return render_index(
250
+ index_path, UI_DIR, substitutions={"__SLM_VERSION__": _v},
251
+ )
252
+ except Exception as exc: # noqa: BLE001 — serve the page regardless
253
+ logger.warning(
254
+ "asset version rewrite unavailable, serving index.html as "
255
+ "written: %s: %s", type(exc).__name__, exc,
256
+ )
257
+ return index_path.read_text().replace("__SLM_VERSION__", _v)
235
258
 
236
259
  @application.get("/favicon.ico", include_in_schema=False)
237
260
  async def favicon():
@@ -52,7 +52,7 @@ os.environ.setdefault("SLM_MCP_EMBEDDED", "1")
52
52
  from fastapi import FastAPI, HTTPException, Request
53
53
  from fastapi.middleware.cors import CORSMiddleware
54
54
  from fastapi.middleware.gzip import GZipMiddleware
55
- from pydantic import BaseModel
55
+ from pydantic import BaseModel, field_validator
56
56
 
57
57
  from superlocalmemory.core.config import CANONICAL_RECALL_LIMIT
58
58
  from superlocalmemory.infra.daemon_identity import (
@@ -587,6 +587,62 @@ class RememberRequest(BaseModel):
587
587
  metadata: dict | None = None # v3.4.26: pass-through from MCP pool_store
588
588
  idempotency_key: str | None = None
589
589
  session_id: str = ""
590
+ #: WHEN this memory is about, as distinct from when it was written.
591
+ #:
592
+ #: The internal admission record has carried this field all along and this
593
+ #: model never had it, so every memory arriving over HTTP — which is every
594
+ #: memory, from the CLI, the tool interface and the dashboard alike — was
595
+ #: stamped with its ingestion date. Measured on the author's store: 200 of
596
+ #: the 200 most recent facts have an observation_date, and 196 of them are
597
+ #: the day they were written. A store that cannot be told "this happened in
598
+ #: March" cannot answer a question about March.
599
+ #:
600
+ #: Empty means "today", the previous behaviour. Format is YYYY-MM-DD or a
601
+ #: full ISO 8601 timestamp.
602
+ session_date: str = ""
603
+
604
+ @field_validator("session_date")
605
+ @classmethod
606
+ def _session_date_is_a_date(cls, value: str) -> str:
607
+ """Reject a malformed date rather than ignore it.
608
+
609
+ Dropping it silently would leave the caller believing the date was
610
+ recorded while the memory quietly filed itself under today — which is
611
+ the exact failure this field exists to fix, reintroduced one layer up.
612
+ Rejecting is recoverable: the caller sees the error and resends.
613
+
614
+ ISO ONLY, DELIBERATELY, even though this system ships a parser that is
615
+ far more permissive. ``encoding/temporal_parser.parse_session_date``
616
+ accepts "May 8, 2026", "1:56 pm on 8 May, 2026", "14/03/2026", and also
617
+ "last tuesday" and "March 2026" — and those last two are why it is not
618
+ used here. Asked on 2026-08-21 it resolves "last tuesday" to
619
+ **2026-08-25**, four days into the future, and "March 2026" to the 21st,
620
+ a day it invents. Accepting either at this boundary would file a memory
621
+ under a confidently wrong date, silently, which is precisely the class
622
+ of defect this field was added to remove.
623
+
624
+ The caller here is a program — the command line, the tool interface, the
625
+ dashboard — not a person typing. ISO is the right contract for a
626
+ program, and a caller holding a human-typed date can run it through the
627
+ parser itself and send the result.
628
+ """
629
+ if not value:
630
+ return value
631
+ from datetime import datetime as _dt
632
+
633
+ text = value.strip()
634
+ try:
635
+ _dt.fromisoformat(text.replace("Z", "+00:00"))
636
+ except ValueError:
637
+ raise ValueError(
638
+ "session_date must be YYYY-MM-DD or a full ISO 8601 timestamp; "
639
+ f"got {value!r}. To accept looser human phrasing, parse it "
640
+ "first with TemporalParser.parse_session_date and send the ISO "
641
+ "result — but check what it returns, because it resolves "
642
+ "relative phrases against today and can answer with a future "
643
+ "date."
644
+ ) from None
645
+ return text
590
646
  # v3.6.15 multi-scope: visibility of the new memory. ``None`` scope means
591
647
  # "use the configured default_scope" (personal). shared_with is the list of
592
648
  # profile_ids for scope='shared'.
@@ -1748,6 +1804,41 @@ def _stop_deployment_retention(application) -> bool:
1748
1804
  return True
1749
1805
 
1750
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
+
1751
1842
  @asynccontextmanager
1752
1843
  async def lifespan(application: FastAPI):
1753
1844
  """Initialize engine, workers, and optional services on startup."""
@@ -2032,6 +2123,40 @@ async def lifespan(application: FastAPI):
2032
2123
  engine = MemoryEngine(config)
2033
2124
  engine.initialize()
2034
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
+
2035
2160
  # Refresh migration state now that the engine is initialised. Any
2036
2161
  # schema work the engine's own bootstrap may have applied (e.g.
2037
2162
  # runtime-table creation) is captured here so the dashboard and
@@ -2119,6 +2244,30 @@ async def lifespan(application: FastAPI):
2119
2244
  "deferred migration runner crashed (non-fatal): %s", _dexc,
2120
2245
  )
2121
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
+
2122
2271
  # S9-DASH-02: start the outcome-queue worker so recall →
2123
2272
  # pending_outcomes is actually produced. Before v3.4.22 this
2124
2273
  # producer had zero callers and the closed-loop pipeline was
@@ -2399,6 +2548,10 @@ async def lifespan(application: FastAPI):
2399
2548
  "state": "checking_components", "embeddings_backfilled": 0,
2400
2549
  "expansion_backfilled": 0, "null_remaining": None,
2401
2550
  "components": None,
2551
+ # Declared here so /status carries the same keys whatever
2552
+ # happens; a field that only appears on failure is a field
2553
+ # nobody's dashboard renders.
2554
+ "incomplete_reason": None,
2402
2555
  "started_at": _t.time(), "finished_at": None,
2403
2556
  }
2404
2557
  # Step 0 (v3.8.2 "whole self-healer"): repair components that
@@ -2507,9 +2660,42 @@ async def lifespan(application: FastAPI):
2507
2660
  _backfill_vector_store()
2508
2661
  except Exception as exc:
2509
2662
  logger.warning("Self-heal vector index failed (non-fatal): %s", exc)
2510
- _SELF_HEAL_STATUS["state"] = "complete"
2663
+
2664
+ # "complete" has to mean complete.
2665
+ #
2666
+ # This line used to set "complete" unconditionally, so a
2667
+ # backfill that gave up after five no-progress attempts, or ran
2668
+ # out its 500-iteration budget, reported success with facts
2669
+ # still unembedded — and an unembedded fact cannot be found by
2670
+ # meaning at all. That is how a machine sat at 56.3% of its
2671
+ # memory reachable while its own status endpoint said the heal
2672
+ # had finished. Nobody was going to look past a green light.
2673
+ #
2674
+ # Now the state is derived from the remaining count, and the
2675
+ # gap is named in plain language for the dashboard, so an
2676
+ # incomplete heal is visible and gets retried on next start
2677
+ # instead of being declared done forever.
2678
+ _remaining = _SELF_HEAL_STATUS.get("null_remaining")
2511
2679
  _SELF_HEAL_STATUS["finished_at"] = _t.time()
2512
- logger.info("Self-heal complete: %s", _SELF_HEAL_STATUS)
2680
+ if _remaining is None:
2681
+ _SELF_HEAL_STATUS["state"] = "complete"
2682
+ elif int(_remaining) > 0:
2683
+ _SELF_HEAL_STATUS["state"] = "incomplete"
2684
+ _SELF_HEAL_STATUS["incomplete_reason"] = (
2685
+ f"{int(_remaining)} memories still have no meaning "
2686
+ f"vector, so they cannot be found by asking a question. "
2687
+ f"This retries automatically next time the service "
2688
+ f"starts. It usually means the embedding model was "
2689
+ f"unavailable."
2690
+ )
2691
+ logger.warning(
2692
+ "Self-heal INCOMPLETE: %d facts still unembedded and "
2693
+ "therefore unreachable by meaning; will retry on next "
2694
+ "start. %s", int(_remaining), _SELF_HEAL_STATUS,
2695
+ )
2696
+ else:
2697
+ _SELF_HEAL_STATUS["state"] = "complete"
2698
+ logger.info("Self-heal complete: %s", _SELF_HEAL_STATUS)
2513
2699
  except Exception as exc:
2514
2700
  _SELF_HEAL_STATUS["state"] = "error"
2515
2701
  logger.warning("Self-heal failed (non-fatal): %s", exc)
@@ -2948,6 +3134,18 @@ async def lifespan(application: FastAPI):
2948
3134
  await _cancel_fact_entity_association_repair(application)
2949
3135
  await _cancel_source_quality_repair(application)
2950
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
+
2951
3149
  # Cancel the cross-platform sync loop (H-CONC-2) so adapter file I/O does
2952
3150
  # not outlive the daemon.
2953
3151
  try:
@@ -3975,8 +4173,30 @@ def _register_dashboard_routes(application: FastAPI) -> None:
3975
4173
  # v3.4.23: substitute version placeholder so the dashboard can detect
3976
4174
  # upgrades and auto-reload. Read fresh each request (daemon uptime is
3977
4175
  # days, but we want zero caching surprises during development).
3978
- html = index_path.read_text()
3979
- return html.replace("__SLM_VERSION__", _SLM_VERSION)
4176
+ #
4177
+ # 4.0.10: asset ?v= strings are now derived from file content instead of
4178
+ # being hand-written literals that tracked nothing. See
4179
+ # server/asset_versions.py — including what that does and does not fix.
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
4190
+
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)
3980
4200
 
3981
4201
  @application.get("/favicon.ico", include_in_schema=False)
3982
4202
  async def favicon():
@@ -4076,6 +4296,20 @@ def _register_daemon_routes(application: FastAPI) -> None:
4076
4296
  "embedding": embedding_ready,
4077
4297
  "recall_health": recall_health.get("recall_healthy") is True,
4078
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
+ },
4079
4313
  }
4080
4314
  readiness["retrieval"] = bool(
4081
4315
  readiness["embedding"] and readiness["recall_health"]
@@ -4146,7 +4380,7 @@ def _register_daemon_routes(application: FastAPI) -> None:
4146
4380
  "runtime_state": runtime_state,
4147
4381
  "active_profile": profile_snapshot.profile_id,
4148
4382
  "profile_generation": profile_snapshot.generation,
4149
- # Wave-3: operational failure counts (visible to all team members)
4383
+ # operational failure counts (visible to all team members)
4150
4384
  **_ops_failure_counts(engine, application),
4151
4385
  # issue #107: does this daemon's *imported* code still match the
4152
4386
  # installed distribution? ``version`` above reports what this
@@ -4154,6 +4388,12 @@ def _register_daemon_routes(application: FastAPI) -> None:
4154
4388
  # reveal staleness on its own. Loopback-only, alongside the other
4155
4389
  # operational metadata.
4156
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(),
4157
4397
  }
4158
4398
 
4159
4399
  @application.get("/recall")
@@ -4513,6 +4753,7 @@ def _register_daemon_routes(application: FastAPI) -> None:
4513
4753
  shared_with=tuple(shared_with or ()),
4514
4754
  trusted_actor_id=trusted_actor_id,
4515
4755
  session_id=req.session_id,
4756
+ session_date=req.session_date,
4516
4757
  )
4517
4758
  actor = Actor(
4518
4759
  principal_id=trusted_actor_id,
@@ -4807,6 +5048,7 @@ def _register_daemon_routes(application: FastAPI) -> None:
4807
5048
  fact_count = 0
4808
5049
  entity_count = 0
4809
5050
  edge_count = 0
5051
+ projection_queue_depth = 0
4810
5052
  if engine is not None:
4811
5053
  try:
4812
5054
  fact_count = engine._db.get_fact_count(profile_snapshot.profile_id)
@@ -4823,6 +5065,11 @@ def _register_daemon_routes(application: FastAPI) -> None:
4823
5065
  edge_count = int(dict(edges[0])["c"]) if edges else 0
4824
5066
  except Exception:
4825
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)
4826
5073
  db_path = getattr(config, "db_path", None)
4827
5074
  db_size_mb = (
4828
5075
  round(db_path.stat().st_size / 1024 / 1024, 2)
@@ -4848,6 +5095,10 @@ def _register_daemon_routes(application: FastAPI) -> None:
4848
5095
  "legacy_port": _LEGACY_PORT,
4849
5096
  "profile": profile_snapshot.profile_id,
4850
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,
4851
5102
  # F2 fix: expose M028 backfill progress so operators can monitor
4852
5103
  # the post-upgrade fact/entity association repair state.
4853
5104
  "m028_backfill": getattr(
@@ -4859,7 +5110,7 @@ def _register_daemon_routes(application: FastAPI) -> None:
4859
5110
  # index backfill after an upgrade). Dashboard renders a plain
4860
5111
  # "Optimizing memory…" line from this. Defaults to idle before start.
4861
5112
  "self_heal": globals().get("_SELF_HEAL_STATUS", {"state": "idle"}),
4862
- # Wave-3: operational failure counts (dead-letter, degraded, stalled)
5113
+ # operational failure counts (dead-letter, degraded, stalled)
4863
5114
  **_ops_failure_counts(engine, application),
4864
5115
  }
4865
5116
 
@@ -4913,7 +5164,7 @@ def _register_daemon_routes(application: FastAPI) -> None:
4913
5164
  return {"status": "started"}
4914
5165
 
4915
5166
  # ------------------------------------------------------------------
4916
- # Wave-3: Operational Recovery & Admin Remediation (V4 resilience slice)
5167
+ # Operational Recovery & Admin Remediation (V4 resilience slice)
4917
5168
  # ------------------------------------------------------------------
4918
5169
 
4919
5170
  @application.get("/operations/failed")
@@ -5459,8 +5710,40 @@ def _terminalize_orphan_operation(engine, operation_id: str) -> None:
5459
5710
  )
5460
5711
 
5461
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
+
5462
5745
  def _ops_failure_counts(engine, application) -> dict:
5463
- """Return Wave-3 operational failure counts for /status and /health.
5746
+ """Return operational failure counts for /status and /health.
5464
5747
 
5465
5748
  Always returns a dict (never raises). Counts default to 0 on any error.
5466
5749
  Includes: dead_letter_count, degraded_operations, exhausted_obligations,
@@ -153,6 +153,19 @@ from superlocalmemory.storage.migrations import (
153
153
  from superlocalmemory.storage.migrations import (
154
154
  M042_correction_case_ledger as _M042,
155
155
  )
156
+ from superlocalmemory.storage.migrations import (
157
+ M043_quarantine_display_summaries as _M043,
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
+ )
156
169
 
157
170
  # Emit under the runner's logger name so operational log filters that key on
158
171
  # "superlocalmemory.storage.migration_runner" keep matching after this split.
@@ -203,6 +216,13 @@ _MODULES = {
203
216
  _M040.NAME: _M040,
204
217
  _M041.NAME: _M041,
205
218
  _M042.NAME: _M042,
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,
206
226
  }
207
227
 
208
228
  # Exact historical DDL fingerprints whose resulting schema is intentionally
@@ -241,9 +261,24 @@ def _ddl_hash(ddl: str) -> str:
241
261
  return hashlib.sha256(ddl.encode("utf-8")).hexdigest()
242
262
 
243
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
+
244
278
  def _connect(db_path: Path) -> sqlite3.Connection:
245
279
  # isolation_level=None → we manage transactions explicitly via DDL.
246
280
  conn = sqlite3.connect(db_path, isolation_level=None)
281
+ conn.execute(f"PRAGMA busy_timeout = {_MIGRATION_BUSY_TIMEOUT_MS};")
247
282
  conn.execute("PRAGMA foreign_keys = OFF;")
248
283
  return conn
249
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):