superlocalmemory 3.6.7 → 3.6.8

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.
package/CHANGELOG.md CHANGED
@@ -5,6 +5,21 @@ All notable changes to SuperLocalMemory V3 will be documented in this file.
5
5
  The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/),
6
6
  and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
7
7
 
8
+ ## [3.6.8] - 2026-06-11 — Runtime recall-health monitor (self-healing recall)
9
+
10
+ ### Fixed
11
+ - **Silent recall degradation eliminated.** On a long-running daemon the cold full-fusion recall could exceed the MCP pool's 30s timeout, so `session_init` silently fell back to FTS5/BM25 keyword-only ("DEGRADED MODE") — observed 7× in 2 days in production logs. Two root causes are now repaired in-life rather than only at boot:
12
+ - **Page-cache eviction:** the graph/`association_edges` table gets evicted under memory pressure, so the first recall after idle re-reads it from disk (15–24s+).
13
+ - **Warm-but-broken embedder:** `OllamaEmbedder.embed` returns `None` on a transient Ollama failure while the boot `_embedding_warm` flag still reports `True`; with `q_emb is None` the engine skips the semantic, hopfield and spreading_activation channels, silently degrading to keyword-only (semantic score `0.0` on every result).
14
+
15
+ ### Added
16
+ - **`server/recall_health.py` — runtime recall-health monitor** (industry-standard 3-tier, validated against Ollama keep-alive, Chroma's active heartbeat, LangChain's circuit breaker and the K8s liveness/readiness split):
17
+ - **Tier 1 — re-warm:** fires a real `engine.recall` every 5 min to keep the graph page cache hot and nomic-embed resident.
18
+ - **Tier 2 — readiness probe:** asserts the semantic channel actually fired (`max semantic > 0`); rows-with-`semantic==0` everywhere is the warm-but-broken signature.
19
+ - **Tier 3 — circuit-breaker self-heal:** resets the embedder's cached `_available` flag (the "available once, cached forever" bug), re-exercises `embed()`, tracks consecutive failures, and logs **CRITICAL** so degradation is never silent.
20
+ - **`/health` now reports `recall_health`** (`recall_healthy`, `consecutive_failures`, `total_heals`, `checks`, `last_semantic_score`) — degradation is visible to operators, not hidden.
21
+ - 8 new tests in `tests/test_core/test_recall_health.py`.
22
+
8
23
  ## [3.6.7] - 2026-06-10 — MCP Streamable-HTTP Transport (Embedded)
9
24
 
10
25
  ### Added
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "superlocalmemory",
3
- "version": "3.6.7",
3
+ "version": "3.6.8",
4
4
  "description": "Information-geometric agent memory with mathematical guarantees. 4-channel retrieval, Fisher-Rao similarity, zero-LLM mode, EU AI Act compliant. Works with Claude, Cursor, Windsurf, and 17+ AI tools.",
5
5
  "keywords": [
6
6
  "ai-memory",
package/pyproject.toml CHANGED
@@ -1,6 +1,6 @@
1
1
  [project]
2
2
  name = "superlocalmemory"
3
- version = "3.6.7"
3
+ version = "3.6.8"
4
4
  description = "Information-geometric agent memory with mathematical guarantees"
5
5
  readme = "README.md"
6
6
  license = {text = "AGPL-3.0-or-later"}
@@ -28,7 +28,7 @@ if "OMP_NUM_THREADS" not in os.environ:
28
28
  os.environ["OMP_NUM_THREADS"] = "2"
29
29
  # ---------------------------------------------------------------------------
30
30
 
31
- __version__ = "3.6.7"
31
+ __version__ = "3.6.8"
32
32
 
33
33
  _REQUIRED_VERSIONS = {
34
34
  "sentence_transformers": "5.3.0",
@@ -0,0 +1,237 @@
1
+ """Runtime recall-health monitor — keep the 6-channel recall path warm,
2
+ detect a "warm-but-broken" embedder at runtime, and self-heal it (v3.6.8).
3
+
4
+ Why this exists
5
+ ---------------
6
+ On a long-running daemon the cold full-fusion recall could exceed the MCP
7
+ pool's 30s timeout, and ``session_init`` then silently fell back to FTS5/BM25
8
+ ("DEGRADED MODE") — observed 7×/2 days in production logs. Two root causes:
9
+
10
+ 1. **Page-cache eviction.** The ~100 MB ``association_edges`` / graph table is
11
+ evicted under memory pressure, so the first recall after idle re-reads it
12
+ from disk (15-24 s+), blowing the pool timeout.
13
+ 2. **Silent embedder death.** ``OllamaEmbedder.embed`` returns ``None`` on a
14
+ transient Ollama failure, while the boot-set ``_embedding_warm`` flag still
15
+ reports ``True``. With ``q_emb is None`` the engine skips the semantic,
16
+ hopfield and spreading_activation channels — recall silently degrades to
17
+ keyword-only (``semantic`` score ``0.0`` on every result).
18
+
19
+ The boot warmup (``_warmup_recall``) runs ONCE and never again, so neither
20
+ condition is repaired in-life.
21
+
22
+ The fix — an industry-standard 3-tier monitor, validated against Ollama
23
+ keep-alive, Chroma's active heartbeat, LangChain's circuit breaker and the
24
+ Kubernetes liveness/readiness split:
25
+
26
+ * **Tier 1 — RE-WARM.** Fire a real ``engine.recall`` every ``interval_s`` to
27
+ keep the graph page cache hot and nomic-embed resident.
28
+ * **Tier 2 — READINESS PROBE.** Assert the semantic channel actually fired
29
+ (``max semantic > 0``). Rows returned with ``semantic == 0`` everywhere is the
30
+ warm-but-broken signature → the embedder is returning ``None``.
31
+ * **Tier 3 — CIRCUIT-BREAKER SELF-HEAL.** Reset the embedder's cached
32
+ ``_available`` flag (the "available once, cached forever" bug), re-exercise
33
+ ``embed()``, track consecutive failures, and log **CRITICAL** so the
34
+ degradation is never silent.
35
+
36
+ All logic is fail-soft: a crash in the monitor never takes down the daemon.
37
+ """
38
+ from __future__ import annotations
39
+
40
+ import logging
41
+ import threading
42
+ from dataclasses import dataclass
43
+
44
+ logger = logging.getLogger(__name__)
45
+
46
+ # A stable probe query. Content-agnostic: we only assert that *some* result
47
+ # carries a non-zero semantic score, which proves the embedder + semantic
48
+ # channel are alive end-to-end.
49
+ DEFAULT_PROBE = "memory recall health probe"
50
+ # A distinct string for the heal embed() call so it is obvious in thread dumps
51
+ # / Ollama logs and never collides with a real cached query embedding.
52
+ HEAL_PROBE = "__recall_health_rewarm__"
53
+
54
+ # Default cadence. 5 min keeps nomic-embed resident (Ollama default unload is
55
+ # 5 min) and the page cache warm without meaningful load.
56
+ DEFAULT_INTERVAL_S = 300
57
+
58
+
59
+ @dataclass
60
+ class RecallHealth:
61
+ """Mutable health state for the recall path."""
62
+
63
+ healthy: bool = True
64
+ consecutive_failures: int = 0
65
+ total_heals: int = 0
66
+ checks: int = 0
67
+ last_semantic_score: float = 0.0
68
+ last_error: str = ""
69
+
70
+
71
+ def _max_semantic(results) -> float:
72
+ """Largest semantic channel score across results (0.0 if none)."""
73
+ best = 0.0
74
+ for r in results:
75
+ cs = getattr(r, "channel_scores", None) or {}
76
+ try:
77
+ best = max(best, float(cs.get("semantic", 0.0) or 0.0))
78
+ except (TypeError, ValueError):
79
+ continue
80
+ return best
81
+
82
+
83
+ def _get_embedder(engine):
84
+ """Locate the engine's embedder (full mode: engine._embedder; some paths
85
+ hang it off the retrieval engine)."""
86
+ emb = getattr(engine, "_embedder", None)
87
+ if emb is None:
88
+ re_eng = getattr(engine, "_retrieval_engine", None)
89
+ emb = getattr(re_eng, "_embedder", None) if re_eng is not None else None
90
+ return emb
91
+
92
+
93
+ def _heal_embedder(engine, *, log) -> bool:
94
+ """Tier 3: reset the cached availability flag and re-exercise the embedder.
95
+
96
+ ``OllamaEmbedder.is_available`` caches its first result forever, so once
97
+ Ollama blips the flag can stay stale. Clearing ``_available`` forces a
98
+ re-probe; ``embed()`` itself always re-attempts the HTTP call, so a single
99
+ successful embed proves recovery. Returns True iff the embedder produced a
100
+ vector.
101
+ """
102
+ emb = _get_embedder(engine)
103
+ if emb is None:
104
+ log.critical("recall-health: no embedder on engine — cannot self-heal")
105
+ return False
106
+ # Reset the "available once, cached forever" flag if present.
107
+ if hasattr(emb, "_available"):
108
+ try:
109
+ emb._available = None
110
+ except Exception: # pragma: no cover - defensive
111
+ pass
112
+ try:
113
+ vec = emb.embed(HEAL_PROBE)
114
+ except Exception as exc:
115
+ log.warning("recall-health: heal embed() raised: %s", exc)
116
+ return False
117
+ return vec is not None and bool(vec)
118
+
119
+
120
+ def run_health_tick(engine, state: RecallHealth, *, probe: str = DEFAULT_PROBE,
121
+ log=logger) -> RecallHealth:
122
+ """One monitor tick: re-warm (Tier 1), probe (Tier 2), self-heal (Tier 3).
123
+
124
+ Mutates and returns ``state``. Never raises — a timed-out / failing recall
125
+ marks the path unhealthy instead of propagating.
126
+ """
127
+ state.checks += 1
128
+
129
+ # Tier 1: re-warm. A real full-fusion recall keeps the graph page cache hot
130
+ # and the embedder resident.
131
+ try:
132
+ resp = engine.recall(probe, limit=3, fast=False)
133
+ except Exception as exc:
134
+ state.healthy = False
135
+ state.consecutive_failures += 1
136
+ state.last_error = f"recall raised: {exc}"
137
+ log.critical(
138
+ "recall-health: re-warm recall FAILED (%s) — recall path unhealthy",
139
+ exc,
140
+ )
141
+ return state
142
+
143
+ results = list(getattr(resp, "results", []) or [])
144
+ sem = _max_semantic(results)
145
+ state.last_semantic_score = sem
146
+
147
+ # Tier 2: readiness. Rows present but semantic never fired == warm-but-broken.
148
+ # Zero results is NOT this signature (could be an empty/filtered corpus).
149
+ broken = bool(results) and sem <= 0.0
150
+ if not broken:
151
+ if not state.healthy:
152
+ log.warning(
153
+ "recall-health: RECOVERED (semantic=%.3f, %d results)",
154
+ sem, len(results),
155
+ )
156
+ state.healthy = True
157
+ state.consecutive_failures = 0
158
+ state.last_error = ""
159
+ return state
160
+
161
+ # Tier 3: self-heal.
162
+ log.critical(
163
+ "recall-health: semantic channel DEAD (%d results, max semantic=0.0) "
164
+ "— embedder returning None; attempting self-heal",
165
+ len(results),
166
+ )
167
+ if _heal_embedder(engine, log=log):
168
+ state.total_heals += 1
169
+ state.healthy = True
170
+ state.consecutive_failures = 0
171
+ state.last_error = ""
172
+ log.warning("recall-health: embedder self-heal SUCCEEDED (re-warmed)")
173
+ else:
174
+ state.healthy = False
175
+ state.consecutive_failures += 1
176
+ state.last_error = "semantic channel dead; embedder heal failed"
177
+ log.critical(
178
+ "recall-health: self-heal FAILED — recall DEGRADED to keyword-only "
179
+ "(consecutive_failures=%d)", state.consecutive_failures,
180
+ )
181
+ return state
182
+
183
+
184
+ def health_monitor_loop(engine, *, interval_s: int, stop_event: threading.Event,
185
+ state: RecallHealth, probe: str = DEFAULT_PROBE,
186
+ log=logger) -> None:
187
+ """Background loop. Sleeps ``interval_s`` between ticks; exits promptly when
188
+ ``stop_event`` is set. An initial short delay avoids racing boot warmup."""
189
+ # Initial delay (bounded) so we don't pile onto the boot warmup threads.
190
+ if stop_event.wait(min(interval_s, 60)):
191
+ return
192
+ while not stop_event.is_set():
193
+ try:
194
+ run_health_tick(engine, state, probe=probe, log=log)
195
+ except Exception as exc: # pragma: no cover - belt & suspenders
196
+ log.warning("recall-health: tick crashed (non-fatal): %s", exc)
197
+ if stop_event.wait(interval_s):
198
+ break
199
+
200
+
201
+ # Module-level state so /health can surface the latest verdict without holding
202
+ # a reference to the thread.
203
+ _GLOBAL_STATE = RecallHealth()
204
+
205
+
206
+ def start_recall_health_monitor(engine, *, interval_s: int = DEFAULT_INTERVAL_S,
207
+ probe: str = DEFAULT_PROBE, log=None):
208
+ """Start the monitor as a daemon thread. Returns ``(thread, stop_event,
209
+ state)``. The state is the shared module-level state read by
210
+ :func:`get_recall_health`."""
211
+ log = log or logger
212
+ state = _GLOBAL_STATE
213
+ stop = threading.Event()
214
+ t = threading.Thread(
215
+ target=health_monitor_loop,
216
+ kwargs=dict(
217
+ engine=engine, interval_s=interval_s, stop_event=stop,
218
+ state=state, probe=probe, log=log,
219
+ ),
220
+ daemon=True,
221
+ name="recall-health",
222
+ )
223
+ t.start()
224
+ return t, stop, state
225
+
226
+
227
+ def get_recall_health() -> dict:
228
+ """Snapshot for /health surfacing (visibility — never silent degradation)."""
229
+ s = _GLOBAL_STATE
230
+ return {
231
+ "recall_healthy": s.healthy,
232
+ "consecutive_failures": s.consecutive_failures,
233
+ "total_heals": s.total_heals,
234
+ "checks": s.checks,
235
+ "last_semantic_score": round(s.last_semantic_score, 4),
236
+ "last_error": s.last_error,
237
+ }
@@ -683,6 +683,25 @@ async def lifespan(application: FastAPI):
683
683
  threading.Thread(target=_warmup_recall, daemon=True, name="recall-warmup").start()
684
684
  threading.Thread(target=_backfill_vector_store, daemon=True, name="vs-backfill").start()
685
685
 
686
+ # v3.6.8: Runtime recall-health monitor. The three warmups above run
687
+ # ONCE at boot; on a long-running daemon the graph page cache gets
688
+ # evicted and the embedder can start returning None while _embedding_warm
689
+ # still claims True — both silently degrade recall to keyword-only BM25
690
+ # (observed: session_init "pool.recall timed out → DEGRADED MODE", 7×/2d).
691
+ # This monitor re-warms + actively probes the semantic channel + self-heals
692
+ # a dead embedder for the daemon's whole life. See server/recall_health.py.
693
+ try:
694
+ from superlocalmemory.server.recall_health import (
695
+ start_recall_health_monitor,
696
+ )
697
+ _rh_thread, _rh_stop, _ = start_recall_health_monitor(engine)
698
+ application.state.recall_health_stop = _rh_stop
699
+ except Exception as _rh_exc:
700
+ logger.warning(
701
+ "recall-health monitor start failed (non-fatal): %s", _rh_exc,
702
+ )
703
+ application.state.recall_health_stop = None
704
+
686
705
  # v3.4.37: QueueConsumer uses daemon's engine directly via adapter.
687
706
  # Previously routed through WorkerPool → recall_worker subprocess,
688
707
  # which loaded a duplicate MemoryEngine (~800 MB waste).
@@ -968,6 +987,14 @@ async def lifespan(application: FastAPI):
968
987
  except Exception as exc: # pragma: no cover — defensive
969
988
  logger.warning("recall_queue close failed: %s", exc)
970
989
 
990
+ # v3.6.8: Stop recall-health monitor (owns a daemon thread).
991
+ _rh_stop = getattr(application.state, "recall_health_stop", None)
992
+ if _rh_stop is not None:
993
+ try:
994
+ _rh_stop.set()
995
+ except Exception as exc: # pragma: no cover — defensive
996
+ logger.warning("recall_health monitor stop failed: %s", exc)
997
+
971
998
  # Stop HealthMonitor (health_monitor.py owns a daemon thread).
972
999
  _health = getattr(application.state, "health_monitor", None)
973
1000
  if _health is not None:
@@ -1579,6 +1606,13 @@ def _register_daemon_routes(application: FastAPI) -> None:
1579
1606
  _update_activity()
1580
1607
  # Non-blocking peek: report status without forcing a re-init.
1581
1608
  engine = getattr(application.state, "engine", None)
1609
+ # v3.6.8: surface the recall-health verdict so a silently-degraded
1610
+ # recall path (warm-but-broken embedder) is VISIBLE, never silent.
1611
+ try:
1612
+ from superlocalmemory.server.recall_health import get_recall_health
1613
+ _recall_health = get_recall_health()
1614
+ except Exception:
1615
+ _recall_health = {"recall_healthy": None}
1582
1616
  return {
1583
1617
  "status": "ok",
1584
1618
  "pid": os.getpid(),
@@ -1587,6 +1621,9 @@ def _register_daemon_routes(application: FastAPI) -> None:
1587
1621
  # v3.4.52: clients can poll this to wait for embedding model
1588
1622
  # readiness before issuing recall calls.
1589
1623
  "embedding_warm": _embedding_warm,
1624
+ # v3.6.8: True iff the semantic channel actually fired on the last
1625
+ # health probe; includes self-heal counters.
1626
+ "recall_health": _recall_health,
1590
1627
  }
1591
1628
 
1592
1629
  @application.get("/recall")
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: superlocalmemory
3
- Version: 3.6.7
3
+ Version: 3.6.8
4
4
  Summary: Information-geometric agent memory with mathematical guarantees
5
5
  Author-email: Varun Pratap Bhardwaj <admin@superlocalmemory.com>
6
6
  License: AGPL-3.0-or-later
@@ -360,6 +360,7 @@ src/superlocalmemory/retrieval/vector_store.py
360
360
  src/superlocalmemory/server/__init__.py
361
361
  src/superlocalmemory/server/api.py
362
362
  src/superlocalmemory/server/bandit_loops.py
363
+ src/superlocalmemory/server/recall_health.py
363
364
  src/superlocalmemory/server/recall_serializer.py
364
365
  src/superlocalmemory/server/security_middleware.py
365
366
  src/superlocalmemory/server/ui.py