superlocalmemory 4.1.0 → 4.1.3

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 (47) hide show
  1. package/.claude-plugin/marketplace.json +29 -1
  2. package/CHANGELOG.md +107 -0
  3. package/README.md +37 -72
  4. package/ide/configs/codex-mcp.toml +3 -1
  5. package/package.json +4 -3
  6. package/plugin/.claude-plugin/plugin.json +1 -1
  7. package/plugin/.mcp.json +1 -3
  8. package/plugin/CLAUDE.md +3 -3
  9. package/plugin/agents/slm-governance-advisor.md +1 -1
  10. package/plugin/agents/slm-loop-runner.md +1 -1
  11. package/plugin/agents/slm-memory-advisor.md +1 -1
  12. package/plugin/agents/slm-optimize-advisor.md +1 -1
  13. package/plugin/requirements.txt +1 -1
  14. package/plugin/scripts/slm-launch +100 -31
  15. package/plugin/skills/slm-cache/SKILL.md +1 -1
  16. package/plugin/skills/slm-compress/SKILL.md +1 -1
  17. package/plugin/skills/slm-governance/SKILL.md +1 -1
  18. package/plugin/skills/slm-graph/SKILL.md +1 -1
  19. package/plugin/skills/slm-loop/SKILL.md +1 -1
  20. package/plugin/skills/slm-mesh/SKILL.md +1 -1
  21. package/plugin/skills/slm-profile/SKILL.md +1 -1
  22. package/plugin/skills/slm-recall/SKILL.md +1 -1
  23. package/plugin/skills/slm-remember/SKILL.md +1 -1
  24. package/plugin/skills/slm-scope/SKILL.md +1 -1
  25. package/plugin/skills/slm-session/SKILL.md +1 -1
  26. package/plugin/skills/slm-status/SKILL.md +1 -1
  27. package/plugin-src/rules/AGENTS.md +1 -1
  28. package/plugin-src/skills/slm-cache/SKILL.md +1 -1
  29. package/plugin-src/skills/slm-compress/SKILL.md +1 -1
  30. package/plugin-src/skills/slm-governance/SKILL.md +1 -1
  31. package/plugin-src/skills/slm-graph/SKILL.md +1 -1
  32. package/plugin-src/skills/slm-loop/SKILL.md +1 -1
  33. package/plugin-src/skills/slm-mesh/SKILL.md +1 -1
  34. package/plugin-src/skills/slm-profile/SKILL.md +1 -1
  35. package/plugin-src/skills/slm-recall/SKILL.md +1 -1
  36. package/plugin-src/skills/slm-remember/SKILL.md +1 -1
  37. package/plugin-src/skills/slm-scope/SKILL.md +1 -1
  38. package/plugin-src/skills/slm-session/SKILL.md +1 -1
  39. package/plugin-src/skills/slm-status/SKILL.md +1 -1
  40. package/pyproject.toml +1 -1
  41. package/src/superlocalmemory/__init__.py +1 -1
  42. package/src/superlocalmemory/cli/commands.py +94 -0
  43. package/src/superlocalmemory/server/recall_health.py +87 -10
  44. package/src/superlocalmemory/server/unified_daemon.py +55 -2
  45. package/src/superlocalmemory/storage/_migration_internals.py +23 -2
  46. package/src/superlocalmemory/storage/migrations/M043_quarantine_display_summaries.py +60 -36
  47. package/src/superlocalmemory/storage/schema.py +23 -0
@@ -96,4 +96,4 @@ paused, name the approval needed; when errored, quote the short detail.
96
96
 
97
97
  ---
98
98
 
99
- SuperLocalMemory v4.1.0 · Qualixar · AGPL-3.0-or-later
99
+ SuperLocalMemory v4.1.3 · Qualixar · AGPL-3.0-or-later
@@ -279,4 +279,4 @@ mesh availability.
279
279
 
280
280
  ---
281
281
 
282
- *SuperLocalMemory v4.1.0 · Qualixar · AGPL-3.0-or-later*
282
+ *SuperLocalMemory v4.1.3 · Qualixar · AGPL-3.0-or-later*
@@ -146,4 +146,4 @@ Name them differently in your MCP config (e.g. `superlocalmemory-personal` and
146
146
 
147
147
  ---
148
148
 
149
- *SuperLocalMemory v4.1.0 · Qualixar · AGPL-3.0-or-later*
149
+ *SuperLocalMemory v4.1.3 · Qualixar · AGPL-3.0-or-later*
@@ -323,4 +323,4 @@ before recalling, then switch back. See `slm-profile` for workspace switching.
323
323
 
324
324
  ---
325
325
 
326
- *SuperLocalMemory v4.1.0 · Qualixar · AGPL-3.0-or-later*
326
+ *SuperLocalMemory v4.1.3 · Qualixar · AGPL-3.0-or-later*
@@ -270,4 +270,4 @@ different workspace, use `switch_profile` first. See `slm-profile`.
270
270
 
271
271
  ---
272
272
 
273
- *SuperLocalMemory v4.1.0 · Qualixar · AGPL-3.0-or-later*
273
+ *SuperLocalMemory v4.1.3 · Qualixar · AGPL-3.0-or-later*
@@ -173,4 +173,4 @@ to review the impact. See `slm-remember` for the full deletion discipline.
173
173
 
174
174
  ---
175
175
 
176
- *SuperLocalMemory v4.1.0 · Qualixar · AGPL-3.0-or-later*
176
+ *SuperLocalMemory v4.1.3 · Qualixar · AGPL-3.0-or-later*
@@ -253,4 +253,4 @@ explicitly and call `recall` with `include_global`/`include_shared` after
253
253
 
254
254
  ---
255
255
 
256
- *SuperLocalMemory v4.1.0 · Qualixar · AGPL-3.0-or-later*
256
+ *SuperLocalMemory v4.1.3 · Qualixar · AGPL-3.0-or-later*
@@ -163,4 +163,4 @@ multi-profile setup. To switch the active profile, see `slm-profile`.
163
163
 
164
164
  ---
165
165
 
166
- SuperLocalMemory v4.1.0 · Qualixar · AGPL-3.0-or-later
166
+ SuperLocalMemory v4.1.3 · Qualixar · AGPL-3.0-or-later
@@ -137,4 +137,4 @@ When the SLM MCP server is unavailable, use these CLI equivalents:
137
137
  - **slm-optimize-advisor** — context compression and KV cache
138
138
  - **slm-governance-advisor** — scope/role compliance, retention policies, GDPR
139
139
 
140
- SuperLocalMemory v4.1.0 · Qualixar · AGPL-3.0-or-later
140
+ SuperLocalMemory v4.1.3 · Qualixar · AGPL-3.0-or-later
@@ -145,4 +145,4 @@ These subcommands control daemon-level cache settings. They do not read or write
145
145
 
146
146
  ---
147
147
 
148
- SuperLocalMemory v4.1.0 · Qualixar · AGPL-3.0-or-later
148
+ SuperLocalMemory v4.1.3 · Qualixar · AGPL-3.0-or-later
@@ -147,4 +147,4 @@ Content over 1 MB (1 000 000 bytes UTF-8) is processed but `reversible` is force
147
147
 
148
148
  ---
149
149
 
150
- SuperLocalMemory v4.1.0 · Qualixar · AGPL-3.0-or-later
150
+ SuperLocalMemory v4.1.3 · Qualixar · AGPL-3.0-or-later
@@ -245,4 +245,4 @@ Before running any destructive operation (`forget`, `compact_memories`):
245
245
 
246
246
  ---
247
247
 
248
- *SuperLocalMemory v4.1.0 · Qualixar · AGPL-3.0-or-later*
248
+ *SuperLocalMemory v4.1.3 · Qualixar · AGPL-3.0-or-later*
@@ -312,4 +312,4 @@ profile. See `slm-profile` for the full profile switching workflow.
312
312
 
313
313
  ---
314
314
 
315
- SuperLocalMemory v4.1.0 · Qualixar · AGPL-3.0-or-later
315
+ SuperLocalMemory v4.1.3 · Qualixar · AGPL-3.0-or-later
@@ -96,4 +96,4 @@ paused, name the approval needed; when errored, quote the short detail.
96
96
 
97
97
  ---
98
98
 
99
- SuperLocalMemory v4.1.0 · Qualixar · AGPL-3.0-or-later
99
+ SuperLocalMemory v4.1.3 · Qualixar · AGPL-3.0-or-later
@@ -279,4 +279,4 @@ mesh availability.
279
279
 
280
280
  ---
281
281
 
282
- *SuperLocalMemory v4.1.0 · Qualixar · AGPL-3.0-or-later*
282
+ *SuperLocalMemory v4.1.3 · Qualixar · AGPL-3.0-or-later*
@@ -146,4 +146,4 @@ Name them differently in your MCP config (e.g. `superlocalmemory-personal` and
146
146
 
147
147
  ---
148
148
 
149
- *SuperLocalMemory v4.1.0 · Qualixar · AGPL-3.0-or-later*
149
+ *SuperLocalMemory v4.1.3 · Qualixar · AGPL-3.0-or-later*
@@ -323,4 +323,4 @@ before recalling, then switch back. See `slm-profile` for workspace switching.
323
323
 
324
324
  ---
325
325
 
326
- *SuperLocalMemory v4.1.0 · Qualixar · AGPL-3.0-or-later*
326
+ *SuperLocalMemory v4.1.3 · Qualixar · AGPL-3.0-or-later*
@@ -270,4 +270,4 @@ different workspace, use `switch_profile` first. See `slm-profile`.
270
270
 
271
271
  ---
272
272
 
273
- *SuperLocalMemory v4.1.0 · Qualixar · AGPL-3.0-or-later*
273
+ *SuperLocalMemory v4.1.3 · Qualixar · AGPL-3.0-or-later*
@@ -173,4 +173,4 @@ to review the impact. See `slm-remember` for the full deletion discipline.
173
173
 
174
174
  ---
175
175
 
176
- *SuperLocalMemory v4.1.0 · Qualixar · AGPL-3.0-or-later*
176
+ *SuperLocalMemory v4.1.3 · Qualixar · AGPL-3.0-or-later*
@@ -253,4 +253,4 @@ explicitly and call `recall` with `include_global`/`include_shared` after
253
253
 
254
254
  ---
255
255
 
256
- *SuperLocalMemory v4.1.0 · Qualixar · AGPL-3.0-or-later*
256
+ *SuperLocalMemory v4.1.3 · Qualixar · AGPL-3.0-or-later*
@@ -163,4 +163,4 @@ multi-profile setup. To switch the active profile, see `slm-profile`.
163
163
 
164
164
  ---
165
165
 
166
- SuperLocalMemory v4.1.0 · Qualixar · AGPL-3.0-or-later
166
+ SuperLocalMemory v4.1.3 · Qualixar · AGPL-3.0-or-later
package/pyproject.toml CHANGED
@@ -1,6 +1,6 @@
1
1
  [project]
2
2
  name = "superlocalmemory"
3
- version = "4.1.0"
3
+ version = "4.1.3"
4
4
  description = "Local-first agent memory with auditable hybrid retrieval"
5
5
  readme = "README.md"
6
6
  license = "AGPL-3.0-or-later"
@@ -32,7 +32,7 @@ if "OMP_NUM_THREADS" not in os.environ:
32
32
  os.environ["OMP_NUM_THREADS"] = "2"
33
33
  # ---------------------------------------------------------------------------
34
34
 
35
- __version__ = "4.1.0"
35
+ __version__ = "4.1.3"
36
36
 
37
37
  _REQUIRED_VERSIONS = {
38
38
  "sentence_transformers": "5.3.0",
@@ -2577,6 +2577,56 @@ def _migration_error_logs() -> list:
2577
2577
  return []
2578
2578
 
2579
2579
 
2580
+ def _slm_version() -> str:
2581
+ """The installed package version, or "unknown"."""
2582
+ try:
2583
+ from importlib.metadata import version
2584
+ return version("superlocalmemory")
2585
+ except Exception: # noqa: BLE001
2586
+ return "unknown"
2587
+
2588
+
2589
+ def _installed_plugin_versions() -> dict:
2590
+ """Version of each editor plugin found on this machine, by install name.
2591
+
2592
+ The skills, agents and commands live in the editor's plugin channel rather
2593
+ than in the Python package, so upgrading with pip leaves them exactly where
2594
+ they were. This looks for them where each editor puts them, and returns an
2595
+ empty mapping when none is installed -- which is itself the answer worth
2596
+ reporting, because it means pip is the only thing being upgraded.
2597
+
2598
+ Best effort by design: an editor this does not know about should produce
2599
+ "not detected", never an error.
2600
+ """
2601
+ import json
2602
+ from pathlib import Path
2603
+
2604
+ found: dict[str, str] = {}
2605
+ roots = (
2606
+ # Claude Code: marketplace installs and directly-added plugins.
2607
+ Path.home() / ".claude" / "plugins",
2608
+ # Codex and VS Code copies, when placed by hand.
2609
+ Path.home() / ".codex" / "plugins",
2610
+ Path.home() / ".vscode" / "extensions",
2611
+ )
2612
+ for root in roots:
2613
+ if not root.is_dir():
2614
+ continue
2615
+ for manifest in list(root.glob("*/.claude-plugin/plugin.json")) + \
2616
+ list(root.glob("*/plugin.json")) + \
2617
+ list(root.glob("*/*/.claude-plugin/plugin.json")):
2618
+ try:
2619
+ data = json.loads(manifest.read_text(encoding="utf-8"))
2620
+ except Exception: # noqa: BLE001 — a sibling plugin's bad json is not ours
2621
+ continue
2622
+ if str(data.get("name", "")) != "superlocalmemory":
2623
+ continue
2624
+ found[str(manifest.parent.parent.name)] = str(
2625
+ data.get("version", "unknown")
2626
+ )
2627
+ return found
2628
+
2629
+
2580
2630
  def _detect_all_installs() -> list:
2581
2631
  """Thin shim so cmd_doctor can be tested without importing install_detector."""
2582
2632
  try:
@@ -3093,6 +3143,50 @@ def cmd_doctor(args: Namespace) -> None:
3093
3143
  except Exception as _inst_exc: # noqa: BLE001 — never break doctor
3094
3144
  _check("install_versions", "WARN", f"could not probe installs: {_inst_exc}")
3095
3145
 
3146
+ # 14. The skills, agents and commands are NOT in the Python package.
3147
+ # They ship through the editor's own plugin channel -- `plugin/` in the
3148
+ # repository -- so `pip install --upgrade` cannot move them, and until now
3149
+ # nothing said so. 4.1 changed 76 files across those trees; a user who
3150
+ # upgraded the package and read a clean `slm doctor` had every reason to
3151
+ # believe they had all of it, and no way to find out otherwise.
3152
+ try:
3153
+ _pkg_version = _slm_version()
3154
+ _pl = _installed_plugin_versions()
3155
+ if not _pl:
3156
+ _check(
3157
+ "plugin_skills",
3158
+ "WARN",
3159
+ f"package is {_pkg_version}; no editor plugin detected, so the "
3160
+ f"skills, agents and commands are not installed or updated by "
3161
+ f"pip",
3162
+ fix="Claude Code: claude plugin marketplace add "
3163
+ "qualixar/superlocalmemory && claude plugin install "
3164
+ "superlocalmemory@qualixar "
3165
+ "Codex / VS Code: copy codex-plugin/ or copilot-plugin/ "
3166
+ "from the tag you are on",
3167
+ )
3168
+ else:
3169
+ _stale = {n: v for n, v in _pl.items() if v != _pkg_version}
3170
+ if _stale:
3171
+ _check(
3172
+ "plugin_skills",
3173
+ "WARN",
3174
+ "package is %s; plugin content still at %s" % (
3175
+ _pkg_version,
3176
+ ", ".join(f"{n}={v}" for n, v in sorted(_stale.items())),
3177
+ ),
3178
+ fix="claude plugin marketplace update qualixar && "
3179
+ "claude plugin update superlocalmemory@qualixar",
3180
+ )
3181
+ else:
3182
+ _check(
3183
+ "plugin_skills",
3184
+ "PASS",
3185
+ f"plugin content matches the package ({_pkg_version})",
3186
+ )
3187
+ except Exception as _pl_exc: # noqa: BLE001 — never break doctor
3188
+ _check("plugin_skills", "WARN", f"could not probe plugins: {_pl_exc}")
3189
+
3096
3190
  # 14. Migration error logs — surface any unresolved failure from a previous
3097
3191
  # upgrade attempt. The daemon writes these and they persist until the
3098
3192
  # user takes action; doctor is the right place to surface them.
@@ -39,6 +39,7 @@ from __future__ import annotations
39
39
 
40
40
  import logging
41
41
  import threading
42
+ import time
42
43
  from contextlib import nullcontext
43
44
  from dataclasses import dataclass
44
45
 
@@ -67,6 +68,17 @@ class RecallHealth:
67
68
  checks: int = 0
68
69
  last_semantic_score: float = 0.0
69
70
  last_error: str = ""
71
+ #: When the last tick finished, as a unix timestamp. A tick that finds
72
+ #: nothing wrong logs nothing, which is correct -- a monitor that narrates
73
+ #: every success is a monitor whose real warnings get skimmed past. But it
74
+ #: left no way to tell a monitor that is ticking quietly from a thread that
75
+ #: died or never started, and that ambiguity cost someone an hour of looking
76
+ #: for log lines that were never going to appear. So the fact of the tick is
77
+ #: recorded here and surfaced on /health, where it can be checked instead of
78
+ #: inferred.
79
+ last_tick_at: float = 0.0
80
+ #: Whether the embedder could produce a vector at the last tick.
81
+ embedder_alive: bool = True
70
82
 
71
83
 
72
84
  def _max_semantic(results) -> float:
@@ -91,6 +103,38 @@ def _get_embedder(engine):
91
103
  return emb
92
104
 
93
105
 
106
+ def _embedder_is_dead(engine) -> bool:
107
+ """Can the embedder produce a vector right now?
108
+
109
+ Asked directly, because it cannot be inferred from a recall. The monitor
110
+ used to decide the embedder was fine whenever the probe recall came back
111
+ with no results at all -- and a dead embedder is one of the reasons a recall
112
+ comes back with no results, so the one symptom that should have triggered a
113
+ heal was read as proof that none was needed.
114
+
115
+ That is not hypothetical. An idle-timeout kill leaves no worker; the next
116
+ probe finds nothing by meaning, finds nothing by keyword either because the
117
+ probe phrase appears in nobody's memories, and returns zero results. The
118
+ monitor then recorded "healthy", logged nothing, and never respawned the
119
+ worker -- so ``readiness.embedding`` stayed false and the daemon sat in
120
+ ``warming`` until someone restarted it by hand, with not one line in the log
121
+ to say why.
122
+
123
+ Fails safe in the opposite direction from before: an embedder this cannot
124
+ reach is reported dead, so the worst case is one unnecessary re-warm rather
125
+ than a silent outage.
126
+ """
127
+ emb = _get_embedder(engine)
128
+ if emb is None:
129
+ return False # BM25-only by configuration; nothing to heal.
130
+ warm = getattr(emb, "is_warm", None)
131
+ if warm is not None and not warm:
132
+ return True
133
+ if getattr(emb, "_available", True) is False:
134
+ return True
135
+ return False
136
+
137
+
94
138
  def _heal_embedder(engine, *, log) -> bool:
95
139
  """Tier 3: reset the cached availability flag and re-exercise the embedder.
96
140
 
@@ -169,10 +213,27 @@ def run_health_tick(engine, state: RecallHealth, *, probe: str = DEFAULT_PROBE,
169
213
  results = list(getattr(resp, "results", []) or [])
170
214
  sem = _max_semantic(results)
171
215
  state.last_semantic_score = sem
172
-
173
- # Tier 2: readiness. Rows present but semantic never fired == warm-but-broken.
174
- # Zero results is NOT this signature (could be an empty/filtered corpus).
175
- broken = bool(results) and sem <= 0.0
216
+ state.last_tick_at = time.time()
217
+
218
+ # Tier 2: readiness. Two independent signatures, and the second one is why
219
+ # this monitor exists.
220
+ #
221
+ # * rows present but semantic never fired -> warm-but-broken
222
+ # * the embedder cannot produce a vector -> dead, whatever the recall said
223
+ #
224
+ # The second used to be missing, and its absence was load-bearing: zero
225
+ # results was treated as "not this signature", so the case where the embedder
226
+ # is dead AND the probe matches nothing by keyword -- which is the normal
227
+ # shape of an idle-timeout kill -- came out as healthy, silently.
228
+ dead = _embedder_is_dead(engine)
229
+ state.embedder_alive = not dead
230
+ broken = dead or (bool(results) and sem <= 0.0)
231
+ if dead:
232
+ log.critical(
233
+ "recall-health: embedder cannot produce a vector (%d probe results) "
234
+ "— attempting self-heal",
235
+ len(results),
236
+ )
176
237
  if not broken:
177
238
  if not state.healthy:
178
239
  log.warning(
@@ -184,12 +245,16 @@ def run_health_tick(engine, state: RecallHealth, *, probe: str = DEFAULT_PROBE,
184
245
  state.last_error = ""
185
246
  return state
186
247
 
187
- # Tier 3: self-heal.
188
- log.critical(
189
- "recall-health: semantic channel DEAD (%d results, max semantic=0.0) "
190
- "— embedder returning None; attempting self-heal",
191
- len(results),
192
- )
248
+ # Tier 3: self-heal. The dead-embedder case already said so above; saying
249
+ # "semantic channel DEAD (max semantic=0.0)" as well would be a second,
250
+ # differently-worded CRITICAL about the same tick, and one of the two would
251
+ # be describing a symptom the reader does not have.
252
+ if not dead:
253
+ log.critical(
254
+ "recall-health: semantic channel DEAD (%d results, max semantic=0.0) "
255
+ "— embedder returning None; attempting self-heal",
256
+ len(results),
257
+ )
193
258
  if _heal_embedder(engine, log=log):
194
259
  state.total_heals += 1
195
260
  state.healthy = True
@@ -256,6 +321,7 @@ def start_recall_health_monitor(engine, *, interval_s: int = DEFAULT_INTERVAL_S,
256
321
  def get_recall_health() -> dict:
257
322
  """Snapshot for /health surfacing (visibility — never silent degradation)."""
258
323
  s = _GLOBAL_STATE
324
+ now = time.time()
259
325
  return {
260
326
  "recall_healthy": s.healthy,
261
327
  "consecutive_failures": s.consecutive_failures,
@@ -263,4 +329,15 @@ def get_recall_health() -> dict:
263
329
  "checks": s.checks,
264
330
  "last_semantic_score": round(s.last_semantic_score, 4),
265
331
  "last_error": s.last_error,
332
+ # Proof of life. A tick that finds nothing wrong logs nothing, so there
333
+ # was no way to tell this monitor apart from a thread that never started
334
+ # -- someone spent an hour reading logs for lines that were never going
335
+ # to be written. These two answer that without needing the log at all:
336
+ # if seconds_since_last_tick keeps climbing past the interval, the thread
337
+ # is gone.
338
+ "last_tick_at": round(s.last_tick_at, 3) if s.last_tick_at else None,
339
+ "seconds_since_last_tick": (
340
+ round(now - s.last_tick_at, 1) if s.last_tick_at else None
341
+ ),
342
+ "embedder_alive": s.embedder_alive,
266
343
  }
@@ -511,6 +511,49 @@ _MIGRATION_EXEMPT_PATH_PREFIXES: tuple[str, ...] = (
511
511
  )
512
512
 
513
513
 
514
+ def _serving_blocked_by(migration_result: dict) -> list[str]:
515
+ """Failed migrations that should stop this daemon serving. Fail-closed.
516
+
517
+ A failed migration used to 503 every route without asking what had failed.
518
+ For a missing table that is right. For a data invariant that ordinary use can
519
+ re-violate it is not: one drifted row made the whole store unreachable until
520
+ somebody restarted it by hand, and the restart fixed nothing that a
521
+ background repair would not have fixed on its own.
522
+
523
+ A migration may answer for itself by exposing ``blocks_serving(conn)``.
524
+ Anything that does not is treated as blocking, so this cannot quietly open a
525
+ door for a migration nobody has thought about.
526
+ """
527
+ failed = list(migration_result.get("failed") or [])
528
+ if not failed:
529
+ return []
530
+ try:
531
+ import sqlite3
532
+
533
+ from superlocalmemory.infra.data_root import state_path
534
+ from superlocalmemory.storage._migration_internals import _MODULES
535
+ except Exception: # noqa: BLE001 — never let this decide by crashing
536
+ return failed
537
+
538
+ blocking: list[str] = []
539
+ for name in failed:
540
+ decide = getattr(_MODULES.get(name), "blocks_serving", None)
541
+ if not callable(decide):
542
+ blocking.append(name)
543
+ continue
544
+ try:
545
+ db = state_path("memory.db")
546
+ conn = sqlite3.connect(f"file:{db}?mode=ro", uri=True)
547
+ try:
548
+ if decide(conn):
549
+ blocking.append(name)
550
+ finally:
551
+ conn.close()
552
+ except Exception: # noqa: BLE001 — unknown means blocking
553
+ blocking.append(name)
554
+ return blocking
555
+
556
+
514
557
  def _is_migration_exempt_path(path: str) -> bool:
515
558
  """Return True for health, status, and repair paths that must stay reachable
516
559
  even when the daemon reports a schema migration failure.
@@ -4009,7 +4052,7 @@ def _register_dashboard_routes(application: FastAPI) -> None:
4009
4052
  @application.middleware("http")
4010
4053
  async def _migration_readiness_gate(request, call_next):
4011
4054
  migration_result = getattr(application.state, "migration_result", None)
4012
- if migration_result and migration_result.get("failed"):
4055
+ if migration_result and _serving_blocked_by(migration_result):
4013
4056
  if not _is_migration_exempt_path(request.url.path):
4014
4057
  from fastapi.responses import JSONResponse
4015
4058
  return JSONResponse(
@@ -4278,7 +4321,14 @@ def _register_daemon_routes(application: FastAPI) -> None:
4278
4321
  (migration_result or {}).get("failed", []) or []
4279
4322
  )
4280
4323
  migration_details = (migration_result or {}).get("details", {}) or {}
4281
- migrations_ready = bool(migration_result) and not migration_failures
4324
+ # Ready means "can serve", so it keys off the failures that actually
4325
+ # stop this daemon serving -- not off every failure. A data invariant
4326
+ # that ordinary use re-violated leaves every route working; reporting
4327
+ # not-ready for it told operators to restart, which fixed nothing a
4328
+ # background repair would not have fixed. Everything still shows up in
4329
+ # migration_failures and migration_failure_reasons below, named.
4330
+ migration_blocking = _serving_blocked_by(migration_result or {})
4331
+ migrations_ready = bool(migration_result) and not migration_blocking
4282
4332
  if migration_details.get("_crash"):
4283
4333
  migrations_ready = False
4284
4334
  writer_runtime = getattr(
@@ -4296,6 +4346,9 @@ def _register_daemon_routes(application: FastAPI) -> None:
4296
4346
  "embedding": embedding_ready,
4297
4347
  "recall_health": recall_health.get("recall_healthy") is True,
4298
4348
  "migration_failures": migration_failures,
4349
+ # Which of those are the reason this daemon will not serve, as
4350
+ # opposed to the ones it is reporting while serving normally.
4351
+ "migration_blocking": migration_blocking,
4299
4352
  # WHY each one failed, not just which. The runner already produces
4300
4353
  # a precise sentence per migration -- "safe repair did not restore
4301
4354
  # M043_...", "schema verification failed ... : <sqlite error>" --
@@ -332,6 +332,22 @@ def _delete_log(conn: sqlite3.Connection, name: str) -> None:
332
332
  conn.execute("DELETE FROM migration_log WHERE name = ?", (name,))
333
333
 
334
334
 
335
+ def _why_unmet(mod, conn) -> str:
336
+ """The migration's own account of which check does not hold.
337
+
338
+ A migration may expose ``unmet(conn)`` returning a sentence. Most do not,
339
+ and for those the caller keeps its generic wording. Never raises: this runs
340
+ while reporting a failure and must not become a second one.
341
+ """
342
+ fn = getattr(mod, "unmet", None)
343
+ if not callable(fn):
344
+ return ""
345
+ try:
346
+ return str(fn(conn) or "")
347
+ except Exception: # noqa: BLE001 - a detail string is not worth a crash
348
+ return ""
349
+
350
+
335
351
  def _apply_single(
336
352
  conn: sqlite3.Connection,
337
353
  migration: Migration,
@@ -414,9 +430,12 @@ def _apply_single(
414
430
  try:
415
431
  repair_fn(conn)
416
432
  if not bool(verify_fn(conn)):
433
+ _why = _why_unmet(mod, conn)
417
434
  return (
418
435
  "failed",
419
- f"safe repair did not restore {migration.name}",
436
+ f"safe repair did not restore "
437
+ f"{migration.name}"
438
+ + (f": {_why}" if _why else ""),
420
439
  )
421
440
  _upsert_log(conn, migration.name, ddl_hash, "complete")
422
441
  return (
@@ -482,9 +501,11 @@ def _apply_single(
482
501
  )
483
502
  try:
484
503
  if not bool(verify_fn(conn)):
504
+ _why = _why_unmet(mod, conn)
485
505
  return (
486
506
  "failed",
487
- f"safe repair did not restore {migration.name}",
507
+ f"safe repair did not restore {migration.name}"
508
+ + (f": {_why}" if _why else ""),
488
509
  )
489
510
  except sqlite3.Error as exc:
490
511
  return (