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.
- package/CHANGELOG.md +112 -0
- package/README.md +32 -120
- package/package.json +9 -2
- package/plugin/.claude-plugin/plugin.json +1 -2
- 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 +2 -1
- package/scripts/postinstall.js +7 -1
- package/src/superlocalmemory/__init__.py +1 -1
- package/src/superlocalmemory/cli/commands.py +494 -9
- package/src/superlocalmemory/cli/daemon.py +7 -0
- package/src/superlocalmemory/cli/loop_cmd.py +2 -7
- package/src/superlocalmemory/cli/main.py +72 -7
- package/src/superlocalmemory/cli/setup_wizard.py +142 -16
- package/src/superlocalmemory/cli/version_banner.py +17 -3
- package/src/superlocalmemory/core/backend_orchestrator.py +18 -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/embedding_worker.py +4 -5
- package/src/superlocalmemory/core/embeddings.py +132 -45
- package/src/superlocalmemory/core/engine.py +29 -22
- package/src/superlocalmemory/core/engine_ingestion.py +332 -45
- package/src/superlocalmemory/core/ingestion_command.py +154 -25
- package/src/superlocalmemory/core/injection.py +12 -7
- package/src/superlocalmemory/core/maintenance.py +43 -0
- package/src/superlocalmemory/core/maintenance_scheduler.py +44 -6
- package/src/superlocalmemory/core/recall_pipeline.py +42 -4
- package/src/superlocalmemory/core/store_pipeline.py +195 -20
- package/src/superlocalmemory/hooks/hook_handlers.py +6 -1
- package/src/superlocalmemory/hooks/portable_kit.py +34 -2
- package/src/superlocalmemory/learning/model_rollback.py +3 -0
- package/src/superlocalmemory/learning/ranker_retrain_online.py +2 -0
- package/src/superlocalmemory/learning/reward.py +50 -0
- package/src/superlocalmemory/learning/source_quality.py +523 -1
- package/src/superlocalmemory/loops/ledger.py +25 -5
- package/src/superlocalmemory/mcp/_daemon_proxy.py +6 -2
- package/src/superlocalmemory/mcp/_pool_adapter.py +4 -1
- package/src/superlocalmemory/mcp/server.py +11 -30
- package/src/superlocalmemory/mcp/tools_active.py +1 -1
- package/src/superlocalmemory/mcp/tools_core.py +21 -5
- package/src/superlocalmemory/mcp/tools_learning.py +2 -2
- package/src/superlocalmemory/retrieval/bridge_discovery.py +14 -0
- package/src/superlocalmemory/retrieval/engine.py +53 -21
- package/src/superlocalmemory/retrieval/reranker.py +3 -4
- package/src/superlocalmemory/retrieval/spreading_activation.py +68 -38
- package/src/superlocalmemory/server/config_file.py +90 -0
- package/src/superlocalmemory/server/origin.py +50 -0
- package/src/superlocalmemory/server/routes/backup.py +293 -70
- package/src/superlocalmemory/server/routes/behavioral.py +342 -61
- package/src/superlocalmemory/server/routes/brain.py +57 -16
- package/src/superlocalmemory/server/routes/config_api.py +84 -82
- package/src/superlocalmemory/server/routes/entity.py +100 -23
- package/src/superlocalmemory/server/routes/evolution.py +103 -100
- package/src/superlocalmemory/server/routes/learning.py +286 -105
- package/src/superlocalmemory/server/routes/learning_telemetry.py +153 -0
- package/src/superlocalmemory/server/routes/memories.py +8 -3
- package/src/superlocalmemory/server/routes/mesh.py +121 -32
- package/src/superlocalmemory/server/routes/ratelimit.py +33 -25
- package/src/superlocalmemory/server/routes/stats.py +93 -155
- package/src/superlocalmemory/server/routes/token.py +3 -13
- package/src/superlocalmemory/server/routes/v3_api.py +184 -20
- package/src/superlocalmemory/server/unified_daemon.py +732 -41
- package/src/superlocalmemory/storage/embedding_migrator.py +235 -0
- package/src/superlocalmemory/storage/migration_runner.py +79 -1
- package/src/superlocalmemory/storage/migrations/M010_evolution_config.py +5 -0
- package/src/superlocalmemory/storage/migrations/M028_fact_entity_associations.py +270 -0
- package/src/superlocalmemory/storage/migrations/M029_behavioral_history_indexes.py +137 -0
- package/src/superlocalmemory/storage/migrations/M030_entity_explorer_indexes.py +93 -0
- package/src/superlocalmemory/storage/migrations/__init__.py +4 -0
- package/src/superlocalmemory/storage/schema.py +49 -1
- package/src/superlocalmemory/storage/schema_v32.py +2 -0
- package/src/superlocalmemory/storage/schema_v347.py +4 -0
- package/src/superlocalmemory/ui/index.html +6 -8
- package/src/superlocalmemory/ui/js/core.js +52 -9
- package/src/superlocalmemory/ui/js/dashboard.js +169 -82
- package/src/superlocalmemory/ui/js/od-backup.js +156 -65
- package/src/superlocalmemory/ui/js/od-brain.js +88 -51
- package/src/superlocalmemory/ui/js/od-components.js +147 -0
- package/src/superlocalmemory/ui/js/od-entities.js +65 -22
- package/src/superlocalmemory/ui/js/od-graph.js +46 -4
- package/src/superlocalmemory/ui/js/od-health.js +18 -0
- package/src/superlocalmemory/ui/js/od-memories.js +84 -5
- package/src/superlocalmemory/ui/js/od-mesh.js +23 -9
- package/src/superlocalmemory/ui/js/od-operations.js +36 -0
- package/src/superlocalmemory/ui/js/od-settings.js +186 -63
- package/src/superlocalmemory/ui/js/od-shell.js +249 -33
- package/src/superlocalmemory/ui/js/od-skills.js +44 -17
- package/src/superlocalmemory/ui/js/settings.js +15 -1
- package/plugin-src/.mcp.json +0 -12
- package/plugin-src/agents/slm-governance-advisor.md +0 -80
- package/plugin-src/agents/slm-loop-runner.md +0 -71
- package/plugin-src/agents/slm-memory-advisor.md +0 -49
- package/plugin-src/agents/slm-optimize-advisor.md +0 -44
- package/plugin-src/commands/slm-loop.md +0 -31
- package/plugin-src/hooks/.gitkeep +0 -0
- package/plugin-src/hooks/hooks.json +0 -102
- package/plugin-src/manifest.json +0 -30
- package/plugin-src/requirements.txt +0 -1
- package/plugin-src/rules/CLAUDE.md.fragment +0 -44
- package/plugin-src/scripts/ensure-venv.bat +0 -122
- package/plugin-src/scripts/ensure-venv.sh +0 -105
- package/plugin-src/scripts/slm-launch +0 -62
- package/plugin-src/scripts/slm-launch.bat +0 -23
- package/plugin-src/settings.json +0 -25
- package/plugin-src/skills/slm-governance/SKILL.md +0 -248
- package/plugin-src/skills/slm-loop/SKILL.md +0 -99
- package/plugin-src/skills/slm-mesh/SKILL.md +0 -282
- package/plugin-src/skills/slm-profile/SKILL.md +0 -148
- 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
|
-
|
|
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
|
-
|
|
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
|
-
#
|
|
1949
|
-
#
|
|
1950
|
-
#
|
|
1951
|
-
#
|
|
1952
|
-
#
|
|
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
|
-
|
|
1957
|
-
|
|
1958
|
-
|
|
1959
|
-
|
|
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
|
-
|
|
2042
|
-
|
|
2043
|
-
|
|
2044
|
-
|
|
2045
|
-
|
|
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
|
-
|
|
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:
|
|
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
|
-
|
|
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
|
-
|
|
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()
|