superlocalmemory 3.8.1 → 3.8.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 (67) hide show
  1. package/CHANGELOG.md +67 -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 +80 -26
  56. package/src/superlocalmemory/server/routes/v3_api.py +120 -0
  57. package/src/superlocalmemory/server/unified_daemon.py +349 -10
  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/core.js +6 -1
  61. package/src/superlocalmemory/ui/js/od-components.js +147 -0
  62. package/src/superlocalmemory/ui/js/od-entities.js +43 -0
  63. package/src/superlocalmemory/ui/js/od-graph.js +35 -0
  64. package/src/superlocalmemory/ui/js/od-health.js +18 -0
  65. package/src/superlocalmemory/ui/js/od-memories.js +37 -0
  66. package/src/superlocalmemory/ui/js/od-operations.js +36 -0
  67. package/src/superlocalmemory/ui/js/od-settings.js +72 -3
@@ -453,6 +453,61 @@ def _emit_event(
453
453
  import asyncio as _asyncio
454
454
  _recall_semaphore = _asyncio.Semaphore(3)
455
455
 
456
+
457
+ def _recall_budget_s() -> float:
458
+ """Generous latency budget for a recall before the keyword fallback (v3.8.3).
459
+
460
+ SLM's value is quality recall under heavy multi-agent load, so semantic
461
+ recall is given ample time; the keyword fallback is a LAST-RESORT safety
462
+ net for a genuine hang (e.g. a wedged embedder), not a speed cutoff. Tune
463
+ with SLM_SEARCH_RECALL_TIMEOUT_S (shared with the dashboard search route).
464
+ """
465
+ import os
466
+ try:
467
+ v = float(os.environ.get("SLM_SEARCH_RECALL_TIMEOUT_S", ""))
468
+ return v if v > 0 else 25.0
469
+ except (TypeError, ValueError):
470
+ return 25.0
471
+
472
+
473
+ def _recall_keyword_fallback(engine, query: str, limit: int) -> dict:
474
+ """Fast profile-scoped keyword (LIKE) fallback for /recall.
475
+
476
+ Used only when semantic recall exceeds its budget, so CLI/MCP callers get
477
+ a bounded response instead of hanging. Mirrors the dashboard /api/search
478
+ fallback shape (retrieval_mode=degraded_lexical).
479
+ """
480
+ results = []
481
+ try:
482
+ rows = engine._db.execute(
483
+ "SELECT fact_id, content, confidence FROM atomic_facts "
484
+ "WHERE profile_id = ? AND content LIKE ? "
485
+ "ORDER BY confidence DESC LIMIT ?",
486
+ (engine.profile_id, f"%{query}%", limit),
487
+ )
488
+ for pos, r in enumerate(rows, start=1):
489
+ d = dict(r)
490
+ results.append({
491
+ "fact_id": d.get("fact_id"),
492
+ "content": (d.get("content") or "")[:2400],
493
+ "score": None, "relevance_score": None, "ranking_score": None,
494
+ "confidence": d.get("confidence"),
495
+ "rank_position": pos,
496
+ })
497
+ except Exception as exc:
498
+ logger.warning("recall keyword fallback failed (non-fatal): %s", exc)
499
+ return {
500
+ "ok": True,
501
+ "query": query,
502
+ "query_type": "text_search",
503
+ "retrieval_mode": "degraded_lexical",
504
+ "degraded_reason": "recall_budget_exceeded",
505
+ "result_count": len(results),
506
+ "results": results,
507
+ "count": len(results),
508
+ "no_confident_match": True,
509
+ }
510
+
456
511
  # v3.4.52: Embedding model warm state. Set to True by the async pre-warm
457
512
  # thread once Ollama has loaded the embedding model. /health reports this
458
513
  # so MCP clients can wait for warm state before issuing recall calls.
@@ -1068,6 +1123,21 @@ async def _source_quality_repair_loop(
1068
1123
  state="retrying",
1069
1124
  last_error="storage_temporarily_unavailable",
1070
1125
  )
1126
+ except Exception as exc: # F6 fix: broad catch keeps loop alive
1127
+ # Any unexpected exception in a single tick (e.g. malformed JSON
1128
+ # blob, AttributeError from a half-migrated schema) must not kill
1129
+ # the entire repair loop. Log and continue to next iteration.
1130
+ logger.warning(
1131
+ "source-quality repair tick failed unexpectedly: %s — "
1132
+ "loop continues",
1133
+ exc,
1134
+ exc_info=True,
1135
+ )
1136
+ _set_source_quality_repair_status(
1137
+ application,
1138
+ state="retrying",
1139
+ last_error=type(exc).__name__,
1140
+ )
1071
1141
  else:
1072
1142
  successful_ticks += 1
1073
1143
  if pending == []:
@@ -1448,9 +1518,186 @@ async def lifespan(application: FastAPI):
1448
1518
  except Exception as exc:
1449
1519
  logger.warning("Vector store backfill failed (non-fatal): %s", exc)
1450
1520
 
1521
+ def _self_heal():
1522
+ """v3.8.2 zero-pain self-heal.
1523
+
1524
+ On daemon start (especially right after a pip/npm upgrade of a
1525
+ months-old database), silently restore full retrieval capability
1526
+ with ZERO user action:
1527
+ 1. embed facts that were never embedded (NULL embedding column),
1528
+ 2. backfill key-expansion alt-keys (BM25 recall aid) via one
1529
+ bounded maintenance pass per profile,
1530
+ 3. index everything (including the just-embedded facts) into the
1531
+ sqlite-vec store.
1532
+ Fully non-blocking (daemon thread) — recall keeps serving throughout.
1533
+ Every step is bounded + idempotent, so on an already-complete DB the
1534
+ whole pass is a fast no-op. Progress is exposed at /status.self_heal
1535
+ so the dashboard can show a plain-language "Optimizing memory…" line.
1536
+ """
1537
+ import time as _t
1538
+ global _SELF_HEAL_STATUS
1539
+ _SELF_HEAL_STATUS = {
1540
+ "state": "checking_components", "embeddings_backfilled": 0,
1541
+ "expansion_backfilled": 0, "null_remaining": None,
1542
+ "components": None,
1543
+ "started_at": _t.time(), "finished_at": None,
1544
+ }
1545
+ # Step 0 (v3.8.2 "whole self-healer"): repair components that
1546
+ # silently failed to install from the internet — BEFORE waiting on
1547
+ # the embedder, since a missing embedding model would make that wait
1548
+ # pointless. Downloads missing HF models (embedder/reranker, only
1549
+ # when torch is present) and pip-installs sqlite-vec when the
1550
+ # interpreter is user-writable. Bounded, fail-open, never sudo,
1551
+ # never auto-pulls Ollama. Manual-only items are recorded for the
1552
+ # dashboard "what's missing" report (GET /api/v3/components), not
1553
+ # acted on here.
1554
+ try:
1555
+ from superlocalmemory.core import component_healer
1556
+ heal_res = component_healer.heal_missing(
1557
+ config,
1558
+ on_progress=lambda k, m: logger.info(
1559
+ "Self-heal component[%s]: %s", k, m,
1560
+ ),
1561
+ )
1562
+ _SELF_HEAL_STATUS["components"] = heal_res
1563
+ if heal_res["attempted"]:
1564
+ logger.info("Self-heal components: %s", heal_res)
1565
+ except Exception as exc:
1566
+ logger.warning(
1567
+ "Self-heal component check failed (non-fatal): %s", exc,
1568
+ )
1569
+ _SELF_HEAL_STATUS["state"] = "waiting_embedder"
1570
+ for _ in range(120): # up to ~60s for the embedder to warm
1571
+ if _embedding_warm:
1572
+ break
1573
+ _t.sleep(0.5)
1574
+ try:
1575
+ embedder = getattr(retrieval_eng, "_embedder", None) if retrieval_eng else None
1576
+ db = engine._db
1577
+ if embedder is None or db is None:
1578
+ _SELF_HEAL_STATUS["state"] = "skipped_no_embedder"
1579
+ return
1580
+ # 1) Embed never-embedded facts (all profiles, the upgrade
1581
+ # headline). Looped: backfill is idempotent + bounded, and the
1582
+ # shared embedding worker can transiently return None under
1583
+ # startup contention (concurrent recall-warmup). Retrying until
1584
+ # the NULL count stops shrinking makes the heal converge
1585
+ # robustly rather than abandoning on one transient miss.
1586
+ # Facts that never embed (e.g. a document far over the model's
1587
+ # token limit) are left as-is after a bounded number of no-progress
1588
+ # attempts. No-op when there are no NULLs.
1589
+ _SELF_HEAL_STATUS["state"] = "backfilling_embeddings"
1590
+ try:
1591
+ from superlocalmemory.storage.embedding_migrator import (
1592
+ backfill_missing_embeddings,
1593
+ )
1594
+ # RECALL-PRIORITY THROTTLE: the embedding worker is a single
1595
+ # serialized subprocess shared with foreground recall. A
1596
+ # continuous backfill starves interactive query-embedding and
1597
+ # recalls time out. So: (a) tiny batches (short worker holds),
1598
+ # and (b) before each batch, defer while ANY user recall is in
1599
+ # flight — reusing the same recall_gate the pending materializer
1600
+ # uses. This keeps recall responsive throughout the heal (the
1601
+ # zero-pain requirement); the heal just takes a little longer.
1602
+ from superlocalmemory.core import recall_gate
1603
+ total_embedded = 0
1604
+ no_progress = 0
1605
+ for _attempt in range(500):
1606
+ # Absolute priority to user recalls: pause the heal while
1607
+ # a recall is active (bounded wait so we never wedge).
1608
+ _waited = 0.0
1609
+ while recall_gate.in_flight() > 0 and _waited < 30.0:
1610
+ _t.sleep(0.5)
1611
+ _waited += 0.5
1612
+ r = backfill_missing_embeddings(
1613
+ config, db, embedder, limit=5, all_profiles=True,
1614
+ )
1615
+ got = r.get("embedded", 0)
1616
+ total_embedded += got
1617
+ _SELF_HEAL_STATUS["embeddings_backfilled"] = total_embedded
1618
+ _SELF_HEAL_STATUS["null_remaining"] = r.get("remaining_null", 0)
1619
+ if r.get("remaining_null", 0) == 0:
1620
+ break
1621
+ if got == 0:
1622
+ no_progress += 1
1623
+ if no_progress >= 5: # transient recovery exhausted
1624
+ break
1625
+ _t.sleep(3) # let the worker settle, then retry
1626
+ else:
1627
+ no_progress = 0
1628
+ _t.sleep(0.5) # brief pause between bursts
1629
+ if total_embedded:
1630
+ logger.info(
1631
+ "Self-heal: embedded %d previously-unembedded facts "
1632
+ "(%d remaining)", total_embedded,
1633
+ _SELF_HEAL_STATUS["null_remaining"],
1634
+ )
1635
+ except Exception as exc:
1636
+ logger.warning("Self-heal embedding backfill failed (non-fatal): %s", exc)
1637
+ # 2) Math/key-expansion maintenance is intentionally NOT run here:
1638
+ # run_maintenance triggers a full Langevin backfill over every
1639
+ # fact, which on a large legacy DB takes minutes and its CPU
1640
+ # burst inflates foreground recall latency during the heal
1641
+ # window. The startup heal stays lean (embeddings + vector
1642
+ # index — what makes facts findable again). Langevin/Sheaf/
1643
+ # key-expansion continue to converge on the periodic
1644
+ # MaintenanceScheduler exactly as before — unchanged behavior.
1645
+ # 3) Index everything (incl. newly-embedded) into the vector store.
1646
+ _SELF_HEAL_STATUS["state"] = "indexing_vectors"
1647
+ try:
1648
+ _backfill_vector_store()
1649
+ except Exception as exc:
1650
+ logger.warning("Self-heal vector index failed (non-fatal): %s", exc)
1651
+ _SELF_HEAL_STATUS["state"] = "complete"
1652
+ _SELF_HEAL_STATUS["finished_at"] = _t.time()
1653
+ logger.info("Self-heal complete: %s", _SELF_HEAL_STATUS)
1654
+ except Exception as exc:
1655
+ _SELF_HEAL_STATUS["state"] = "error"
1656
+ logger.warning("Self-heal failed (non-fatal): %s", exc)
1657
+
1658
+ def _component_recheck_loop():
1659
+ """Periodic component re-check (v3.8.2 "whole self-healer").
1660
+
1661
+ The startup heal (_self_heal Step 0) runs once. This keeps the
1662
+ self-healer promise for a long-running daemon: it re-probes
1663
+ components on a slow cadence and auto-repairs any that regress
1664
+ (a cache eviction, a half-finished install). No-op on a healthy
1665
+ machine — nothing is auto-fixable-missing, so it is just a cheap
1666
+ probe. Disable with SLM_COMPONENT_RECHECK_SEC=0.
1667
+ """
1668
+ import os as _os
1669
+ import time as _t
1670
+ try:
1671
+ cadence = int(_os.environ.get("SLM_COMPONENT_RECHECK_SEC", "1800"))
1672
+ except ValueError:
1673
+ cadence = 1800
1674
+ if cadence <= 0:
1675
+ return
1676
+ # Let the startup heal + warmup settle before the first re-check.
1677
+ _t.sleep(max(cadence, 300))
1678
+ while True:
1679
+ try:
1680
+ from superlocalmemory.core import component_healer
1681
+ res = component_healer.heal_missing(
1682
+ config,
1683
+ on_progress=lambda k, m: logger.info(
1684
+ "Component re-check[%s]: %s", k, m,
1685
+ ),
1686
+ )
1687
+ if res["attempted"]:
1688
+ logger.info("Component re-check repaired: %s", res)
1689
+ except Exception as exc:
1690
+ logger.debug("Component re-check failed (non-fatal): %s", exc)
1691
+ _t.sleep(cadence)
1692
+
1451
1693
  threading.Thread(target=_warmup_embedder, daemon=True, name="embed-warmup").start()
1452
1694
  threading.Thread(target=_warmup_recall, daemon=True, name="recall-warmup").start()
1453
- threading.Thread(target=_backfill_vector_store, daemon=True, name="vs-backfill").start()
1695
+ # v3.8.2: self-heal supersedes the bare vector-store backfill (it calls
1696
+ # _backfill_vector_store itself, after embedding + expansion heal).
1697
+ threading.Thread(target=_self_heal, daemon=True, name="self-heal").start()
1698
+ threading.Thread(
1699
+ target=_component_recheck_loop, daemon=True, name="component-recheck",
1700
+ ).start()
1454
1701
 
1455
1702
  # v3.6.8: Runtime recall-health monitor. The three warmups above run
1456
1703
  # ONCE at boot; on a long-running daemon the graph page cache gets
@@ -2761,7 +3008,15 @@ def _register_daemon_routes(application: FastAPI) -> None:
2761
3008
  request: Request,
2762
3009
  q: str = "", query: str = "", limit: int = CANONICAL_RECALL_LIMIT,
2763
3010
  session_id: str = "",
2764
- fast: bool = True,
3011
+ # v3.8.2 client-driven agentic: ``fast`` is left UNSET (None) by default
3012
+ # so the daemon resolves the configured policy (retrieval.client_driven_agentic,
3013
+ # ships True). The agent hot path is consumed by a frontier LLM that
3014
+ # reformulates queries far better than the local Ollama model, so it
3015
+ # skips the internal agentic round and returns fast local retrieval (all
3016
+ # six channels + reranker) plus confidence signals; the client re-queries
3017
+ # on low confidence. An explicit ?fast=true / ?fast=false always wins.
3018
+ # See recall_pipeline.resolve_hot_path_fast.
3019
+ fast: bool | None = None,
2765
3020
  full: bool = False,
2766
3021
  include_source: bool = False,
2767
3022
  include_global: bool | None = None,
@@ -2773,6 +3028,10 @@ def _register_daemon_routes(application: FastAPI) -> None:
2773
3028
  engine = _get_engine_or_503()
2774
3029
  if not search_query:
2775
3030
  return {"results": [], "count": 0, "query_type": "none", "retrieval_time_ms": 0}
3031
+ # v3.8.2: resolve the client-driven-agentic default now so the concrete
3032
+ # bool drives BOTH the full-recall semaphore below and engine.recall().
3033
+ from superlocalmemory.core.recall_pipeline import resolve_hot_path_fast
3034
+ fast = resolve_hot_path_fast(fast, engine._config)
2776
3035
  # S9-DASH-02: session_id for the outcome-queue producer.
2777
3036
  # Priority: ?session_id= > X-SLM-Session-Id header > synthetic
2778
3037
  # "http:<ts>". Without a session_id the recall still works
@@ -2809,15 +3068,35 @@ def _register_daemon_routes(application: FastAPI) -> None:
2809
3068
  if not fast:
2810
3069
  await _recall_semaphore.acquire()
2811
3070
  try:
2812
- response = await asyncio.to_thread(
2813
- engine.recall,
2814
- search_query, limit=limit, session_id=effective_sid,
2815
- agent_id=recall_actor,
2816
- fast=fast,
2817
- include_global=include_global,
2818
- include_shared=include_shared,
2819
- window=window or None,
3071
+ # v3.8.3: bound the recall so CLI/MCP callers never hang on a
3072
+ # wedged embedder. Poll the executor future (which cannot be
3073
+ # cancelled) without blocking the loop, and give quality recall a
3074
+ # GENEROUS budget; only if it is exceeded do we serve the fast
3075
+ # keyword fallback. The orphaned recall finishes in the background.
3076
+ loop = asyncio.get_running_loop()
3077
+ _rf = loop.run_in_executor(
3078
+ None,
3079
+ lambda: engine.recall(
3080
+ search_query, limit=limit, session_id=effective_sid,
3081
+ agent_id=recall_actor,
3082
+ fast=fast,
3083
+ include_global=include_global,
3084
+ include_shared=include_shared,
3085
+ window=window or None,
3086
+ ),
2820
3087
  )
3088
+ _budget = _recall_budget_s()
3089
+ _deadline = loop.time() + _budget
3090
+ while not _rf.done() and loop.time() < _deadline:
3091
+ await asyncio.sleep(0.05)
3092
+ if not _rf.done():
3093
+ _rf.add_done_callback(lambda f: (f.cancelled() or f.exception()))
3094
+ logger.warning(
3095
+ "recall: semantic recall exceeded %.0fs budget for %r — "
3096
+ "serving keyword fallback", _budget, (search_query or "")[:80],
3097
+ )
3098
+ return _recall_keyword_fallback(engine, search_query, limit)
3099
+ response = _rf.result()
2821
3100
  # v3.4.26: return the same field shape as recall_worker so
2822
3101
  # MCP processes proxying through the daemon get recall_trace-
2823
3102
  # compatible data without a second round trip.
@@ -3178,8 +3457,68 @@ def _register_daemon_routes(application: FastAPI) -> None:
3178
3457
  "legacy_port": _LEGACY_PORT,
3179
3458
  "profile": profile_snapshot.profile_id,
3180
3459
  "profile_generation": profile_snapshot.generation,
3460
+ # F2 fix: expose M028 backfill progress so operators can monitor
3461
+ # the post-upgrade fact/entity association repair state.
3462
+ "m028_backfill": getattr(
3463
+ application.state,
3464
+ "fact_entity_association_repair_status",
3465
+ None,
3466
+ ),
3467
+ # v3.8.2: zero-pain self-heal progress (embeddings/expansion/vector
3468
+ # index backfill after an upgrade). Dashboard renders a plain
3469
+ # "Optimizing memory…" line from this. Defaults to idle before start.
3470
+ "self_heal": globals().get("_SELF_HEAL_STATUS", {"state": "idle"}),
3181
3471
  }
3182
3472
 
3473
+ @application.get("/api/v3/components")
3474
+ async def components():
3475
+ """Component / dependency health (v3.8.2).
3476
+
3477
+ Read-only snapshot from the central registry (core.component_registry)
3478
+ — the same source the self-heal thread acts on. Powers the dashboard
3479
+ 'what's missing' report and `slm doctor`. Includes live 'retrying'
3480
+ overlays while a background repair is in flight. Never mutates state.
3481
+ """
3482
+ _update_activity()
3483
+ try:
3484
+ from superlocalmemory.core import component_registry
3485
+ cfg = getattr(application.state, "config", None)
3486
+ return component_registry.snapshot(cfg)
3487
+ except Exception as exc:
3488
+ raise HTTPException(500, detail=str(exc))
3489
+
3490
+ @application.post("/api/v3/components/heal")
3491
+ async def heal_components(request: Request):
3492
+ """Trigger a component self-heal pass (dashboard 'Retry now').
3493
+
3494
+ Repairs auto-fixable missing components (re-download missing models,
3495
+ install sqlite-vec). Runs in a background thread and returns
3496
+ immediately so the HTTP request never blocks on a multi-minute
3497
+ download; the dashboard polls GET /api/v3/components to watch the
3498
+ 'retrying' → 'ok' transition. Safe to call repeatedly (no-op when
3499
+ healthy). Requires the dashboard write principal, like other
3500
+ dashboard mutations.
3501
+ """
3502
+ _require_write_actor(request)
3503
+ _update_activity()
3504
+ cfg = getattr(application.state, "config", None)
3505
+
3506
+ def _run():
3507
+ try:
3508
+ from superlocalmemory.core import component_healer
3509
+ res = component_healer.heal_missing(
3510
+ cfg,
3511
+ on_progress=lambda k, m: logger.info(
3512
+ "Manual heal[%s]: %s", k, m,
3513
+ ),
3514
+ )
3515
+ logger.info("Manual component heal: %s", res)
3516
+ except Exception as exc:
3517
+ logger.warning("Manual component heal failed (non-fatal): %s", exc)
3518
+
3519
+ threading.Thread(target=_run, daemon=True, name="manual-heal").start()
3520
+ return {"status": "started"}
3521
+
3183
3522
  @application.get("/list")
3184
3523
  async def list_facts(limit: int = 50):
3185
3524
  _update_activity()
@@ -26,6 +26,23 @@ if TYPE_CHECKING:
26
26
 
27
27
  logger = logging.getLogger(__name__)
28
28
 
29
+ # ---------------------------------------------------------------------------
30
+ # Backfill constants
31
+ # ---------------------------------------------------------------------------
32
+
33
+ #: Default batch size for backfill_missing_embeddings.
34
+ _BACKFILL_BATCH_SIZE = 50
35
+
36
+ #: Max characters embedded per fact during backfill. The embedding model
37
+ #: (nomic-embed-text-v1.5) truncates at ~8192 tokens anyway, but a raw
38
+ #: oversized document (observed up to 107 KB on a real DB) makes the shared
39
+ #: single-worker embedder busy for 15-20s on ONE fact — starving foreground
40
+ #: recall during a self-heal pass. Bounding the input keeps every fact's embed
41
+ #: fast and the worker responsive; the leading slice captures the fact's gist
42
+ #: for semantic recall. Facts this large are documents that were almost
43
+ #: certainly NULL because they failed to embed at ingestion for the same reason.
44
+ _MAX_EMBED_CHARS = 8000
45
+
29
46
  # Sentinel stored in config.json when no model has been set yet.
30
47
  _NO_MODEL = ""
31
48
 
@@ -44,6 +61,24 @@ def _model_signature(config: SLMConfig) -> str:
44
61
  return f"{emb.model_name}::{emb.dimension}"
45
62
 
46
63
 
64
+ def _normalize_signature(signature: str) -> str:
65
+ """Normalize a signature for equivalence comparison.
66
+
67
+ v3.8.2 self-healing: the SAME embedding model has been recorded under
68
+ different name strings across releases — notably the HuggingFace org
69
+ prefix drifted (``nomic-ai/nomic-embed-text-v1.5`` vs the bare
70
+ ``nomic-embed-text-v1.5``). A prefix-only difference does NOT change the
71
+ embedding vector space, so it must not trigger a full multi-hour re-embed
72
+ when a non-technical user upgrades. This collapses the model name to its
73
+ basename (segment after the last ``/``) while keeping the ``::dimension``
74
+ suffix — a genuine model change (different basename OR dimension) still
75
+ differs and still triggers migration.
76
+ """
77
+ model, sep, dim = signature.partition("::")
78
+ model = model.rsplit("/", 1)[-1].strip()
79
+ return f"{model}{sep}{dim}" if sep else model
80
+
81
+
47
82
  def _read_stored_signature(config_dir: Path) -> str:
48
83
  """Read the last-used embedding model signature from config.json."""
49
84
  config_path = config_dir / "config.json"
@@ -88,6 +123,19 @@ def check_embedding_migration(config: SLMConfig) -> bool:
88
123
  if stored_sig == current_sig:
89
124
  return False
90
125
 
126
+ # v3.8.2 self-healing: a prefix-only model-name drift (e.g. the nomic-ai/
127
+ # org prefix appearing/disappearing between releases) is the SAME vector
128
+ # space — absorb the transition by refreshing the stored signature to the
129
+ # current form, with NO re-embed. This spares non-technical users a
130
+ # multi-hour full re-index on a cosmetic upgrade.
131
+ if _normalize_signature(stored_sig) == _normalize_signature(current_sig):
132
+ _write_stored_signature(config.base_dir, current_sig)
133
+ logger.info(
134
+ "Embedding signature normalized (no re-embed): %s ~= %s",
135
+ stored_sig, current_sig,
136
+ )
137
+ return False
138
+
91
139
  logger.warning(
92
140
  "Embedding model changed: %s -> %s. Re-indexing required.",
93
141
  stored_sig, current_sig,
@@ -177,3 +225,190 @@ def run_embedding_migration(
177
225
  reindexed, total,
178
226
  )
179
227
  return reindexed
228
+
229
+
230
+ # ---------------------------------------------------------------------------
231
+ # Backfill: embed facts that were NEVER embedded (embedding IS NULL)
232
+ # ---------------------------------------------------------------------------
233
+
234
+ def _count_null_embeddings(
235
+ db: Any,
236
+ profile_id: str,
237
+ all_profiles: bool,
238
+ ) -> int:
239
+ """Return count of atomic_facts rows with NULL embedding."""
240
+ if all_profiles:
241
+ rows = db.execute(
242
+ "SELECT count(*) AS c FROM atomic_facts WHERE embedding IS NULL",
243
+ )
244
+ else:
245
+ rows = db.execute(
246
+ "SELECT count(*) AS c FROM atomic_facts "
247
+ "WHERE embedding IS NULL AND profile_id = ?",
248
+ (profile_id,),
249
+ )
250
+ return int(rows[0]["c"]) if rows else 0
251
+
252
+
253
+ def backfill_missing_embeddings(
254
+ config: "SLMConfig",
255
+ db: Any,
256
+ embedder: Any,
257
+ batch_size: int = _BACKFILL_BATCH_SIZE,
258
+ limit: int | None = None,
259
+ all_profiles: bool = False,
260
+ ) -> dict[str, int]:
261
+ """Embed atomic_facts rows whose ``embedding`` column is NULL.
262
+
263
+ Unlike :func:`run_embedding_migration` (which re-embeds on model-signature
264
+ change), this function handles facts that were *never* embedded — for
265
+ example facts stored while the embedder was unavailable.
266
+
267
+ Resumable and idempotent: re-running after a partial run only processes
268
+ the remaining NULLs. Fail-open per-fact: a single bad fact logs a warning
269
+ and is skipped; the batch continues.
270
+
271
+ Writes mirror :func:`run_embedding_migration` exactly:
272
+ * ``atomic_facts.embedding`` ← ``json.dumps(vector)``
273
+ * ``embedding_metadata`` ← upserted row with current model name + dimension
274
+
275
+ Args:
276
+ config: Active SLMConfig (provides profile_id, model name, dimension).
277
+ db: DatabaseManager (or duck-compatible object with ``.execute()``).
278
+ embedder: Object implementing ``embed_batch(texts) -> list[vec|None]``
279
+ and (optionally) ``embed(text) -> vec|None``. Pass ``None`` to
280
+ make this a no-op (returns zero counts).
281
+ batch_size: Facts per embed_batch() call. Defaults to 50.
282
+ limit: Maximum facts to embed in this call. ``None`` means no cap —
283
+ all NULL-embedding facts are processed. Use a bounded limit for
284
+ the maintenance self-healing path so each pass is quick.
285
+ all_profiles: When ``True``, processes facts from every profile in the
286
+ database. When ``False`` (default), scopes to
287
+ ``config.active_profile``.
288
+
289
+ Returns:
290
+ ``{"scanned": int, "embedded": int, "remaining_null": int}``
291
+
292
+ *scanned*: total NULL-embedding facts found before applying *limit*.
293
+ *embedded*: facts successfully written in this call.
294
+ *remaining_null*: NULL count after the call (includes facts not yet
295
+ reached because of *limit*).
296
+ """
297
+ profile_id = config.active_profile
298
+
299
+ if embedder is None:
300
+ logger.warning(
301
+ "backfill_missing_embeddings: no embedder available — skipping."
302
+ )
303
+ return {"scanned": 0, "embedded": 0, "remaining_null": 0}
304
+
305
+ # ------------------------------------------------------------------
306
+ # 1. Fetch all NULL-embedding facts (cheap query; only reads IDs + content)
307
+ # ------------------------------------------------------------------
308
+ if all_profiles:
309
+ rows = db.execute(
310
+ "SELECT fact_id, content, profile_id FROM atomic_facts "
311
+ "WHERE embedding IS NULL ORDER BY created_at",
312
+ )
313
+ else:
314
+ rows = db.execute(
315
+ "SELECT fact_id, content, profile_id FROM atomic_facts "
316
+ "WHERE embedding IS NULL AND profile_id = ? ORDER BY created_at",
317
+ (profile_id,),
318
+ )
319
+
320
+ facts: list[tuple[str, str, str]] = [
321
+ (dict(r)["fact_id"], dict(r)["content"], dict(r)["profile_id"])
322
+ for r in rows
323
+ ]
324
+ scanned = len(facts)
325
+
326
+ if scanned == 0:
327
+ return {"scanned": 0, "embedded": 0, "remaining_null": 0}
328
+
329
+ # Apply call-level limit (resumability: next call picks up where this left off)
330
+ if limit is not None:
331
+ facts = facts[:limit]
332
+
333
+ current_model = config.embedding.model_name
334
+ current_dim = config.embedding.dimension
335
+ embedded = 0
336
+
337
+ # ------------------------------------------------------------------
338
+ # 2. Batch embed and write back
339
+ # ------------------------------------------------------------------
340
+ for batch_start in range(0, len(facts), batch_size):
341
+ batch = facts[batch_start : batch_start + batch_size]
342
+ # Bound per-fact input so an oversized document doesn't monopolize the
343
+ # shared embedding worker (starving foreground recall during self-heal).
344
+ texts = [(content or "")[:_MAX_EMBED_CHARS] for _, content, _ in batch]
345
+ fact_ids = [fid for fid, _, _ in batch]
346
+ prof_ids = [pid for _, _, pid in batch]
347
+
348
+ # Attempt batch embed; fall back to per-fact on batch failure.
349
+ try:
350
+ vectors: list[Any] = embedder.embed_batch(texts)
351
+ except Exception as exc:
352
+ logger.warning(
353
+ "backfill: batch embed failed for facts %d-%d: %s — "
354
+ "retrying per-fact.",
355
+ batch_start,
356
+ batch_start + len(batch),
357
+ exc,
358
+ )
359
+ vectors = []
360
+ for text in texts:
361
+ try:
362
+ vec = embedder.embed(text)
363
+ vectors.append(vec)
364
+ except Exception as per_fact_exc:
365
+ logger.warning(
366
+ "backfill: per-fact embed failed for '%s...': %s",
367
+ text[:40],
368
+ per_fact_exc,
369
+ )
370
+ vectors.append(None)
371
+
372
+ # Write each successfully-embedded fact back to the DB.
373
+ for fid, vec, pid in zip(fact_ids, vectors, prof_ids):
374
+ if vec is None:
375
+ logger.warning(
376
+ "backfill: null vector for fact %s — skipping.", fid[:16]
377
+ )
378
+ continue
379
+ try:
380
+ embedding_json = json.dumps(vec)
381
+ # Mirror run_embedding_migration's write path exactly.
382
+ db.execute(
383
+ "UPDATE atomic_facts SET embedding = ? WHERE fact_id = ?",
384
+ (embedding_json, fid),
385
+ )
386
+ # Upsert embedding_metadata. NULL-embedding facts have no row
387
+ # here yet, so we INSERT; if a row somehow exists, update it.
388
+ db.execute(
389
+ "INSERT INTO embedding_metadata"
390
+ " (fact_id, profile_id, model_name, dimension)"
391
+ " VALUES (?, ?, ?, ?)"
392
+ " ON CONFLICT(fact_id) DO UPDATE SET"
393
+ " model_name = excluded.model_name",
394
+ (fid, pid, current_model, current_dim),
395
+ )
396
+ embedded += 1
397
+ except Exception as exc:
398
+ logger.warning(
399
+ "backfill: failed to write fact %s: %s", fid[:16], exc
400
+ )
401
+
402
+ # ------------------------------------------------------------------
403
+ # 3. Count remaining NULLs (accounts for the limit; tells caller how
404
+ # many passes remain before full convergence).
405
+ # ------------------------------------------------------------------
406
+ remaining = _count_null_embeddings(db, profile_id, all_profiles)
407
+
408
+ logger.info(
409
+ "Embedding backfill: %d/%d facts embedded, %d remaining NULL.",
410
+ embedded,
411
+ scanned,
412
+ remaining,
413
+ )
414
+ return {"scanned": scanned, "embedded": embedded, "remaining_null": remaining}