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.
- package/CHANGELOG.md +67 -0
- package/README.md +2 -2
- package/package.json +1 -1
- package/plugin/.claude-plugin/plugin.json +1 -1
- package/plugin/CLAUDE.md +3 -3
- package/plugin/agents/slm-governance-advisor.md +1 -1
- package/plugin/agents/slm-loop-runner.md +1 -1
- package/plugin/agents/slm-memory-advisor.md +2 -2
- package/plugin/agents/slm-optimize-advisor.md +1 -1
- package/plugin/requirements.txt +1 -1
- package/plugin/skills/slm-cache/SKILL.md +1 -1
- package/plugin/skills/slm-compress/SKILL.md +1 -1
- package/plugin/skills/slm-governance/SKILL.md +1 -1
- package/plugin/skills/slm-graph/SKILL.md +1 -1
- package/plugin/skills/slm-loop/SKILL.md +1 -1
- package/plugin/skills/slm-mesh/SKILL.md +1 -1
- package/plugin/skills/slm-profile/SKILL.md +1 -1
- package/plugin/skills/slm-recall/SKILL.md +3 -5
- package/plugin/skills/slm-remember/SKILL.md +1 -1
- package/plugin/skills/slm-scope/SKILL.md +1 -1
- package/plugin/skills/slm-session/SKILL.md +1 -1
- package/plugin/skills/slm-status/SKILL.md +1 -1
- package/plugin-src/rules/AGENTS.md +2 -1
- package/plugin-src/skills/slm-cache/SKILL.md +1 -1
- package/plugin-src/skills/slm-compress/SKILL.md +1 -1
- package/plugin-src/skills/slm-graph/SKILL.md +1 -1
- package/plugin-src/skills/slm-recall/SKILL.md +3 -5
- package/plugin-src/skills/slm-remember/SKILL.md +1 -1
- package/plugin-src/skills/slm-session/SKILL.md +1 -1
- package/plugin-src/skills/slm-status/SKILL.md +1 -1
- package/pyproject.toml +1 -1
- package/scripts/postinstall.js +7 -1
- package/src/superlocalmemory/__init__.py +1 -1
- package/src/superlocalmemory/cli/commands.py +360 -2
- package/src/superlocalmemory/cli/main.py +62 -3
- package/src/superlocalmemory/cli/setup_wizard.py +142 -16
- package/src/superlocalmemory/core/component_healer.py +144 -0
- package/src/superlocalmemory/core/component_registry.py +487 -0
- package/src/superlocalmemory/core/config.py +21 -0
- package/src/superlocalmemory/core/embeddings.py +14 -1
- package/src/superlocalmemory/core/engine.py +9 -5
- package/src/superlocalmemory/core/ingestion_command.py +36 -16
- package/src/superlocalmemory/core/maintenance.py +43 -0
- package/src/superlocalmemory/core/maintenance_scheduler.py +28 -0
- package/src/superlocalmemory/core/recall_pipeline.py +39 -3
- package/src/superlocalmemory/core/store_pipeline.py +42 -0
- package/src/superlocalmemory/mcp/_daemon_proxy.py +6 -2
- package/src/superlocalmemory/mcp/_pool_adapter.py +4 -1
- package/src/superlocalmemory/mcp/tools_active.py +1 -1
- package/src/superlocalmemory/mcp/tools_core.py +17 -2
- package/src/superlocalmemory/retrieval/bridge_discovery.py +14 -0
- package/src/superlocalmemory/retrieval/spreading_activation.py +68 -38
- package/src/superlocalmemory/server/routes/behavioral.py +6 -2
- package/src/superlocalmemory/server/routes/learning.py +13 -3
- package/src/superlocalmemory/server/routes/memories.py +80 -26
- package/src/superlocalmemory/server/routes/v3_api.py +120 -0
- package/src/superlocalmemory/server/unified_daemon.py +349 -10
- package/src/superlocalmemory/storage/embedding_migrator.py +235 -0
- package/src/superlocalmemory/ui/index.html +3 -2
- package/src/superlocalmemory/ui/js/core.js +6 -1
- package/src/superlocalmemory/ui/js/od-components.js +147 -0
- package/src/superlocalmemory/ui/js/od-entities.js +43 -0
- package/src/superlocalmemory/ui/js/od-graph.js +35 -0
- package/src/superlocalmemory/ui/js/od-health.js +18 -0
- package/src/superlocalmemory/ui/js/od-memories.js +37 -0
- package/src/superlocalmemory/ui/js/od-operations.js +36 -0
- 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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
2813
|
-
|
|
2814
|
-
|
|
2815
|
-
|
|
2816
|
-
|
|
2817
|
-
|
|
2818
|
-
|
|
2819
|
-
|
|
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}
|