superlocalmemory 3.8.0 → 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 (134) hide show
  1. package/CHANGELOG.md +112 -0
  2. package/README.md +32 -120
  3. package/package.json +9 -2
  4. package/plugin/.claude-plugin/plugin.json +1 -2
  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 +2 -1
  32. package/scripts/postinstall.js +7 -1
  33. package/src/superlocalmemory/__init__.py +1 -1
  34. package/src/superlocalmemory/cli/commands.py +494 -9
  35. package/src/superlocalmemory/cli/daemon.py +7 -0
  36. package/src/superlocalmemory/cli/loop_cmd.py +2 -7
  37. package/src/superlocalmemory/cli/main.py +72 -7
  38. package/src/superlocalmemory/cli/setup_wizard.py +142 -16
  39. package/src/superlocalmemory/cli/version_banner.py +17 -3
  40. package/src/superlocalmemory/core/backend_orchestrator.py +18 -16
  41. package/src/superlocalmemory/core/component_healer.py +144 -0
  42. package/src/superlocalmemory/core/component_registry.py +487 -0
  43. package/src/superlocalmemory/core/config.py +21 -0
  44. package/src/superlocalmemory/core/embedding_worker.py +4 -5
  45. package/src/superlocalmemory/core/embeddings.py +132 -45
  46. package/src/superlocalmemory/core/engine.py +29 -22
  47. package/src/superlocalmemory/core/engine_ingestion.py +332 -45
  48. package/src/superlocalmemory/core/ingestion_command.py +154 -25
  49. package/src/superlocalmemory/core/injection.py +12 -7
  50. package/src/superlocalmemory/core/maintenance.py +43 -0
  51. package/src/superlocalmemory/core/maintenance_scheduler.py +44 -6
  52. package/src/superlocalmemory/core/recall_pipeline.py +42 -4
  53. package/src/superlocalmemory/core/store_pipeline.py +195 -20
  54. package/src/superlocalmemory/hooks/hook_handlers.py +6 -1
  55. package/src/superlocalmemory/hooks/portable_kit.py +34 -2
  56. package/src/superlocalmemory/learning/model_rollback.py +3 -0
  57. package/src/superlocalmemory/learning/ranker_retrain_online.py +2 -0
  58. package/src/superlocalmemory/learning/reward.py +50 -0
  59. package/src/superlocalmemory/learning/source_quality.py +523 -1
  60. package/src/superlocalmemory/loops/ledger.py +25 -5
  61. package/src/superlocalmemory/mcp/_daemon_proxy.py +6 -2
  62. package/src/superlocalmemory/mcp/_pool_adapter.py +4 -1
  63. package/src/superlocalmemory/mcp/server.py +11 -30
  64. package/src/superlocalmemory/mcp/tools_active.py +1 -1
  65. package/src/superlocalmemory/mcp/tools_core.py +21 -5
  66. package/src/superlocalmemory/mcp/tools_learning.py +2 -2
  67. package/src/superlocalmemory/retrieval/bridge_discovery.py +14 -0
  68. package/src/superlocalmemory/retrieval/engine.py +53 -21
  69. package/src/superlocalmemory/retrieval/reranker.py +3 -4
  70. package/src/superlocalmemory/retrieval/spreading_activation.py +68 -38
  71. package/src/superlocalmemory/server/config_file.py +90 -0
  72. package/src/superlocalmemory/server/origin.py +50 -0
  73. package/src/superlocalmemory/server/routes/backup.py +293 -70
  74. package/src/superlocalmemory/server/routes/behavioral.py +342 -61
  75. package/src/superlocalmemory/server/routes/brain.py +57 -16
  76. package/src/superlocalmemory/server/routes/config_api.py +84 -82
  77. package/src/superlocalmemory/server/routes/entity.py +100 -23
  78. package/src/superlocalmemory/server/routes/evolution.py +103 -100
  79. package/src/superlocalmemory/server/routes/learning.py +286 -105
  80. package/src/superlocalmemory/server/routes/learning_telemetry.py +153 -0
  81. package/src/superlocalmemory/server/routes/memories.py +8 -3
  82. package/src/superlocalmemory/server/routes/mesh.py +121 -32
  83. package/src/superlocalmemory/server/routes/ratelimit.py +33 -25
  84. package/src/superlocalmemory/server/routes/stats.py +93 -155
  85. package/src/superlocalmemory/server/routes/token.py +3 -13
  86. package/src/superlocalmemory/server/routes/v3_api.py +184 -20
  87. package/src/superlocalmemory/server/unified_daemon.py +732 -41
  88. package/src/superlocalmemory/storage/embedding_migrator.py +235 -0
  89. package/src/superlocalmemory/storage/migration_runner.py +79 -1
  90. package/src/superlocalmemory/storage/migrations/M010_evolution_config.py +5 -0
  91. package/src/superlocalmemory/storage/migrations/M028_fact_entity_associations.py +270 -0
  92. package/src/superlocalmemory/storage/migrations/M029_behavioral_history_indexes.py +137 -0
  93. package/src/superlocalmemory/storage/migrations/M030_entity_explorer_indexes.py +93 -0
  94. package/src/superlocalmemory/storage/migrations/__init__.py +4 -0
  95. package/src/superlocalmemory/storage/schema.py +49 -1
  96. package/src/superlocalmemory/storage/schema_v32.py +2 -0
  97. package/src/superlocalmemory/storage/schema_v347.py +4 -0
  98. package/src/superlocalmemory/ui/index.html +6 -8
  99. package/src/superlocalmemory/ui/js/core.js +52 -9
  100. package/src/superlocalmemory/ui/js/dashboard.js +169 -82
  101. package/src/superlocalmemory/ui/js/od-backup.js +156 -65
  102. package/src/superlocalmemory/ui/js/od-brain.js +88 -51
  103. package/src/superlocalmemory/ui/js/od-components.js +147 -0
  104. package/src/superlocalmemory/ui/js/od-entities.js +65 -22
  105. package/src/superlocalmemory/ui/js/od-graph.js +46 -4
  106. package/src/superlocalmemory/ui/js/od-health.js +18 -0
  107. package/src/superlocalmemory/ui/js/od-memories.js +84 -5
  108. package/src/superlocalmemory/ui/js/od-mesh.js +23 -9
  109. package/src/superlocalmemory/ui/js/od-operations.js +36 -0
  110. package/src/superlocalmemory/ui/js/od-settings.js +186 -63
  111. package/src/superlocalmemory/ui/js/od-shell.js +249 -33
  112. package/src/superlocalmemory/ui/js/od-skills.js +44 -17
  113. package/src/superlocalmemory/ui/js/settings.js +15 -1
  114. package/plugin-src/.mcp.json +0 -12
  115. package/plugin-src/agents/slm-governance-advisor.md +0 -80
  116. package/plugin-src/agents/slm-loop-runner.md +0 -71
  117. package/plugin-src/agents/slm-memory-advisor.md +0 -49
  118. package/plugin-src/agents/slm-optimize-advisor.md +0 -44
  119. package/plugin-src/commands/slm-loop.md +0 -31
  120. package/plugin-src/hooks/.gitkeep +0 -0
  121. package/plugin-src/hooks/hooks.json +0 -102
  122. package/plugin-src/manifest.json +0 -30
  123. package/plugin-src/requirements.txt +0 -1
  124. package/plugin-src/rules/CLAUDE.md.fragment +0 -44
  125. package/plugin-src/scripts/ensure-venv.bat +0 -122
  126. package/plugin-src/scripts/ensure-venv.sh +0 -105
  127. package/plugin-src/scripts/slm-launch +0 -62
  128. package/plugin-src/scripts/slm-launch.bat +0 -23
  129. package/plugin-src/settings.json +0 -25
  130. package/plugin-src/skills/slm-governance/SKILL.md +0 -248
  131. package/plugin-src/skills/slm-loop/SKILL.md +0 -99
  132. package/plugin-src/skills/slm-mesh/SKILL.md +0 -282
  133. package/plugin-src/skills/slm-profile/SKILL.md +0 -148
  134. package/plugin-src/skills/slm-scope/SKILL.md +0 -176
@@ -31,6 +31,7 @@ import json
31
31
  import logging
32
32
  import os
33
33
  import signal
34
+ import sqlite3
34
35
  import sys
35
36
  import threading
36
37
  import time
@@ -65,12 +66,51 @@ from superlocalmemory.infra.data_root import (
65
66
  canonical_data_root,
66
67
  state_path,
67
68
  )
69
+ from superlocalmemory.learning.source_quality import (
70
+ SourceQualityRepairUnavailable,
71
+ enumerate_source_quality_repair_profiles,
72
+ repair_historical_source_quality,
73
+ )
68
74
 
69
75
  logger = logging.getLogger("superlocalmemory.unified_daemon")
70
76
 
71
77
  _DEFAULT_PORT = 8765
72
78
  _LEGACY_PORT = 8767
73
79
  _ACTIVE_DAEMON_DESCRIPTOR: DaemonDescriptor | None = None
80
+ _SOURCE_QUALITY_MAX_BATCH_SIZE = 250
81
+ _SOURCE_QUALITY_PROFILE_REFRESH_SECONDS = 60.0
82
+ _FACT_ENTITY_REPAIR_MIN_RETRY_SECONDS = 0.05
83
+ _FACT_ENTITY_REPAIR_MAX_RETRY_SECONDS = 30.0
84
+ # ``wait=true`` is a compatibility affordance, never permission to hold the
85
+ # ASGI event loop hostage to a local LLM. Normal clients omit it and receive
86
+ # an immediate durable/queryable receipt; explicit waiters get this small
87
+ # completion window, then the M018 materializer continues in the background.
88
+ _REMEMBER_ENRICHMENT_WAIT_SECONDS = 0.75
89
+ _SENSITIVE_READ_PREFIXES = (
90
+ "/api/memories", "/api/facts", "/api/clusters", "/api/graph",
91
+ "/api/v3/associations", "/api/v3/core-memory",
92
+ "/api/v3/soft-prompts", "/api/v3/dashboard", "/api/v3/mode",
93
+ "/api/v3/embedding/config", "/api/v3/scope/config",
94
+ "/api/v3/storage/config", "/api/v3/daemon/config",
95
+ "/api/v3/mesh/config", "/api/v3/trust/config",
96
+ "/api/v3/forgetting/config", "/api/v3/mcp/profiles",
97
+ "/api/learning", "/api/behavioral",
98
+ )
99
+ _SENSITIVE_READ_EXACT_PATHS = (
100
+ "/api/search", "/api/v3/recall/trace", "/api/patterns",
101
+ "/api/feedback/stats", "/api/stats", "/api/timeline",
102
+ )
103
+
104
+
105
+ def _is_sensitive_dashboard_read(method: str, path: str) -> bool:
106
+ return (
107
+ method == "GET"
108
+ and (
109
+ path.startswith(_SENSITIVE_READ_PREFIXES)
110
+ or path in _SENSITIVE_READ_EXACT_PATHS
111
+ or path.startswith("/api/v3/recall")
112
+ )
113
+ )
74
114
 
75
115
 
76
116
  def _rbac_read_gate(request, app_state):
@@ -715,6 +755,362 @@ def _warm_spreading_activation(engine, runtime) -> bool:
715
755
  return False
716
756
 
717
757
 
758
+ def _set_source_quality_repair_status(application, **updates) -> dict:
759
+ current = getattr(
760
+ application.state, "source_quality_repair_status", {},
761
+ )
762
+ status = {**current, **updates}
763
+ application.state.source_quality_repair_status = status
764
+ return status
765
+
766
+
767
+ def _schedule_fact_entity_association_repair(
768
+ application,
769
+ memory_db_path: Path,
770
+ *,
771
+ batch_size: int = 250,
772
+ tick_seconds: float = 1.0,
773
+ ) -> asyncio.Task:
774
+ """Schedule bounded M028 backfill only after readiness is published."""
775
+ from superlocalmemory.storage.migrations.M028_fact_entity_associations import (
776
+ get_repair_status,
777
+ )
778
+
779
+ durable = get_repair_status(Path(memory_db_path))
780
+ application.state.fact_entity_association_repair_status = {
781
+ **durable,
782
+ "source": "startup_background_repair",
783
+ "batch_size": batch_size,
784
+ }
785
+ task = asyncio.create_task(
786
+ _fact_entity_association_repair_loop(
787
+ application,
788
+ Path(memory_db_path),
789
+ batch_size=batch_size,
790
+ tick_seconds=tick_seconds,
791
+ ),
792
+ name="fact-entity-association-upgrade-repair",
793
+ )
794
+ application.state.fact_entity_association_repair_task = task
795
+ return task
796
+
797
+
798
+ async def _fact_entity_association_repair_loop(
799
+ application,
800
+ memory_db_path: Path,
801
+ *,
802
+ batch_size: int,
803
+ tick_seconds: float,
804
+ ) -> None:
805
+ from superlocalmemory.storage.migrations.M028_fact_entity_associations import (
806
+ get_repair_status,
807
+ repair_fact_entity_associations,
808
+ )
809
+
810
+ consecutive_failures = 0
811
+ try:
812
+ while True:
813
+ try:
814
+ await asyncio.to_thread(
815
+ repair_fact_entity_associations,
816
+ memory_db_path,
817
+ batch_size=batch_size,
818
+ max_batches=1,
819
+ )
820
+ durable = await asyncio.to_thread(
821
+ get_repair_status, memory_db_path,
822
+ )
823
+ except sqlite3.Error as exc:
824
+ consecutive_failures += 1
825
+ retry_delay = min(
826
+ _FACT_ENTITY_REPAIR_MAX_RETRY_SECONDS,
827
+ max(
828
+ _FACT_ENTITY_REPAIR_MIN_RETRY_SECONDS,
829
+ float(tick_seconds),
830
+ ) * (2 ** min(consecutive_failures - 1, 10)),
831
+ )
832
+ try:
833
+ durable = await asyncio.to_thread(
834
+ get_repair_status, memory_db_path,
835
+ )
836
+ except sqlite3.Error:
837
+ durable = getattr(
838
+ application.state,
839
+ "fact_entity_association_repair_status",
840
+ {},
841
+ )
842
+ application.state.fact_entity_association_repair_status = {
843
+ **durable,
844
+ "state": "retrying",
845
+ "source": "startup_background_repair",
846
+ "batch_size": batch_size,
847
+ "last_error": (
848
+ durable.get("last_error") or type(exc).__name__
849
+ ),
850
+ "retry_attempt": consecutive_failures,
851
+ "retry_delay_seconds": retry_delay,
852
+ }
853
+ await asyncio.sleep(retry_delay)
854
+ continue
855
+
856
+ consecutive_failures = 0
857
+ application.state.fact_entity_association_repair_status = {
858
+ **durable,
859
+ "source": "startup_background_repair",
860
+ "batch_size": batch_size,
861
+ "retry_attempt": 0,
862
+ "retry_delay_seconds": 0.0,
863
+ }
864
+ if durable["state"] == "complete":
865
+ return
866
+ await asyncio.sleep(max(0.0, float(tick_seconds)))
867
+ except asyncio.CancelledError:
868
+ raise
869
+ except Exception as exc:
870
+ durable = await asyncio.to_thread(get_repair_status, memory_db_path)
871
+ application.state.fact_entity_association_repair_status = {
872
+ **durable,
873
+ "source": "startup_background_repair",
874
+ "batch_size": batch_size,
875
+ "last_error": durable.get("last_error") or type(exc).__name__,
876
+ }
877
+
878
+
879
+ async def _cancel_fact_entity_association_repair(application) -> None:
880
+ task = getattr(
881
+ application.state, "fact_entity_association_repair_task", None,
882
+ )
883
+ if task is None:
884
+ return
885
+ if not task.done():
886
+ task.cancel()
887
+ try:
888
+ await task
889
+ except asyncio.CancelledError:
890
+ pass
891
+
892
+
893
+ def _schedule_source_quality_repair(
894
+ application,
895
+ memory_db_path: Path,
896
+ learning_db_path: Path,
897
+ *,
898
+ batch_size: int = 25,
899
+ tick_seconds: float = 1.0,
900
+ ) -> asyncio.Task:
901
+ """Schedule post-readiness repair without awaiting historical DB work."""
902
+ _set_source_quality_repair_status(
903
+ application,
904
+ state="scheduled",
905
+ source="startup_background_repair",
906
+ batch_size=batch_size,
907
+ profiles=[],
908
+ completed_profiles=[],
909
+ profile_results={},
910
+ batches_completed=0,
911
+ scanned=0,
912
+ observations=0,
913
+ last_error=None,
914
+ )
915
+ task = asyncio.create_task(
916
+ _source_quality_repair_loop(
917
+ application,
918
+ Path(memory_db_path),
919
+ Path(learning_db_path),
920
+ batch_size=batch_size,
921
+ tick_seconds=tick_seconds,
922
+ ),
923
+ name="source-quality-upgrade-repair",
924
+ )
925
+ application.state.source_quality_repair_task = task
926
+ return task
927
+
928
+
929
+ async def _repair_one_source_quality_profile(
930
+ memory_db_path: Path,
931
+ learning_db_path: Path,
932
+ profile_id: str,
933
+ batch_size: int,
934
+ ) -> dict[str, int | bool]:
935
+ worker = asyncio.create_task(
936
+ asyncio.to_thread(
937
+ repair_historical_source_quality,
938
+ memory_db_path,
939
+ learning_db_path,
940
+ profile_id,
941
+ batch_size=batch_size,
942
+ max_batches=1,
943
+ ),
944
+ name=f"source-quality-repair-batch-{profile_id}",
945
+ )
946
+ try:
947
+ return await asyncio.shield(worker)
948
+ except asyncio.CancelledError:
949
+ # Cancellation cannot stop a running worker thread. Await the bounded
950
+ # batch so SQLite writes finish before daemon teardown closes storage.
951
+ await worker
952
+ raise
953
+
954
+
955
+ def _record_source_quality_repair_result(
956
+ application,
957
+ profile_id: str,
958
+ result: dict[str, int | bool],
959
+ ) -> bool:
960
+ current = getattr(
961
+ application.state, "source_quality_repair_status", {},
962
+ )
963
+ completed = set(current.get("completed_profiles", []))
964
+ if result["complete"]:
965
+ completed.add(profile_id)
966
+ results = {
967
+ **current.get("profile_results", {}),
968
+ profile_id: result,
969
+ }
970
+ _set_source_quality_repair_status(
971
+ application,
972
+ profile_results=results,
973
+ completed_profiles=sorted(completed),
974
+ batches_completed=int(current.get("batches_completed", 0)) + 1,
975
+ scanned=int(current.get("scanned", 0)) + int(result["scanned"]),
976
+ observations=int(current.get("observations", 0))
977
+ + int(result["observations"]),
978
+ )
979
+ return not bool(result["complete"])
980
+
981
+
982
+ async def _source_quality_repair_tick(
983
+ application,
984
+ memory_db_path: Path,
985
+ learning_db_path: Path,
986
+ profiles: list[str],
987
+ *,
988
+ batch_size: int,
989
+ ) -> list[str]:
990
+ _set_source_quality_repair_status(
991
+ application,
992
+ state="running",
993
+ profiles=profiles,
994
+ current_batch_size=batch_size,
995
+ last_error=None,
996
+ )
997
+ pending = []
998
+ for profile_id in profiles:
999
+ result = await _repair_one_source_quality_profile(
1000
+ memory_db_path, learning_db_path, profile_id, batch_size,
1001
+ )
1002
+ if _record_source_quality_repair_result(
1003
+ application, profile_id, result,
1004
+ ):
1005
+ pending.append(profile_id)
1006
+ await asyncio.sleep(0)
1007
+ return pending
1008
+
1009
+
1010
+ async def _discover_source_quality_profiles(
1011
+ memory_db_path: Path,
1012
+ pending: list[str] | None,
1013
+ completed: list[str],
1014
+ ) -> list[str]:
1015
+ discovered = await asyncio.to_thread(
1016
+ enumerate_source_quality_repair_profiles,
1017
+ memory_db_path,
1018
+ )
1019
+ return sorted(
1020
+ (set(pending or []) | set(discovered)) - set(completed),
1021
+ )
1022
+
1023
+
1024
+ async def _source_quality_repair_loop(
1025
+ application,
1026
+ memory_db_path: Path,
1027
+ learning_db_path: Path,
1028
+ *,
1029
+ batch_size: int,
1030
+ tick_seconds: float,
1031
+ ) -> None:
1032
+ """Run one resumable repair batch per discovered profile and tick."""
1033
+ pending: list[str] | None = None
1034
+ next_refresh = 0.0
1035
+ successful_ticks = 0
1036
+ try:
1037
+ while True:
1038
+ try:
1039
+ now = time.monotonic()
1040
+ if pending is None or now >= next_refresh:
1041
+ status = getattr(
1042
+ application.state,
1043
+ "source_quality_repair_status",
1044
+ {},
1045
+ )
1046
+ pending = await _discover_source_quality_profiles(
1047
+ memory_db_path,
1048
+ pending,
1049
+ status.get("completed_profiles", []),
1050
+ )
1051
+ next_refresh = (
1052
+ now + _SOURCE_QUALITY_PROFILE_REFRESH_SECONDS
1053
+ )
1054
+ adaptive_batch = min(
1055
+ _SOURCE_QUALITY_MAX_BATCH_SIZE,
1056
+ batch_size * (2 ** min(successful_ticks, 4)),
1057
+ )
1058
+ pending = await _source_quality_repair_tick(
1059
+ application,
1060
+ memory_db_path,
1061
+ learning_db_path,
1062
+ pending,
1063
+ batch_size=adaptive_batch,
1064
+ )
1065
+ except (SourceQualityRepairUnavailable, sqlite3.Error):
1066
+ _set_source_quality_repair_status(
1067
+ application,
1068
+ state="retrying",
1069
+ last_error="storage_temporarily_unavailable",
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
+ )
1086
+ else:
1087
+ successful_ticks += 1
1088
+ if pending == []:
1089
+ _set_source_quality_repair_status(application, state="complete")
1090
+ return
1091
+ await asyncio.sleep(max(0.0, float(tick_seconds)))
1092
+ except asyncio.CancelledError:
1093
+ _set_source_quality_repair_status(application, state="cancelled")
1094
+ raise
1095
+ except Exception as exc:
1096
+ logger.warning("source-quality startup repair failed: %s", exc)
1097
+ _set_source_quality_repair_status(
1098
+ application, state="failed", last_error=type(exc).__name__,
1099
+ )
1100
+
1101
+
1102
+ async def _cancel_source_quality_repair(application) -> None:
1103
+ task = getattr(application.state, "source_quality_repair_task", None)
1104
+ if task is None:
1105
+ return
1106
+ if not task.done():
1107
+ task.cancel()
1108
+ try:
1109
+ await task
1110
+ except asyncio.CancelledError:
1111
+ pass
1112
+
1113
+
718
1114
  @asynccontextmanager
719
1115
  async def lifespan(application: FastAPI):
720
1116
  """Initialize engine, workers, and optional services on startup."""
@@ -1067,9 +1463,186 @@ async def lifespan(application: FastAPI):
1067
1463
  except Exception as exc:
1068
1464
  logger.warning("Vector store backfill failed (non-fatal): %s", exc)
1069
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
+
1070
1638
  threading.Thread(target=_warmup_embedder, daemon=True, name="embed-warmup").start()
1071
1639
  threading.Thread(target=_warmup_recall, daemon=True, name="recall-warmup").start()
1072
- 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()
1073
1646
 
1074
1647
  # v3.6.8: Runtime recall-health monitor. The three warmups above run
1075
1648
  # ONCE at boot; on a long-running daemon the graph page cache gets
@@ -1350,7 +1923,20 @@ async def lifespan(application: FastAPI):
1350
1923
  application.state.daemon_descriptor = _publish_process_descriptor(
1351
1924
  _configured_daemon_port(), SLM_VERSION, "ready",
1352
1925
  )
1353
- yield
1926
+ _schedule_source_quality_repair(
1927
+ application,
1928
+ state_path("memory.db"),
1929
+ state_path("learning.db"),
1930
+ )
1931
+ _schedule_fact_entity_association_repair(
1932
+ application,
1933
+ state_path("memory.db"),
1934
+ )
1935
+ try:
1936
+ yield
1937
+ finally:
1938
+ await _cancel_fact_entity_association_repair(application)
1939
+ await _cancel_source_quality_repair(application)
1354
1940
 
1355
1941
  # Cancel the cross-platform sync loop (H-CONC-2) so adapter file I/O does
1356
1942
  # not outlive the daemon.
@@ -1945,19 +2531,35 @@ def _register_dashboard_routes(application: FastAPI) -> None:
1945
2531
  "error": "Remote HTTP MCP requires a configured SLM API key."
1946
2532
  },
1947
2533
  )
1948
- # v3.6.12 (csrf-1): defense-in-depth CSRF/DNS-rebinding guard on
1949
- # state-changing requests. A cross-origin browser Origin is rejected;
1950
- # loopback origins (the local dashboard) always pass, and LAN origins
1951
- # pass only when explicitly allowlisted in SLM_REMOTE mode. Non-browser
1952
- # clients (CLI/MCP/curl) send no Origin and are unaffected.
2534
+ # Defense-in-depth CSRF/DNS-rebinding guard. A loopback hostname is
2535
+ # not, by itself, a trusted web origin: a different local process can
2536
+ # serve a page on another port. Credentialless browser writes must
2537
+ # therefore originate from this daemon's exact port. A local
2538
+ # integration on another port may still write when it presents a
2539
+ # valid credential; require_http_mutation_actor below validates it.
2540
+ # LAN origins remain opt-in through remote mode. Non-browser clients
2541
+ # (CLI/MCP/curl) send no Origin and are unaffected.
1953
2542
  if requires_mutation_actor:
1954
2543
  _origin = headers.get("origin", "") or headers.get("Origin", "")
1955
2544
  if _origin:
1956
- _ok_origin = any(_origin.startswith(p) for p in (
1957
- "http://127.0.0.1", "https://127.0.0.1",
1958
- "http://localhost", "https://localhost",
1959
- "http://[::1]", "https://[::1]",
1960
- ))
2545
+ from superlocalmemory.server.origin import (
2546
+ origin_is_daemon,
2547
+ origin_is_loopback,
2548
+ )
2549
+
2550
+ _daemon = getattr(application.state, "daemon_descriptor", None)
2551
+ _daemon_port = getattr(_daemon, "port", None) or _configured_daemon_port()
2552
+ _ok_origin = origin_is_daemon(_origin, port=int(_daemon_port))
2553
+ _has_browser_credential = any(
2554
+ headers.get(_header)
2555
+ for _header in (
2556
+ "x-slm-daemon-capability",
2557
+ "x-install-token",
2558
+ "x-slm-api-key",
2559
+ )
2560
+ )
2561
+ if not _ok_origin and origin_is_loopback(_origin) and _has_browser_credential:
2562
+ _ok_origin = True
1961
2563
  if not _ok_origin:
1962
2564
  from superlocalmemory.core.remote_mode import is_remote_origin_allowed
1963
2565
  _ok_origin = is_remote_origin_allowed(_origin)
@@ -2038,30 +2640,11 @@ def _register_dashboard_routes(application: FastAPI) -> None:
2038
2640
  # single-operator installs are unaffected. Owner (no session) reads
2039
2641
  # freely unless company mode (require_login) is on; a logged-in user
2040
2642
  # must hold READ on the active workspace.
2041
- _p = request.url.path
2042
- _is_sensitive_read = (
2043
- (request.method == "GET" and _p.startswith((
2044
- "/api/memories", "/api/facts", "/api/clusters", "/api/graph",
2045
- "/api/v3/associations", "/api/v3/core-memory",
2046
- "/api/v3/soft-prompts",
2047
- # Config metadata GETs expose the install path (base_dir) and
2048
- # LLM stack (provider/model/endpoint). Harmless to the loopback
2049
- # owner (personal mode: gate is a no-op), but in company mode an
2050
- # unauthenticated caller must not read them — same login gate as
2051
- # content reads.
2052
- "/api/v3/dashboard", "/api/v3/mode", "/api/v3/embedding/config",
2053
- # SEC-M-02: the remaining config GETs also expose base_dir,
2054
- # daemon port, and backend topology — gate them in company mode.
2055
- "/api/v3/scope/config", "/api/v3/storage/config",
2056
- "/api/v3/daemon/config", "/api/v3/mesh/config",
2057
- "/api/v3/trust/config", "/api/v3/forgetting/config",
2058
- # MCP profile metadata — reveals which tools agent clients
2059
- # can invoke; consistent with other config GETs.
2060
- "/api/v3/mcp/profiles")))
2061
- or _p in ("/api/search", "/api/v3/recall/trace")
2062
- or _p.startswith("/api/v3/recall")
2063
- )
2064
- if _is_sensitive_read:
2643
+ # Config, learning, and behavioral reads expose installation,
2644
+ # preference, workflow, source-reputation, or outcome data.
2645
+ if _is_sensitive_dashboard_read(
2646
+ request.method, request.url.path,
2647
+ ):
2065
2648
  _resp = _rbac_read_gate(request, application.state)
2066
2649
  if _resp is not None:
2067
2650
  return _resp
@@ -2370,7 +2953,15 @@ def _register_daemon_routes(application: FastAPI) -> None:
2370
2953
  request: Request,
2371
2954
  q: str = "", query: str = "", limit: int = CANONICAL_RECALL_LIMIT,
2372
2955
  session_id: str = "",
2373
- fast: bool = False,
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,
2374
2965
  full: bool = False,
2375
2966
  include_source: bool = False,
2376
2967
  include_global: bool | None = None,
@@ -2382,6 +2973,10 @@ def _register_daemon_routes(application: FastAPI) -> None:
2382
2973
  engine = _get_engine_or_503()
2383
2974
  if not search_query:
2384
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)
2385
2980
  # S9-DASH-02: session_id for the outcome-queue producer.
2386
2981
  # Priority: ?session_id= > X-SLM-Session-Id header > synthetic
2387
2982
  # "http:<ts>". Without a session_id the recall still works
@@ -2409,7 +3004,7 @@ def _register_daemon_routes(application: FastAPI) -> None:
2409
3004
  # recall (reranker timeout, cold embedder) blocks ALL endpoints.
2410
3005
  import asyncio
2411
3006
  _begin_recall()
2412
- # v3.4.53: Full (non-fast) recalls are gated by a semaphore to
3007
+ # v3.4.53: Opt-in deep recalls are gated by a semaphore to
2413
3008
  # prevent resource oversaturation. Ollama serialises concurrent
2414
3009
  # embedding calls and the reranker subprocess has a single lock —
2415
3010
  # queuing more than ~3 concurrent full recalls just adds latency.
@@ -2526,7 +3121,7 @@ def _register_daemon_routes(application: FastAPI) -> None:
2526
3121
  if isinstance(extra, dict):
2527
3122
  meta.update(extra)
2528
3123
  command = build_engine_ingestion_command(engine)
2529
- receipt = command.submit(IngestionRequest(
3124
+ ingestion_request = IngestionRequest(
2530
3125
  content=req.content,
2531
3126
  profile_id=engine._profile_id,
2532
3127
  source_type="http",
@@ -2536,9 +3131,42 @@ def _register_daemon_routes(application: FastAPI) -> None:
2536
3131
  shared_with=tuple(shared_with or ()),
2537
3132
  trusted_actor_id=trusted_actor_id,
2538
3133
  session_id=req.session_id,
2539
- ))
3134
+ )
3135
+ # SQLite admission is usually milliseconds, but it can wait on a
3136
+ # concurrent migration or writer. Keep that wait out of ASGI so
3137
+ # dashboard navigation and recall stay responsive.
3138
+ receipt = await asyncio.to_thread(command.submit, ingestion_request)
3139
+ result = receipt
3140
+ wait_budget_exhausted = False
3141
+ if wait:
3142
+ materialization_task = asyncio.create_task(
3143
+ asyncio.to_thread(command.materialize, receipt.operation_id)
3144
+ )
3145
+ try:
3146
+ result = await asyncio.wait_for(
3147
+ asyncio.shield(materialization_task),
3148
+ timeout=_REMEMBER_ENRICHMENT_WAIT_SECONDS,
3149
+ )
3150
+ except TimeoutError:
3151
+ # The task retains the M018 lease and continues outside
3152
+ # this request. Return the durable receipt honestly;
3153
+ # the normal materializer can also reclaim it after a
3154
+ # lease expiry if the request-owned worker dies.
3155
+ wait_budget_exhausted = True
3156
+
3157
+ def _log_background_materialization(task):
3158
+ try:
3159
+ task.result()
3160
+ except Exception as exc:
3161
+ logger.warning(
3162
+ "bounded remember enrichment failed for %s: %s",
3163
+ receipt.operation_id,
3164
+ exc,
3165
+ )
2540
3166
 
2541
- result = command.materialize(receipt.operation_id) if wait else receipt
3167
+ materialization_task.add_done_callback(
3168
+ _log_background_materialization
3169
+ )
2542
3170
  fact_ids = list(result.fact_ids)
2543
3171
  # The queryable write is a separate durable transaction. A cold
2544
3172
  # optional enrichment dependency (most often the local embedding
@@ -2581,10 +3209,13 @@ def _register_daemon_routes(application: FastAPI) -> None:
2581
3209
  "note": (
2582
3210
  "canonical ingestion complete"
2583
3211
  if completed
3212
+ else "queryable now; enrichment continues after the wait budget"
3213
+ if wait_budget_exhausted
2584
3214
  else "queryable now; canonical enrichment will retry"
2585
3215
  if enrichment_deferred
2586
3216
  else "queryable now; canonical enrichment pending"
2587
3217
  ),
3218
+ "wait_budget_exhausted": wait_budget_exhausted,
2588
3219
  }
2589
3220
  except Exception as exc:
2590
3221
  raise HTTPException(500, detail=str(exc))
@@ -2751,8 +3382,68 @@ def _register_daemon_routes(application: FastAPI) -> None:
2751
3382
  "legacy_port": _LEGACY_PORT,
2752
3383
  "profile": profile_snapshot.profile_id,
2753
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"}),
2754
3396
  }
2755
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
+
2756
3447
  @application.get("/list")
2757
3448
  async def list_facts(limit: int = 50):
2758
3449
  _update_activity()