superlocalmemory 3.8.1 → 3.8.2

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 (66) hide show
  1. package/CHANGELOG.md +52 -0
  2. package/README.md +2 -2
  3. package/package.json +1 -1
  4. package/plugin/.claude-plugin/plugin.json +1 -1
  5. package/plugin/CLAUDE.md +3 -3
  6. package/plugin/agents/slm-governance-advisor.md +1 -1
  7. package/plugin/agents/slm-loop-runner.md +1 -1
  8. package/plugin/agents/slm-memory-advisor.md +2 -2
  9. package/plugin/agents/slm-optimize-advisor.md +1 -1
  10. package/plugin/requirements.txt +1 -1
  11. package/plugin/skills/slm-cache/SKILL.md +1 -1
  12. package/plugin/skills/slm-compress/SKILL.md +1 -1
  13. package/plugin/skills/slm-governance/SKILL.md +1 -1
  14. package/plugin/skills/slm-graph/SKILL.md +1 -1
  15. package/plugin/skills/slm-loop/SKILL.md +1 -1
  16. package/plugin/skills/slm-mesh/SKILL.md +1 -1
  17. package/plugin/skills/slm-profile/SKILL.md +1 -1
  18. package/plugin/skills/slm-recall/SKILL.md +3 -5
  19. package/plugin/skills/slm-remember/SKILL.md +1 -1
  20. package/plugin/skills/slm-scope/SKILL.md +1 -1
  21. package/plugin/skills/slm-session/SKILL.md +1 -1
  22. package/plugin/skills/slm-status/SKILL.md +1 -1
  23. package/plugin-src/rules/AGENTS.md +2 -1
  24. package/plugin-src/skills/slm-cache/SKILL.md +1 -1
  25. package/plugin-src/skills/slm-compress/SKILL.md +1 -1
  26. package/plugin-src/skills/slm-graph/SKILL.md +1 -1
  27. package/plugin-src/skills/slm-recall/SKILL.md +3 -5
  28. package/plugin-src/skills/slm-remember/SKILL.md +1 -1
  29. package/plugin-src/skills/slm-session/SKILL.md +1 -1
  30. package/plugin-src/skills/slm-status/SKILL.md +1 -1
  31. package/pyproject.toml +1 -1
  32. package/scripts/postinstall.js +7 -1
  33. package/src/superlocalmemory/__init__.py +1 -1
  34. package/src/superlocalmemory/cli/commands.py +360 -2
  35. package/src/superlocalmemory/cli/main.py +62 -3
  36. package/src/superlocalmemory/cli/setup_wizard.py +142 -16
  37. package/src/superlocalmemory/core/component_healer.py +144 -0
  38. package/src/superlocalmemory/core/component_registry.py +487 -0
  39. package/src/superlocalmemory/core/config.py +21 -0
  40. package/src/superlocalmemory/core/embeddings.py +14 -1
  41. package/src/superlocalmemory/core/engine.py +9 -5
  42. package/src/superlocalmemory/core/ingestion_command.py +36 -16
  43. package/src/superlocalmemory/core/maintenance.py +43 -0
  44. package/src/superlocalmemory/core/maintenance_scheduler.py +28 -0
  45. package/src/superlocalmemory/core/recall_pipeline.py +39 -3
  46. package/src/superlocalmemory/core/store_pipeline.py +42 -0
  47. package/src/superlocalmemory/mcp/_daemon_proxy.py +6 -2
  48. package/src/superlocalmemory/mcp/_pool_adapter.py +4 -1
  49. package/src/superlocalmemory/mcp/tools_active.py +1 -1
  50. package/src/superlocalmemory/mcp/tools_core.py +17 -2
  51. package/src/superlocalmemory/retrieval/bridge_discovery.py +14 -0
  52. package/src/superlocalmemory/retrieval/spreading_activation.py +68 -38
  53. package/src/superlocalmemory/server/routes/behavioral.py +6 -2
  54. package/src/superlocalmemory/server/routes/learning.py +13 -3
  55. package/src/superlocalmemory/server/routes/memories.py +8 -3
  56. package/src/superlocalmemory/server/routes/v3_api.py +120 -0
  57. package/src/superlocalmemory/server/unified_daemon.py +266 -2
  58. package/src/superlocalmemory/storage/embedding_migrator.py +235 -0
  59. package/src/superlocalmemory/ui/index.html +3 -2
  60. package/src/superlocalmemory/ui/js/od-components.js +147 -0
  61. package/src/superlocalmemory/ui/js/od-entities.js +43 -0
  62. package/src/superlocalmemory/ui/js/od-graph.js +35 -0
  63. package/src/superlocalmemory/ui/js/od-health.js +18 -0
  64. package/src/superlocalmemory/ui/js/od-memories.js +37 -0
  65. package/src/superlocalmemory/ui/js/od-operations.js +36 -0
  66. package/src/superlocalmemory/ui/js/od-settings.js +72 -3
@@ -1060,6 +1060,126 @@ async def set_auto_recall_config(request: Request):
1060
1060
  return _internal_error()
1061
1061
 
1062
1062
 
1063
+ # ── Runtime behaviour config (v3.8.2 UX-1) ──────────────────
1064
+ # User-facing settings that take effect LIVE (no restart) via the
1065
+ # reconfigure_daemon_engine hot-swap path AND persist across restarts via
1066
+ # SLMConfig.save(). Scoped deliberately to fields that save() round-trips:
1067
+ # retrieval (asdict) + injection (explicit). Excludes setup-time/internal
1068
+ # knobs and any field save() doesn't persist (which would silently revert).
1069
+ _RUNTIME_CONFIG_FIELDS = (
1070
+ # (section, field, kind, min, max)
1071
+ ("retrieval", "top_k", "int", 1, 200),
1072
+ ("retrieval", "use_cross_encoder", "bool", None, None),
1073
+ ("injection", "enabled", "bool", None, None),
1074
+ ("injection", "core_block_enabled", "bool", None, None),
1075
+ ("injection", "core_block_max_facts", "int", 0, 50),
1076
+ )
1077
+
1078
+
1079
+ def _runtime_config_snapshot(config) -> dict:
1080
+ """Current values of the exposed runtime fields, grouped by section."""
1081
+ out: dict = {}
1082
+ for section, field, *_ in _RUNTIME_CONFIG_FIELDS:
1083
+ sec = getattr(config, section, None)
1084
+ out.setdefault(section, {})[field] = (
1085
+ getattr(sec, field, None) if sec is not None else None
1086
+ )
1087
+ return out
1088
+
1089
+
1090
+ @router.get("/runtime/config")
1091
+ async def get_runtime_config(request: Request):
1092
+ """User-facing runtime behaviour settings.
1093
+
1094
+ Recall depth (top_k), reranker on/off (use_cross_encoder), and memory
1095
+ injection (master + core-block). All apply live via the daemon hot-swap —
1096
+ no restart — and persist across restarts.
1097
+ """
1098
+ try:
1099
+ from superlocalmemory.core.config import SLMConfig
1100
+ config = getattr(request.app.state, "config", None) or SLMConfig.load()
1101
+ return {"success": True, "config": _runtime_config_snapshot(config)}
1102
+ except Exception:
1103
+ return _internal_error()
1104
+
1105
+
1106
+ @router.put("/runtime/config")
1107
+ async def set_runtime_config(request: Request):
1108
+ """Validate, persist, and hot-apply runtime behaviour settings.
1109
+
1110
+ Body: ``{"retrieval": {"top_k": 30}, "injection": {"enabled": false}}``.
1111
+ Only known fields are accepted; a bad type/range is rejected 400 and
1112
+ nothing is applied (fail-fast — never let a bad value wedge the engine).
1113
+ """
1114
+ _require_manage(request)
1115
+ try:
1116
+ import dataclasses as _dc
1117
+ from superlocalmemory.core.config import SLMConfig
1118
+
1119
+ body = await request.json()
1120
+ if not isinstance(body, dict):
1121
+ return JSONResponse({"error": "body must be an object"}, status_code=400)
1122
+
1123
+ # Validate everything BEFORE mutating anything.
1124
+ updates: dict[str, dict] = {}
1125
+ for section, field, kind, lo, hi in _RUNTIME_CONFIG_FIELDS:
1126
+ sec_in = body.get(section)
1127
+ if not isinstance(sec_in, dict) or field not in sec_in:
1128
+ continue
1129
+ val = sec_in[field]
1130
+ if kind == "bool":
1131
+ if not isinstance(val, bool):
1132
+ return JSONResponse(
1133
+ {"error": f"{section}.{field} must be true or false"},
1134
+ status_code=400,
1135
+ )
1136
+ elif kind == "int":
1137
+ # bool is an int subclass — reject it explicitly.
1138
+ if isinstance(val, bool) or not isinstance(val, int):
1139
+ return JSONResponse(
1140
+ {"error": f"{section}.{field} must be an integer"},
1141
+ status_code=400,
1142
+ )
1143
+ if (lo is not None and val < lo) or (hi is not None and val > hi):
1144
+ return JSONResponse(
1145
+ {"error": f"{section}.{field} must be between {lo} and {hi}"},
1146
+ status_code=400,
1147
+ )
1148
+ updates.setdefault(section, {})[field] = val
1149
+
1150
+ if not updates:
1151
+ return JSONResponse(
1152
+ {"error": "no known runtime settings in request body"},
1153
+ status_code=400,
1154
+ )
1155
+
1156
+ # These are plain behaviour flags read per-recall — NOT model/mode
1157
+ # swaps. So we apply them the light way: swap the sub-config objects on
1158
+ # the LIVE config the daemon+engine already hold, then persist to disk.
1159
+ # This avoids the heavyweight engine drain+rebuild (which reloads
1160
+ # models and can time out draining in-flight recalls) — the running
1161
+ # engine simply reads the new values on its next recall/injection.
1162
+ live = getattr(request.app.state, "config", None) or SLMConfig.load()
1163
+ targets = [live]
1164
+ engine = getattr(request.app.state, "engine", None)
1165
+ eng_cfg = getattr(engine, "_config", None) if engine is not None else None
1166
+ if eng_cfg is not None and eng_cfg is not live:
1167
+ targets.append(eng_cfg)
1168
+ for cfg in targets:
1169
+ for section, changes in updates.items():
1170
+ sec = getattr(cfg, section, None)
1171
+ if sec is None:
1172
+ continue
1173
+ # replace() yields a NEW sub-config (works frozen or mutable);
1174
+ # assigning the attribute is an atomic reference swap.
1175
+ setattr(cfg, section, _dc.replace(sec, **changes))
1176
+ request.app.state.config = live
1177
+ live.save(mode_change=False) # durable across restart
1178
+ return {"success": True, "config": _runtime_config_snapshot(live)}
1179
+ except Exception:
1180
+ return _internal_error()
1181
+
1182
+
1063
1183
  # ── IDE Status ───────────────────────────────────────────────
1064
1184
 
1065
1185
  @router.get("/ide/status")
@@ -1068,6 +1068,21 @@ async def _source_quality_repair_loop(
1068
1068
  state="retrying",
1069
1069
  last_error="storage_temporarily_unavailable",
1070
1070
  )
1071
+ except Exception as exc: # F6 fix: broad catch keeps loop alive
1072
+ # Any unexpected exception in a single tick (e.g. malformed JSON
1073
+ # blob, AttributeError from a half-migrated schema) must not kill
1074
+ # the entire repair loop. Log and continue to next iteration.
1075
+ logger.warning(
1076
+ "source-quality repair tick failed unexpectedly: %s — "
1077
+ "loop continues",
1078
+ exc,
1079
+ exc_info=True,
1080
+ )
1081
+ _set_source_quality_repair_status(
1082
+ application,
1083
+ state="retrying",
1084
+ last_error=type(exc).__name__,
1085
+ )
1071
1086
  else:
1072
1087
  successful_ticks += 1
1073
1088
  if pending == []:
@@ -1448,9 +1463,186 @@ async def lifespan(application: FastAPI):
1448
1463
  except Exception as exc:
1449
1464
  logger.warning("Vector store backfill failed (non-fatal): %s", exc)
1450
1465
 
1466
+ def _self_heal():
1467
+ """v3.8.2 zero-pain self-heal.
1468
+
1469
+ On daemon start (especially right after a pip/npm upgrade of a
1470
+ months-old database), silently restore full retrieval capability
1471
+ with ZERO user action:
1472
+ 1. embed facts that were never embedded (NULL embedding column),
1473
+ 2. backfill key-expansion alt-keys (BM25 recall aid) via one
1474
+ bounded maintenance pass per profile,
1475
+ 3. index everything (including the just-embedded facts) into the
1476
+ sqlite-vec store.
1477
+ Fully non-blocking (daemon thread) — recall keeps serving throughout.
1478
+ Every step is bounded + idempotent, so on an already-complete DB the
1479
+ whole pass is a fast no-op. Progress is exposed at /status.self_heal
1480
+ so the dashboard can show a plain-language "Optimizing memory…" line.
1481
+ """
1482
+ import time as _t
1483
+ global _SELF_HEAL_STATUS
1484
+ _SELF_HEAL_STATUS = {
1485
+ "state": "checking_components", "embeddings_backfilled": 0,
1486
+ "expansion_backfilled": 0, "null_remaining": None,
1487
+ "components": None,
1488
+ "started_at": _t.time(), "finished_at": None,
1489
+ }
1490
+ # Step 0 (v3.8.2 "whole self-healer"): repair components that
1491
+ # silently failed to install from the internet — BEFORE waiting on
1492
+ # the embedder, since a missing embedding model would make that wait
1493
+ # pointless. Downloads missing HF models (embedder/reranker, only
1494
+ # when torch is present) and pip-installs sqlite-vec when the
1495
+ # interpreter is user-writable. Bounded, fail-open, never sudo,
1496
+ # never auto-pulls Ollama. Manual-only items are recorded for the
1497
+ # dashboard "what's missing" report (GET /api/v3/components), not
1498
+ # acted on here.
1499
+ try:
1500
+ from superlocalmemory.core import component_healer
1501
+ heal_res = component_healer.heal_missing(
1502
+ config,
1503
+ on_progress=lambda k, m: logger.info(
1504
+ "Self-heal component[%s]: %s", k, m,
1505
+ ),
1506
+ )
1507
+ _SELF_HEAL_STATUS["components"] = heal_res
1508
+ if heal_res["attempted"]:
1509
+ logger.info("Self-heal components: %s", heal_res)
1510
+ except Exception as exc:
1511
+ logger.warning(
1512
+ "Self-heal component check failed (non-fatal): %s", exc,
1513
+ )
1514
+ _SELF_HEAL_STATUS["state"] = "waiting_embedder"
1515
+ for _ in range(120): # up to ~60s for the embedder to warm
1516
+ if _embedding_warm:
1517
+ break
1518
+ _t.sleep(0.5)
1519
+ try:
1520
+ embedder = getattr(retrieval_eng, "_embedder", None) if retrieval_eng else None
1521
+ db = engine._db
1522
+ if embedder is None or db is None:
1523
+ _SELF_HEAL_STATUS["state"] = "skipped_no_embedder"
1524
+ return
1525
+ # 1) Embed never-embedded facts (all profiles, the upgrade
1526
+ # headline). Looped: backfill is idempotent + bounded, and the
1527
+ # shared embedding worker can transiently return None under
1528
+ # startup contention (concurrent recall-warmup). Retrying until
1529
+ # the NULL count stops shrinking makes the heal converge
1530
+ # robustly rather than abandoning on one transient miss.
1531
+ # Facts that never embed (e.g. a document far over the model's
1532
+ # token limit) are left as-is after a bounded number of no-progress
1533
+ # attempts. No-op when there are no NULLs.
1534
+ _SELF_HEAL_STATUS["state"] = "backfilling_embeddings"
1535
+ try:
1536
+ from superlocalmemory.storage.embedding_migrator import (
1537
+ backfill_missing_embeddings,
1538
+ )
1539
+ # RECALL-PRIORITY THROTTLE: the embedding worker is a single
1540
+ # serialized subprocess shared with foreground recall. A
1541
+ # continuous backfill starves interactive query-embedding and
1542
+ # recalls time out. So: (a) tiny batches (short worker holds),
1543
+ # and (b) before each batch, defer while ANY user recall is in
1544
+ # flight — reusing the same recall_gate the pending materializer
1545
+ # uses. This keeps recall responsive throughout the heal (the
1546
+ # zero-pain requirement); the heal just takes a little longer.
1547
+ from superlocalmemory.core import recall_gate
1548
+ total_embedded = 0
1549
+ no_progress = 0
1550
+ for _attempt in range(500):
1551
+ # Absolute priority to user recalls: pause the heal while
1552
+ # a recall is active (bounded wait so we never wedge).
1553
+ _waited = 0.0
1554
+ while recall_gate.in_flight() > 0 and _waited < 30.0:
1555
+ _t.sleep(0.5)
1556
+ _waited += 0.5
1557
+ r = backfill_missing_embeddings(
1558
+ config, db, embedder, limit=5, all_profiles=True,
1559
+ )
1560
+ got = r.get("embedded", 0)
1561
+ total_embedded += got
1562
+ _SELF_HEAL_STATUS["embeddings_backfilled"] = total_embedded
1563
+ _SELF_HEAL_STATUS["null_remaining"] = r.get("remaining_null", 0)
1564
+ if r.get("remaining_null", 0) == 0:
1565
+ break
1566
+ if got == 0:
1567
+ no_progress += 1
1568
+ if no_progress >= 5: # transient recovery exhausted
1569
+ break
1570
+ _t.sleep(3) # let the worker settle, then retry
1571
+ else:
1572
+ no_progress = 0
1573
+ _t.sleep(0.5) # brief pause between bursts
1574
+ if total_embedded:
1575
+ logger.info(
1576
+ "Self-heal: embedded %d previously-unembedded facts "
1577
+ "(%d remaining)", total_embedded,
1578
+ _SELF_HEAL_STATUS["null_remaining"],
1579
+ )
1580
+ except Exception as exc:
1581
+ logger.warning("Self-heal embedding backfill failed (non-fatal): %s", exc)
1582
+ # 2) Math/key-expansion maintenance is intentionally NOT run here:
1583
+ # run_maintenance triggers a full Langevin backfill over every
1584
+ # fact, which on a large legacy DB takes minutes and its CPU
1585
+ # burst inflates foreground recall latency during the heal
1586
+ # window. The startup heal stays lean (embeddings + vector
1587
+ # index — what makes facts findable again). Langevin/Sheaf/
1588
+ # key-expansion continue to converge on the periodic
1589
+ # MaintenanceScheduler exactly as before — unchanged behavior.
1590
+ # 3) Index everything (incl. newly-embedded) into the vector store.
1591
+ _SELF_HEAL_STATUS["state"] = "indexing_vectors"
1592
+ try:
1593
+ _backfill_vector_store()
1594
+ except Exception as exc:
1595
+ logger.warning("Self-heal vector index failed (non-fatal): %s", exc)
1596
+ _SELF_HEAL_STATUS["state"] = "complete"
1597
+ _SELF_HEAL_STATUS["finished_at"] = _t.time()
1598
+ logger.info("Self-heal complete: %s", _SELF_HEAL_STATUS)
1599
+ except Exception as exc:
1600
+ _SELF_HEAL_STATUS["state"] = "error"
1601
+ logger.warning("Self-heal failed (non-fatal): %s", exc)
1602
+
1603
+ def _component_recheck_loop():
1604
+ """Periodic component re-check (v3.8.2 "whole self-healer").
1605
+
1606
+ The startup heal (_self_heal Step 0) runs once. This keeps the
1607
+ self-healer promise for a long-running daemon: it re-probes
1608
+ components on a slow cadence and auto-repairs any that regress
1609
+ (a cache eviction, a half-finished install). No-op on a healthy
1610
+ machine — nothing is auto-fixable-missing, so it is just a cheap
1611
+ probe. Disable with SLM_COMPONENT_RECHECK_SEC=0.
1612
+ """
1613
+ import os as _os
1614
+ import time as _t
1615
+ try:
1616
+ cadence = int(_os.environ.get("SLM_COMPONENT_RECHECK_SEC", "1800"))
1617
+ except ValueError:
1618
+ cadence = 1800
1619
+ if cadence <= 0:
1620
+ return
1621
+ # Let the startup heal + warmup settle before the first re-check.
1622
+ _t.sleep(max(cadence, 300))
1623
+ while True:
1624
+ try:
1625
+ from superlocalmemory.core import component_healer
1626
+ res = component_healer.heal_missing(
1627
+ config,
1628
+ on_progress=lambda k, m: logger.info(
1629
+ "Component re-check[%s]: %s", k, m,
1630
+ ),
1631
+ )
1632
+ if res["attempted"]:
1633
+ logger.info("Component re-check repaired: %s", res)
1634
+ except Exception as exc:
1635
+ logger.debug("Component re-check failed (non-fatal): %s", exc)
1636
+ _t.sleep(cadence)
1637
+
1451
1638
  threading.Thread(target=_warmup_embedder, daemon=True, name="embed-warmup").start()
1452
1639
  threading.Thread(target=_warmup_recall, daemon=True, name="recall-warmup").start()
1453
- threading.Thread(target=_backfill_vector_store, daemon=True, name="vs-backfill").start()
1640
+ # v3.8.2: self-heal supersedes the bare vector-store backfill (it calls
1641
+ # _backfill_vector_store itself, after embedding + expansion heal).
1642
+ threading.Thread(target=_self_heal, daemon=True, name="self-heal").start()
1643
+ threading.Thread(
1644
+ target=_component_recheck_loop, daemon=True, name="component-recheck",
1645
+ ).start()
1454
1646
 
1455
1647
  # v3.6.8: Runtime recall-health monitor. The three warmups above run
1456
1648
  # ONCE at boot; on a long-running daemon the graph page cache gets
@@ -2761,7 +2953,15 @@ def _register_daemon_routes(application: FastAPI) -> None:
2761
2953
  request: Request,
2762
2954
  q: str = "", query: str = "", limit: int = CANONICAL_RECALL_LIMIT,
2763
2955
  session_id: str = "",
2764
- fast: bool = True,
2956
+ # v3.8.2 client-driven agentic: ``fast`` is left UNSET (None) by default
2957
+ # so the daemon resolves the configured policy (retrieval.client_driven_agentic,
2958
+ # ships True). The agent hot path is consumed by a frontier LLM that
2959
+ # reformulates queries far better than the local Ollama model, so it
2960
+ # skips the internal agentic round and returns fast local retrieval (all
2961
+ # six channels + reranker) plus confidence signals; the client re-queries
2962
+ # on low confidence. An explicit ?fast=true / ?fast=false always wins.
2963
+ # See recall_pipeline.resolve_hot_path_fast.
2964
+ fast: bool | None = None,
2765
2965
  full: bool = False,
2766
2966
  include_source: bool = False,
2767
2967
  include_global: bool | None = None,
@@ -2773,6 +2973,10 @@ def _register_daemon_routes(application: FastAPI) -> None:
2773
2973
  engine = _get_engine_or_503()
2774
2974
  if not search_query:
2775
2975
  return {"results": [], "count": 0, "query_type": "none", "retrieval_time_ms": 0}
2976
+ # v3.8.2: resolve the client-driven-agentic default now so the concrete
2977
+ # bool drives BOTH the full-recall semaphore below and engine.recall().
2978
+ from superlocalmemory.core.recall_pipeline import resolve_hot_path_fast
2979
+ fast = resolve_hot_path_fast(fast, engine._config)
2776
2980
  # S9-DASH-02: session_id for the outcome-queue producer.
2777
2981
  # Priority: ?session_id= > X-SLM-Session-Id header > synthetic
2778
2982
  # "http:<ts>". Without a session_id the recall still works
@@ -3178,8 +3382,68 @@ def _register_daemon_routes(application: FastAPI) -> None:
3178
3382
  "legacy_port": _LEGACY_PORT,
3179
3383
  "profile": profile_snapshot.profile_id,
3180
3384
  "profile_generation": profile_snapshot.generation,
3385
+ # F2 fix: expose M028 backfill progress so operators can monitor
3386
+ # the post-upgrade fact/entity association repair state.
3387
+ "m028_backfill": getattr(
3388
+ application.state,
3389
+ "fact_entity_association_repair_status",
3390
+ None,
3391
+ ),
3392
+ # v3.8.2: zero-pain self-heal progress (embeddings/expansion/vector
3393
+ # index backfill after an upgrade). Dashboard renders a plain
3394
+ # "Optimizing memory…" line from this. Defaults to idle before start.
3395
+ "self_heal": globals().get("_SELF_HEAL_STATUS", {"state": "idle"}),
3181
3396
  }
3182
3397
 
3398
+ @application.get("/api/v3/components")
3399
+ async def components():
3400
+ """Component / dependency health (v3.8.2).
3401
+
3402
+ Read-only snapshot from the central registry (core.component_registry)
3403
+ — the same source the self-heal thread acts on. Powers the dashboard
3404
+ 'what's missing' report and `slm doctor`. Includes live 'retrying'
3405
+ overlays while a background repair is in flight. Never mutates state.
3406
+ """
3407
+ _update_activity()
3408
+ try:
3409
+ from superlocalmemory.core import component_registry
3410
+ cfg = getattr(application.state, "config", None)
3411
+ return component_registry.snapshot(cfg)
3412
+ except Exception as exc:
3413
+ raise HTTPException(500, detail=str(exc))
3414
+
3415
+ @application.post("/api/v3/components/heal")
3416
+ async def heal_components(request: Request):
3417
+ """Trigger a component self-heal pass (dashboard 'Retry now').
3418
+
3419
+ Repairs auto-fixable missing components (re-download missing models,
3420
+ install sqlite-vec). Runs in a background thread and returns
3421
+ immediately so the HTTP request never blocks on a multi-minute
3422
+ download; the dashboard polls GET /api/v3/components to watch the
3423
+ 'retrying' → 'ok' transition. Safe to call repeatedly (no-op when
3424
+ healthy). Requires the dashboard write principal, like other
3425
+ dashboard mutations.
3426
+ """
3427
+ _require_write_actor(request)
3428
+ _update_activity()
3429
+ cfg = getattr(application.state, "config", None)
3430
+
3431
+ def _run():
3432
+ try:
3433
+ from superlocalmemory.core import component_healer
3434
+ res = component_healer.heal_missing(
3435
+ cfg,
3436
+ on_progress=lambda k, m: logger.info(
3437
+ "Manual heal[%s]: %s", k, m,
3438
+ ),
3439
+ )
3440
+ logger.info("Manual component heal: %s", res)
3441
+ except Exception as exc:
3442
+ logger.warning("Manual component heal failed (non-fatal): %s", exc)
3443
+
3444
+ threading.Thread(target=_run, daemon=True, name="manual-heal").start()
3445
+ return {"status": "started"}
3446
+
3183
3447
  @application.get("/list")
3184
3448
  async def list_facts(limit: int = 50):
3185
3449
  _update_activity()