superlocalmemory 3.6.7 → 3.6.9

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,36 @@ 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.9] - 2026-06-11
9
+
10
+ ### Fixed
11
+ - **BUG-A: Health monitor no longer kills the embedding worker under memory pressure.** The watchdog now spares the embedding worker (load-bearing for recall quality) and prefers the reranker instead. The RSS budget defaults to 40% of physical RAM (floor 2500 MB) rather than a hardcoded 2500 MB, preventing thrash on machines with ≥8 GB. `SLM_RSS_BUDGET_MB` env and a new `"health"` config section give full operator control. `HealthConfig` dataclass + `SLMConfig.load()` parsing added so config.json `"health"` keys now actually take effect.
12
+ - **#34: Mesh tools no longer block the daemon event loop.** All 8 async mesh tools wrapped `_ensure_registered` and `_mesh_request` in `asyncio.to_thread` so the blocking loopback HTTP calls leave the event loop (v3.6.7 in-process transport made this a self-deadlock). MCP lifespan hardened so a tool-level exception cannot propagate to uvicorn's shutdown handler. `heartbeat_active` and `registered` now return live state instead of hardcoded literals.
13
+ - **#35: `session_init` now returns a `session_id`.** Clients pass it to `remember()` and `close_session()`. `store_fast` lifts `session_id` from metadata onto the `MemoryRecord` row so facts are correctly attributed. `close_session` queries the DB for the most recent session instead of chasing a phantom `_last_session_id` that was never assigned — `summary_events_created` now returns real counts.
14
+ - **#36-1: HTTP MCP reachable from LAN.** New `SLM_MCP_ALLOWED_HOSTS` (opt-in, default localhost-only) overrides MCP's DNS-rebinding protection. Accepts comma-separated `host:port*` patterns or `*` to disable protection entirely on a trusted LAN.
15
+ - **#36-2: `slm mcp` and the HTTP daemon no longer race for port 8765.** `ensure_daemon` now checks TCP connectivity in addition to PID file + HTTP health, detecting systemd-started daemons mid-startup before attempting a second bind. `SLM_DAEMON_PORT` is now fully wired end-to-end (previously only read in `commands.py` URL building, not in the actual uvicorn bind or `_start_daemon_subprocess`).
16
+ - **#32: Docs correctly state Python 3.11+ requirement** (was "3.10 or later"). Added Ubuntu 22.04 deadsnakes install instructions and a new `docs/install-linux.md` guide.
17
+
18
+ ### Added
19
+ - `HealthConfig` dataclass in `core/config.py` and `"health"` section parsing in `SLMConfig.load()`.
20
+ - `docs/distributed-deployment.md` — complete guide for LXC/container/multi-machine setups, including a full ~90-entry `SLM_*` environment variable reference table (closes #33 + #37).
21
+ - `docs/install-linux.md` — Ubuntu 22.04 / Debian install guide with venv, pipx, and pyenv options, plus a systemd unit template.
22
+
23
+ ## [3.6.8] - 2026-06-11 — Runtime recall-health monitor (self-healing recall)
24
+
25
+ ### Fixed
26
+ - **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:
27
+ - **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+).
28
+ - **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).
29
+
30
+ ### Added
31
+ - **`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):
32
+ - **Tier 1 — re-warm:** fires a real `engine.recall` every 5 min to keep the graph page cache hot and nomic-embed resident.
33
+ - **Tier 2 — readiness probe:** asserts the semantic channel actually fired (`max semantic > 0`); rows-with-`semantic==0` everywhere is the warm-but-broken signature.
34
+ - **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.
35
+ - **`/health` now reports `recall_health`** (`recall_healthy`, `consecutive_failures`, `total_heals`, `checks`, `last_semantic_score`) — degradation is visible to operators, not hidden.
36
+ - 8 new tests in `tests/test_core/test_recall_health.py`.
37
+
8
38
  ## [3.6.7] - 2026-06-10 — MCP Streamable-HTTP Transport (Embedded)
9
39
 
10
40
  ### Added
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "superlocalmemory",
3
- "version": "3.6.7",
3
+ "version": "3.6.9",
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.9"
4
4
  description = "Information-geometric agent memory with mathematical guarantees"
5
5
  readme = "README.md"
6
6
  license = {text = "AGPL-3.0-or-later"}
@@ -1,4 +1,8 @@
1
- """SuperLocalMemory — information-geometric agent memory."""
1
+ """SuperLocalMemory — information-geometric agent memory.
2
+
3
+ v3.6.9: all 7 retrieval layers at full quality (Hopfield@1000, entity_graph@100,
4
+ SA neighbor-cache fix, fast=True deprecated). See CHANGELOG.md.
5
+ """
2
6
 
3
7
  import os
4
8
 
@@ -28,7 +32,7 @@ if "OMP_NUM_THREADS" not in os.environ:
28
32
  os.environ["OMP_NUM_THREADS"] = "2"
29
33
  # ---------------------------------------------------------------------------
30
34
 
31
- __version__ = "3.6.7"
35
+ __version__ = "3.6.9"
32
36
 
33
37
  _REQUIRED_VERSIONS = {
34
38
  "sentence_transformers": "5.3.0",
@@ -34,7 +34,10 @@ from threading import Thread
34
34
 
35
35
  logger = logging.getLogger(__name__)
36
36
 
37
- _DEFAULT_PORT = 8765 # v3.4.3: unified daemon on 8765 (was 8767)
37
+ try:
38
+ _DEFAULT_PORT = int(os.environ.get("SLM_DAEMON_PORT", "") or 8765)
39
+ except ValueError:
40
+ _DEFAULT_PORT = 8765
38
41
  _LEGACY_PORT = 8767 # backward-compat redirect
39
42
  _DEFAULT_IDLE_TIMEOUT = 0 # v3.4.3: 24/7 default (was 1800)
40
43
  _PID_FILE = Path.home() / ".superlocalmemory" / "daemon.pid"
@@ -160,7 +163,13 @@ def _start_daemon_subprocess() -> bool:
160
163
  return True
161
164
 
162
165
  import subprocess
163
- cmd = [sys.executable, "-m", "superlocalmemory.server.unified_daemon", "--start"]
166
+ # v3.6.9 (#33): pass SLM_DAEMON_PORT as explicit --port= so the daemon
167
+ # binds the right port even when the env var reaches the subprocess.
168
+ _target_port = _DEFAULT_PORT
169
+ cmd = [
170
+ sys.executable, "-m", "superlocalmemory.server.unified_daemon",
171
+ "--start", f"--port={_target_port}",
172
+ ]
164
173
  log_dir = Path.home() / ".superlocalmemory" / "logs"
165
174
  log_dir.mkdir(parents=True, exist_ok=True)
166
175
  log_file = log_dir / "daemon.log"
@@ -187,7 +196,7 @@ def _start_daemon_subprocess() -> bool:
187
196
 
188
197
  # Write PID immediately so other callers see it during warmup
189
198
  _PID_FILE.write_text(str(proc.pid))
190
- _PORT_FILE.write_text(str(_DEFAULT_PORT))
199
+ _PORT_FILE.write_text(str(_target_port))
191
200
 
192
201
  return _wait_for_daemon(timeout=60)
193
202
 
@@ -237,6 +246,19 @@ def ensure_daemon() -> bool:
237
246
  if is_daemon_running():
238
247
  return True
239
248
 
249
+ # v3.6.9 (#36): TCP-level check catches a systemd-started daemon that
250
+ # has bound the port but hasn't written a PID file yet (e.g. different
251
+ # HOME for the service user vs. the SSH user). If the port is already
252
+ # bound, don't start a second daemon — wait for HTTP readiness instead.
253
+ try:
254
+ import socket as _socket
255
+ with _socket.socket(_socket.AF_INET, _socket.SOCK_STREAM) as _s:
256
+ _s.settimeout(1)
257
+ if _s.connect_ex(("127.0.0.1", _DEFAULT_PORT)) == 0:
258
+ return _wait_for_daemon(timeout=30)
259
+ except Exception:
260
+ pass
261
+
240
262
  # Start unified daemon in background — delegated to helper so the
241
263
  # same logic can be reused by callers that already hold the lock.
242
264
  return _start_daemon_subprocess()
@@ -654,6 +654,25 @@ class AutoInvokeConfig:
654
654
  relevance_threshold: float = 0.3 # Legacy compat with AutoRecall
655
655
 
656
656
 
657
+ # ---------------------------------------------------------------------------
658
+ # Health Config (v3.6.9 BUG-A)
659
+ # ---------------------------------------------------------------------------
660
+
661
+ @dataclass
662
+ class HealthConfig:
663
+ """Health-monitor tuning knobs.
664
+
665
+ All values have safe defaults so an empty ``"health": {}`` JSON section
666
+ silently inherits them. The ``global_rss_budget_mb`` default is computed
667
+ at runtime (40% of physical RAM, floor 2500 MB) so low-RAM boxes keep the
668
+ old behaviour while large machines are never accidentally throttled.
669
+ """
670
+ global_rss_budget_mb: int = 0 # 0 = compute at runtime (40% RAM, floor 2500)
671
+ heartbeat_timeout_sec: int = 60
672
+ health_check_interval_sec: int = 15
673
+ enable_structured_logging: bool = True
674
+
675
+
657
676
  # ---------------------------------------------------------------------------
658
677
  # Master Config
659
678
  # ---------------------------------------------------------------------------
@@ -698,6 +717,7 @@ class SLMConfig:
698
717
  graph_backend: str = "auto" # "auto" = cozo if pycozo installed, else sqlite
699
718
  vector_backend: str = "auto" # "auto" = lancedb if installed, else sqlite-vec
700
719
  evolution: EvolutionConfig = field(default_factory=EvolutionConfig)
720
+ health: HealthConfig = field(default_factory=HealthConfig)
701
721
 
702
722
  # v3.4.3: Daemon configuration
703
723
  daemon_idle_timeout: int = 0 # 0 = 24/7 (no auto-kill). >0 = seconds before auto-kill.
@@ -789,6 +809,14 @@ class SLMConfig:
789
809
  if k in EvolutionConfig.__dataclass_fields__
790
810
  })
791
811
 
812
+ # V3.6.9: Health monitor config (BUG-A — previously silently ignored)
813
+ hlth = data.get("health", {})
814
+ if hlth:
815
+ config.health = HealthConfig(**{
816
+ k: v for k, v in hlth.items()
817
+ if k in HealthConfig.__dataclass_fields__
818
+ })
819
+
792
820
  # V3.4.65: Injection config (additive — defaults if missing from JSON)
793
821
  inj = data.get("injection", {}) or {}
794
822
  config.injection = InjectionConfig(
@@ -452,7 +452,9 @@ class MemoryEngine:
452
452
  now = datetime.now(timezone.utc).isoformat()
453
453
  record = MemoryRecord(
454
454
  profile_id=self._profile_id, content=content,
455
- session_date=now[:10], metadata=metadata or {},
455
+ session_date=now[:10],
456
+ session_id=(metadata or {}).get("session_id", ""),
457
+ metadata=metadata or {},
456
458
  )
457
459
  self._db.store_memory(record)
458
460
  # Lightweight regex entities (matches store_pipeline verbatim path) so
@@ -520,13 +522,22 @@ class MemoryEngine:
520
522
  on a background worker.
521
523
 
522
524
  V3.4.40 (2026-05-09): ``fast=True`` skips the SpreadingActivation
523
- 5th channel for sub-second response. The other 4 channels still
524
- run. Use when recall must complete before another tool call (e.g.
525
- agent recall before WebSearch).
525
+ channel. Deprecated in v3.6.9 SA now completes in ~36ms after the
526
+ neighbor-cache fix; fast=True is slower than fast=False and reduces
527
+ recall quality. The parameter is accepted for backward compatibility
528
+ but is silently treated as False.
526
529
  """
527
530
  self._require_full("recall")
528
531
  self._ensure_init()
529
532
 
533
+ if fast:
534
+ logger.warning(
535
+ "fast=True is deprecated (v3.6.9): SpreadingActivation now "
536
+ "completes in ~36ms; fast mode is slower and reduces quality. "
537
+ "Pass fast=False (the default) to silence this warning."
538
+ )
539
+ fast = False
540
+
530
541
  pid = profile_id or self._profile_id
531
542
 
532
543
  from superlocalmemory.core.recall_pipeline import run_recall
@@ -130,14 +130,26 @@ class HealthMonitor:
130
130
  "superlocalmemory.core.reranker_worker",
131
131
  "superlocalmemory.core.recall_worker",
132
132
  )
133
+ # Workers that are LOAD-BEARING for recall quality — never kill these
134
+ # first; prefer killing the reranker (gracefully degrades) or GC instead.
135
+ _EMBEDDING_IDENTIFIER = "superlocalmemory.core.embedding_worker"
133
136
 
134
137
  def __init__(
135
138
  self,
136
- global_rss_budget_mb: int = 2500,
139
+ global_rss_budget_mb: int = 0,
137
140
  heartbeat_timeout_sec: int = 60,
138
141
  check_interval_sec: int = 15,
139
142
  enable_structured_logging: bool = True,
140
143
  ):
144
+ # Compute RAM-scaled default when 0 is passed (or when the caller
145
+ # explicitly passes 0 meaning "auto"). Floor at 2500 so low-RAM boxes
146
+ # keep the old conservative behaviour.
147
+ if global_rss_budget_mb <= 0:
148
+ if PSUTIL_AVAILABLE:
149
+ phys_mb = psutil.virtual_memory().total // (1024 * 1024)
150
+ global_rss_budget_mb = max(2500, int(phys_mb * 0.40))
151
+ else:
152
+ global_rss_budget_mb = 8000 # safe fallback when psutil absent
141
153
  self._budget_mb = global_rss_budget_mb
142
154
  self._heartbeat_timeout = heartbeat_timeout_sec
143
155
  self._interval = check_interval_sec
@@ -207,7 +219,7 @@ class HealthMonitor:
207
219
  slm_workers.append({
208
220
  "pid": child.pid,
209
221
  "rss_mb": round(rss_mb, 1),
210
- "cmdline": cmdline[:80],
222
+ "cmdline": cmdline[:200],
211
223
  })
212
224
  except (psutil.NoSuchProcess, psutil.AccessDenied):
213
225
  continue
@@ -225,22 +237,33 @@ class HealthMonitor:
225
237
  budget_mb=self._budget_mb,
226
238
  )
227
239
 
228
- # RSS budget enforcement
240
+ # RSS budget enforcement — spare the embedding worker (load-bearing for
241
+ # recall quality). Kill the reranker first (degrades gracefully); only
242
+ # fall back to the embedder if it is the only worker remaining.
229
243
  if total_rss_mb > self._budget_mb and slm_workers:
230
- heaviest = max(slm_workers, key=lambda w: w["rss_mb"])
244
+ non_embedder = [
245
+ w for w in slm_workers
246
+ if self._EMBEDDING_IDENTIFIER not in w["cmdline"]
247
+ ]
248
+ candidate = (
249
+ max(non_embedder, key=lambda w: w["rss_mb"])
250
+ if non_embedder
251
+ else max(slm_workers, key=lambda w: w["rss_mb"])
252
+ )
231
253
  logger.warning(
232
- "RSS budget exceeded (%.0fMB > %dMB). Killing heaviest worker PID %d (%.0fMB)",
233
- total_rss_mb, self._budget_mb, heaviest["pid"], heaviest["rss_mb"],
254
+ "RSS budget exceeded (%.0fMB > %dMB). Killing worker PID %d (%.0fMB)",
255
+ total_rss_mb, self._budget_mb, candidate["pid"], candidate["rss_mb"],
234
256
  )
235
257
  log_structured(
236
258
  level="warning",
237
259
  operation="rss_budget_kill",
238
- killed_pid=heaviest["pid"],
239
- killed_rss_mb=heaviest["rss_mb"],
260
+ killed_pid=candidate["pid"],
261
+ killed_rss_mb=candidate["rss_mb"],
240
262
  total_rss_mb=round(total_rss_mb, 1),
263
+ spared_embedder=bool(non_embedder),
241
264
  )
242
265
  try:
243
- psutil.Process(heaviest["pid"]).terminate()
266
+ psutil.Process(candidate["pid"]).terminate()
244
267
  except psutil.NoSuchProcess:
245
268
  pass
246
269
 
@@ -16,9 +16,12 @@ Part of Qualixar | Author: Varun Pratap Bhardwaj
16
16
 
17
17
  from __future__ import annotations
18
18
 
19
+ import asyncio
20
+ import datetime
19
21
  import logging
20
22
  import os
21
23
  import sqlite3
24
+ import uuid
22
25
  from pathlib import Path
23
26
  from typing import Callable
24
27
 
@@ -213,7 +216,13 @@ def register_active_tools(server, get_engine: Callable) -> None:
213
216
  from superlocalmemory.mcp._pool_adapter import PoolError
214
217
  degraded_mode = False
215
218
  try:
216
- response = pool_recall(search_query, limit=max_results, fast=False)
219
+ # v3.6.9-audit: pool_recall uses blocking urllib under the hood
220
+ # (DaemonPoolProxy.recall → urllib.urlopen). Must run in a
221
+ # thread so the async MCP event loop is not stalled — same
222
+ # fix class as #34 mesh tools deadlock.
223
+ response = await asyncio.to_thread(
224
+ pool_recall, search_query, limit=max_results, fast=False,
225
+ )
217
226
  except (PoolError, Exception) as exc:
218
227
  logger.warning(
219
228
  "session_init: daemon recall failed (%s) — using FTS5 emergency fallback. "
@@ -349,6 +358,13 @@ def register_active_tools(server, get_engine: Callable) -> None:
349
358
  "session_init feedback_count read failed: %s", exc,
350
359
  )
351
360
 
361
+ # v3.6.9 (#35): generate a stable session_id so clients can pass it
362
+ # to remember() and close_session() for proper session aggregation.
363
+ session_id = (
364
+ f"slm-{datetime.datetime.now(datetime.timezone.utc):%Y%m%d}"
365
+ f"-{uuid.uuid4().hex[:8]}"
366
+ )
367
+
352
368
  # Register agent + emit event (v3.4.39: SLM_AGENT_ID env support)
353
369
  agent_id = _get_agent_id()
354
370
  _register_agent(agent_id, pid)
@@ -360,6 +376,7 @@ def register_active_tools(server, get_engine: Callable) -> None:
360
376
 
361
377
  return {
362
378
  "success": True,
379
+ "session_id": session_id,
363
380
  "context": context,
364
381
  "memories": memories[:max_results],
365
382
  "memory_count": len(memories),
@@ -430,8 +447,11 @@ def register_active_tools(server, get_engine: Callable) -> None:
430
447
  "confidence": round(decision.confidence, 3),
431
448
  }
432
449
 
433
- # Auto-store via engine
434
- stored = auto.capture(
450
+ # Auto-store via engine.
451
+ # pool_store uses blocking urllib (DaemonPoolProxy) — run in
452
+ # thread so the MCP event loop stays unblocked (#34 class).
453
+ stored = await asyncio.to_thread(
454
+ auto.capture,
435
455
  content,
436
456
  category=decision.category,
437
457
  metadata={"agent_id": agent_id, "source": "auto-observe"},
@@ -525,8 +545,24 @@ def register_active_tools(server, get_engine: Callable) -> None:
525
545
  try:
526
546
  engine = get_engine()
527
547
  sid = session_id or getattr(engine, '_last_session_id', '')
548
+ # v3.6.9 (#35): _last_session_id was never assigned — fall back to
549
+ # querying the DB for the most recent session_id instead of silently
550
+ # returning summary_events_created: 0.
551
+ if not sid:
552
+ try:
553
+ db = getattr(engine, '_db', None) or getattr(engine, 'db', None)
554
+ if db and hasattr(db, 'execute'):
555
+ rows = db.execute(
556
+ "SELECT session_id FROM memories "
557
+ "WHERE session_id != '' ORDER BY created_at DESC LIMIT 1",
558
+ ()
559
+ )
560
+ if rows:
561
+ sid = str(rows[0][0])
562
+ except Exception:
563
+ pass
528
564
  if not sid:
529
- return {"success": False, "error": "No session_id provided"}
565
+ return {"success": False, "error": "No session_id provided or found"}
530
566
  count = engine.close_session(sid)
531
567
  return {
532
568
  "success": True,
@@ -122,9 +122,13 @@ def register_core_tools(server, get_engine: Callable) -> None:
122
122
  # recall window so a parallel/next agent finds memories saved seconds ago.
123
123
  # Falls back to pending.db only if the daemon is unreachable.
124
124
  try:
125
+ import asyncio as _asyncio
125
126
  from superlocalmemory.cli.daemon import daemon_request, is_daemon_running
126
- if is_daemon_running():
127
- resp = daemon_request("POST", "/remember", {
127
+ # is_daemon_running() and daemon_request() both use blocking urllib
128
+ # against the same uvicorn server — run in threads so the MCP
129
+ # event loop stays unblocked (#34 class bug).
130
+ if await _asyncio.to_thread(is_daemon_running):
131
+ resp = await _asyncio.to_thread(daemon_request, "POST", "/remember", {
128
132
  "content": content, "tags": tags, "metadata": meta,
129
133
  })
130
134
  if resp and (resp.get("fact_ids") is not None or resp.get("ok")):
@@ -16,6 +16,7 @@ Auto-heartbeat keeps the session alive as long as the MCP server is running.
16
16
 
17
17
  from __future__ import annotations
18
18
 
19
+ import asyncio
19
20
  import json
20
21
  import logging
21
22
  import os
@@ -141,20 +142,20 @@ def register_mesh_tools(server, get_engine: Callable) -> None:
141
142
  global _SESSION_SUMMARY
142
143
  _SESSION_SUMMARY = summary or "Active session"
143
144
 
144
- _ensure_registered()
145
+ await asyncio.to_thread(_ensure_registered)
145
146
 
146
147
  # Update summary
147
- result = _mesh_request("POST", "/summary", {
148
- "peer_id": _PEER_ID,
149
- "summary": _SESSION_SUMMARY,
150
- })
148
+ result = await asyncio.to_thread(
149
+ _mesh_request, "POST", "/summary",
150
+ {"peer_id": _PEER_ID, "summary": _SESSION_SUMMARY},
151
+ )
151
152
 
152
153
  return {
153
154
  "peer_id": _PEER_ID,
154
155
  "summary": _SESSION_SUMMARY,
155
156
  "project_path": _PROJECT_PATH,
156
- "registered": True,
157
- "heartbeat_active": _HEARTBEAT_THREAD is not None,
157
+ "registered": _REGISTERED,
158
+ "heartbeat_active": _HEARTBEAT_THREAD is not None and _HEARTBEAT_THREAD.is_alive(),
158
159
  "broker_response": result,
159
160
  }
160
161
 
@@ -165,8 +166,8 @@ def register_mesh_tools(server, get_engine: Callable) -> None:
165
166
  Shows other Claude Code, Cursor, or AI agent sessions that are
166
167
  connected to the same SLM mesh network.
167
168
  """
168
- _ensure_registered()
169
- result = _mesh_request("GET", "/peers")
169
+ await asyncio.to_thread(_ensure_registered)
170
+ result = await asyncio.to_thread(_mesh_request, "GET", "/peers")
170
171
  peers = (result or {}).get("peers", [])
171
172
  return {
172
173
  "peers": peers,
@@ -185,12 +186,11 @@ def register_mesh_tools(server, get_engine: Callable) -> None:
185
186
  - "project:/path/to/dir" (all sessions in that project directory)
186
187
  message: The message content (max 4KB — use file paths for large data)
187
188
  """
188
- _ensure_registered()
189
- result = _mesh_request("POST", "/send", {
190
- "from_peer": _PEER_ID,
191
- "to_peer": to,
192
- "content": message,
193
- })
189
+ await asyncio.to_thread(_ensure_registered)
190
+ result = await asyncio.to_thread(
191
+ _mesh_request, "POST", "/send",
192
+ {"from_peer": _PEER_ID, "to_peer": to, "content": message},
193
+ )
194
194
  return result or {"error": "Failed to send message"}
195
195
 
196
196
  @server.tool()
@@ -201,18 +201,19 @@ def register_mesh_tools(server, get_engine: Callable) -> None:
201
201
  Broadcast/project messages are delivered to ALL matching sessions.
202
202
  Messages auto-expire after 48 hours.
203
203
  """
204
- _ensure_registered()
204
+ await asyncio.to_thread(_ensure_registered)
205
205
  project = _PROJECT_PATH or _detect_project_path()
206
- messages = _mesh_request(
207
- "GET", f"/inbox/{_PEER_ID}?project_path={project}",
206
+ messages = await asyncio.to_thread(
207
+ _mesh_request, "GET", f"/inbox/{_PEER_ID}?project_path={project}",
208
208
  )
209
209
  msg_list = (messages or {}).get("messages", [])
210
210
  # Auto-mark unread messages as read
211
211
  unread_ids = [m["id"] for m in msg_list if not m.get("read")]
212
212
  if unread_ids:
213
- _mesh_request("POST", f"/inbox/{_PEER_ID}/read", {
214
- "message_ids": unread_ids,
215
- })
213
+ await asyncio.to_thread(
214
+ _mesh_request, "POST", f"/inbox/{_PEER_ID}/read",
215
+ {"message_ids": unread_ids},
216
+ )
216
217
  return {
217
218
  "messages": msg_list,
218
219
  "count": len(msg_list),
@@ -231,21 +232,20 @@ def register_mesh_tools(server, get_engine: Callable) -> None:
231
232
  value: Value to set (only for action="set")
232
233
  action: "get" (read all or one key), "set" (write a key)
233
234
  """
234
- _ensure_registered()
235
+ await asyncio.to_thread(_ensure_registered)
235
236
 
236
237
  if action == "set" and key:
237
- result = _mesh_request("POST", "/state", {
238
- "key": key,
239
- "value": value,
240
- "set_by": _PEER_ID,
241
- })
238
+ result = await asyncio.to_thread(
239
+ _mesh_request, "POST", "/state",
240
+ {"key": key, "value": value, "set_by": _PEER_ID},
241
+ )
242
242
  return result or {"error": "Failed to set state"}
243
243
 
244
244
  if key:
245
- result = _mesh_request("GET", f"/state/{key}")
245
+ result = await asyncio.to_thread(_mesh_request, "GET", f"/state/{key}")
246
246
  return result or {"key": key, "value": None}
247
247
 
248
- result = _mesh_request("GET", "/state")
248
+ result = await asyncio.to_thread(_mesh_request, "GET", "/state")
249
249
  return result or {"state": {}}
250
250
 
251
251
  @server.tool()
@@ -261,12 +261,11 @@ def register_mesh_tools(server, get_engine: Callable) -> None:
261
261
  file_path: Path to the file
262
262
  action: "query" (check lock), "acquire" (lock file), "release" (unlock)
263
263
  """
264
- _ensure_registered()
265
- result = _mesh_request("POST", "/lock", {
266
- "file_path": file_path,
267
- "action": action,
268
- "locked_by": _PEER_ID,
269
- })
264
+ await asyncio.to_thread(_ensure_registered)
265
+ result = await asyncio.to_thread(
266
+ _mesh_request, "POST", "/lock",
267
+ {"file_path": file_path, "action": action, "locked_by": _PEER_ID},
268
+ )
270
269
  return result or {"error": "Lock operation failed"}
271
270
 
272
271
  @server.tool(annotations=ToolAnnotations(readOnlyHint=True))
@@ -275,7 +274,7 @@ def register_mesh_tools(server, get_engine: Callable) -> None:
275
274
 
276
275
  Shows the activity log of the mesh network.
277
276
  """
278
- result = _mesh_request("GET", "/events")
277
+ result = await asyncio.to_thread(_mesh_request, "GET", "/events")
279
278
  return result or {"events": []}
280
279
 
281
280
  @server.tool(annotations=ToolAnnotations(readOnlyHint=True))
@@ -284,10 +283,10 @@ def register_mesh_tools(server, get_engine: Callable) -> None:
284
283
 
285
284
  Shows broker uptime, peer count, and connection status.
286
285
  """
287
- result = _mesh_request("GET", "/status")
286
+ result = await asyncio.to_thread(_mesh_request, "GET", "/status")
288
287
  if result:
289
288
  result["my_peer_id"] = _PEER_ID
290
- result["heartbeat_active"] = _HEARTBEAT_THREAD is not None
289
+ result["heartbeat_active"] = _HEARTBEAT_THREAD is not None and _HEARTBEAT_THREAD.is_alive()
291
290
  return result or {
292
291
  "broker_up": False,
293
292
  "error": "Cannot reach mesh broker. Is the daemon running? (slm serve start)",
@@ -170,8 +170,11 @@ class SpreadingActivation:
170
170
  for fact_id, similarity in seeds:
171
171
  activations[fact_id] = cfg.alpha * similarity
172
172
 
173
- # Precompute out-degrees for fan effect
173
+ # Cache neighbor lookups and out-degrees across iterations — same node
174
+ # often survives multiple rounds via self-retention (delta=0.5);
175
+ # caching here cuts ~80% of SQL queries vs per-iteration re-query.
174
176
  degree_cache: dict[str, int] = {}
177
+ neighbor_cache: dict[str, list] = {}
175
178
 
176
179
  # Steps 2-4, repeated T times
177
180
  for _iteration in range(cfg.max_iterations):
@@ -181,8 +184,10 @@ class SpreadingActivation:
181
184
  if activation < 0.001:
182
185
  continue
183
186
 
184
- # Get neighbors from BOTH tables (Rule 13)
185
- neighbors = self._get_unified_neighbors(node_id, profile_id)
187
+ # Get neighbors from BOTH tables (Rule 13) — cached per node
188
+ if node_id not in neighbor_cache:
189
+ neighbor_cache[node_id] = self._get_unified_neighbors(node_id, profile_id)
190
+ neighbors = neighbor_cache[node_id]
186
191
 
187
192
  # Out-degree for fan effect normalization
188
193
  if node_id not in degree_cache:
@@ -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).
@@ -727,8 +746,12 @@ async def lifespan(application: FastAPI):
727
746
  try:
728
747
  from superlocalmemory.core.health_monitor import HealthMonitor
729
748
  health_config = getattr(config, 'health', None)
749
+ # v3.6.9 BUG-A: env override + RAM-scaled default (HealthMonitor computes
750
+ # 40% of physical RAM when budget=0). SLM_RSS_BUDGET_MB takes priority.
751
+ _env_budget = int(os.environ.get("SLM_RSS_BUDGET_MB", "0") or 0)
752
+ _cfg_budget = getattr(health_config, 'global_rss_budget_mb', 0) if health_config else 0
730
753
  monitor = HealthMonitor(
731
- global_rss_budget_mb=getattr(health_config, 'global_rss_budget_mb', 2500) if health_config else 2500,
754
+ global_rss_budget_mb=_env_budget or _cfg_budget or 0,
732
755
  heartbeat_timeout_sec=getattr(health_config, 'heartbeat_timeout_sec', 60) if health_config else 60,
733
756
  check_interval_sec=getattr(health_config, 'health_check_interval_sec', 15) if health_config else 15,
734
757
  enable_structured_logging=getattr(health_config, 'enable_structured_logging', True) if health_config else True,
@@ -862,12 +885,21 @@ async def lifespan(application: FastAPI):
862
885
  # lifespan every POST /mcp 500s with "Task group is not initialized."
863
886
  # AsyncExitStack enters the context only when _mcp_app was mounted; if the
864
887
  # mount failed (non-fatal) the daemon starts normally without HTTP MCP.
888
+ # v3.6.9 (#34): wrap the MCP lifespan in shield so an unhandled exception
889
+ # or tool-level cancellation inside a session manager task group cannot
890
+ # propagate out and trigger uvicorn's graceful-shutdown handler.
865
891
  async with AsyncExitStack() as _mcp_stack:
866
892
  if _mcp_app is not None:
867
- await _mcp_stack.enter_async_context(
868
- _mcp_app.router.lifespan_context(_mcp_app)
869
- )
870
- logger.info("MCP HTTP session manager started (Streamable-HTTP on /mcp)")
893
+ try:
894
+ await _mcp_stack.enter_async_context(
895
+ _mcp_app.router.lifespan_context(_mcp_app)
896
+ )
897
+ logger.info("MCP HTTP session manager started (Streamable-HTTP on /mcp)")
898
+ except Exception as _mcp_lifespan_exc:
899
+ logger.warning(
900
+ "MCP HTTP session manager failed to start (non-fatal, stdio still works): %s",
901
+ _mcp_lifespan_exc,
902
+ )
871
903
 
872
904
  yield
873
905
 
@@ -968,6 +1000,14 @@ async def lifespan(application: FastAPI):
968
1000
  except Exception as exc: # pragma: no cover — defensive
969
1001
  logger.warning("recall_queue close failed: %s", exc)
970
1002
 
1003
+ # v3.6.8: Stop recall-health monitor (owns a daemon thread).
1004
+ _rh_stop = getattr(application.state, "recall_health_stop", None)
1005
+ if _rh_stop is not None:
1006
+ try:
1007
+ _rh_stop.set()
1008
+ except Exception as exc: # pragma: no cover — defensive
1009
+ logger.warning("recall_health monitor stop failed: %s", exc)
1010
+
971
1011
  # Stop HealthMonitor (health_monitor.py owns a daemon thread).
972
1012
  _health = getattr(application.state, "health_monitor", None)
973
1013
  if _health is not None:
@@ -1183,6 +1223,27 @@ def create_app() -> FastAPI:
1183
1223
  from superlocalmemory.mcp.server import server as _mcp_fastmcp
1184
1224
  _mcp_fastmcp.settings.streamable_http_path = "/"
1185
1225
  _mcp_fastmcp._session_manager = None # Defensive reset for idempotency
1226
+ # v3.6.9 (#36): configure DNS-rebinding protection from env.
1227
+ # Default: localhost-only (safe). Set SLM_MCP_ALLOWED_HOSTS=192.168.x.y:*
1228
+ # (comma-separated, e.g. "192.168.50.144:*,slm.lan:*") to open to a LAN.
1229
+ # Use "*" to disable protection entirely (trusted private network only).
1230
+ # TransportSecuritySettings imported lazily here so that MCP mount
1231
+ # works on older SDK versions when SLM_MCP_ALLOWED_HOSTS is not set.
1232
+ _mcp_allowed = os.environ.get("SLM_MCP_ALLOWED_HOSTS", "").strip()
1233
+ if _mcp_allowed:
1234
+ from mcp.server.transport_security import TransportSecuritySettings
1235
+ if _mcp_allowed == "*":
1236
+ _mcp_fastmcp.settings.transport_security = TransportSecuritySettings(
1237
+ enable_dns_rebinding_protection=False,
1238
+ )
1239
+ else:
1240
+ _hosts = [h.strip() for h in _mcp_allowed.split(",") if h.strip()]
1241
+ _mcp_fastmcp.settings.transport_security = TransportSecuritySettings(
1242
+ enable_dns_rebinding_protection=True,
1243
+ allowed_hosts=_hosts,
1244
+ allowed_origins=[f"http://{h}" for h in _hosts],
1245
+ )
1246
+ logger.info("MCP transport security: allowed_hosts=%r", _mcp_allowed)
1186
1247
  global _mcp_app
1187
1248
  _mcp_app = _mcp_fastmcp.streamable_http_app()
1188
1249
  application.mount("/mcp", _mcp_app)
@@ -1579,6 +1640,13 @@ def _register_daemon_routes(application: FastAPI) -> None:
1579
1640
  _update_activity()
1580
1641
  # Non-blocking peek: report status without forcing a re-init.
1581
1642
  engine = getattr(application.state, "engine", None)
1643
+ # v3.6.8: surface the recall-health verdict so a silently-degraded
1644
+ # recall path (warm-but-broken embedder) is VISIBLE, never silent.
1645
+ try:
1646
+ from superlocalmemory.server.recall_health import get_recall_health
1647
+ _recall_health = get_recall_health()
1648
+ except Exception:
1649
+ _recall_health = {"recall_healthy": None}
1582
1650
  return {
1583
1651
  "status": "ok",
1584
1652
  "pid": os.getpid(),
@@ -1587,6 +1655,9 @@ def _register_daemon_routes(application: FastAPI) -> None:
1587
1655
  # v3.4.52: clients can poll this to wait for embedding model
1588
1656
  # readiness before issuing recall calls.
1589
1657
  "embedding_warm": _embedding_warm,
1658
+ # v3.6.8: True iff the semantic channel actually fired on the last
1659
+ # health probe; includes self-heal counters.
1660
+ "recall_health": _recall_health,
1590
1661
  }
1591
1662
 
1592
1663
  @application.get("/recall")
@@ -2185,7 +2256,9 @@ if __name__ == "__main__":
2185
2256
  # freshly-sized file.
2186
2257
  rotate_oversized_logs()
2187
2258
  logging.basicConfig(level=logging.INFO, format="%(asctime)s %(message)s")
2188
- port = _DEFAULT_PORT
2259
+ # v3.6.9 (#33): honour SLM_DAEMON_PORT env so operators can configure the
2260
+ # port without changing the launch command. --port= arg takes precedence.
2261
+ port = int(os.environ.get("SLM_DAEMON_PORT", "") or _DEFAULT_PORT)
2189
2262
  for arg in sys.argv:
2190
2263
  if arg.startswith("--port="):
2191
2264
  port = int(arg.split("=")[1])
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: superlocalmemory
3
- Version: 3.6.7
3
+ Version: 3.6.9
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