superlocalmemory 3.8.0 → 3.8.1
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 +60 -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 +1 -1
- 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 +1 -1
- 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 +1 -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 +1 -1
- 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/src/superlocalmemory/__init__.py +1 -1
- package/src/superlocalmemory/cli/commands.py +134 -7
- package/src/superlocalmemory/cli/daemon.py +7 -0
- package/src/superlocalmemory/cli/loop_cmd.py +2 -7
- package/src/superlocalmemory/cli/main.py +10 -4
- package/src/superlocalmemory/cli/version_banner.py +17 -3
- package/src/superlocalmemory/core/backend_orchestrator.py +18 -16
- package/src/superlocalmemory/core/embedding_worker.py +4 -5
- package/src/superlocalmemory/core/embeddings.py +119 -45
- package/src/superlocalmemory/core/engine.py +24 -21
- package/src/superlocalmemory/core/engine_ingestion.py +332 -45
- package/src/superlocalmemory/core/ingestion_command.py +134 -25
- package/src/superlocalmemory/core/injection.py +12 -7
- package/src/superlocalmemory/core/maintenance_scheduler.py +17 -7
- package/src/superlocalmemory/core/recall_pipeline.py +5 -3
- package/src/superlocalmemory/core/store_pipeline.py +153 -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/server.py +11 -30
- package/src/superlocalmemory/mcp/tools_core.py +4 -3
- package/src/superlocalmemory/mcp/tools_learning.py +2 -2
- package/src/superlocalmemory/retrieval/engine.py +53 -21
- package/src/superlocalmemory/retrieval/reranker.py +3 -4
- 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 +336 -59
- 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 +276 -105
- package/src/superlocalmemory/server/routes/learning_telemetry.py +153 -0
- 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 +64 -20
- package/src/superlocalmemory/server/unified_daemon.py +467 -40
- 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 +3 -6
- 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-entities.js +22 -22
- package/src/superlocalmemory/ui/js/od-graph.js +11 -4
- package/src/superlocalmemory/ui/js/od-memories.js +47 -5
- package/src/superlocalmemory/ui/js/od-mesh.js +23 -9
- package/src/superlocalmemory/ui/js/od-settings.js +113 -59
- 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,347 @@ 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
|
+
else:
|
|
1072
|
+
successful_ticks += 1
|
|
1073
|
+
if pending == []:
|
|
1074
|
+
_set_source_quality_repair_status(application, state="complete")
|
|
1075
|
+
return
|
|
1076
|
+
await asyncio.sleep(max(0.0, float(tick_seconds)))
|
|
1077
|
+
except asyncio.CancelledError:
|
|
1078
|
+
_set_source_quality_repair_status(application, state="cancelled")
|
|
1079
|
+
raise
|
|
1080
|
+
except Exception as exc:
|
|
1081
|
+
logger.warning("source-quality startup repair failed: %s", exc)
|
|
1082
|
+
_set_source_quality_repair_status(
|
|
1083
|
+
application, state="failed", last_error=type(exc).__name__,
|
|
1084
|
+
)
|
|
1085
|
+
|
|
1086
|
+
|
|
1087
|
+
async def _cancel_source_quality_repair(application) -> None:
|
|
1088
|
+
task = getattr(application.state, "source_quality_repair_task", None)
|
|
1089
|
+
if task is None:
|
|
1090
|
+
return
|
|
1091
|
+
if not task.done():
|
|
1092
|
+
task.cancel()
|
|
1093
|
+
try:
|
|
1094
|
+
await task
|
|
1095
|
+
except asyncio.CancelledError:
|
|
1096
|
+
pass
|
|
1097
|
+
|
|
1098
|
+
|
|
718
1099
|
@asynccontextmanager
|
|
719
1100
|
async def lifespan(application: FastAPI):
|
|
720
1101
|
"""Initialize engine, workers, and optional services on startup."""
|
|
@@ -1350,7 +1731,20 @@ async def lifespan(application: FastAPI):
|
|
|
1350
1731
|
application.state.daemon_descriptor = _publish_process_descriptor(
|
|
1351
1732
|
_configured_daemon_port(), SLM_VERSION, "ready",
|
|
1352
1733
|
)
|
|
1353
|
-
|
|
1734
|
+
_schedule_source_quality_repair(
|
|
1735
|
+
application,
|
|
1736
|
+
state_path("memory.db"),
|
|
1737
|
+
state_path("learning.db"),
|
|
1738
|
+
)
|
|
1739
|
+
_schedule_fact_entity_association_repair(
|
|
1740
|
+
application,
|
|
1741
|
+
state_path("memory.db"),
|
|
1742
|
+
)
|
|
1743
|
+
try:
|
|
1744
|
+
yield
|
|
1745
|
+
finally:
|
|
1746
|
+
await _cancel_fact_entity_association_repair(application)
|
|
1747
|
+
await _cancel_source_quality_repair(application)
|
|
1354
1748
|
|
|
1355
1749
|
# Cancel the cross-platform sync loop (H-CONC-2) so adapter file I/O does
|
|
1356
1750
|
# not outlive the daemon.
|
|
@@ -1945,19 +2339,35 @@ def _register_dashboard_routes(application: FastAPI) -> None:
|
|
|
1945
2339
|
"error": "Remote HTTP MCP requires a configured SLM API key."
|
|
1946
2340
|
},
|
|
1947
2341
|
)
|
|
1948
|
-
#
|
|
1949
|
-
#
|
|
1950
|
-
#
|
|
1951
|
-
#
|
|
1952
|
-
#
|
|
2342
|
+
# Defense-in-depth CSRF/DNS-rebinding guard. A loopback hostname is
|
|
2343
|
+
# not, by itself, a trusted web origin: a different local process can
|
|
2344
|
+
# serve a page on another port. Credentialless browser writes must
|
|
2345
|
+
# therefore originate from this daemon's exact port. A local
|
|
2346
|
+
# integration on another port may still write when it presents a
|
|
2347
|
+
# valid credential; require_http_mutation_actor below validates it.
|
|
2348
|
+
# LAN origins remain opt-in through remote mode. Non-browser clients
|
|
2349
|
+
# (CLI/MCP/curl) send no Origin and are unaffected.
|
|
1953
2350
|
if requires_mutation_actor:
|
|
1954
2351
|
_origin = headers.get("origin", "") or headers.get("Origin", "")
|
|
1955
2352
|
if _origin:
|
|
1956
|
-
|
|
1957
|
-
|
|
1958
|
-
|
|
1959
|
-
|
|
1960
|
-
|
|
2353
|
+
from superlocalmemory.server.origin import (
|
|
2354
|
+
origin_is_daemon,
|
|
2355
|
+
origin_is_loopback,
|
|
2356
|
+
)
|
|
2357
|
+
|
|
2358
|
+
_daemon = getattr(application.state, "daemon_descriptor", None)
|
|
2359
|
+
_daemon_port = getattr(_daemon, "port", None) or _configured_daemon_port()
|
|
2360
|
+
_ok_origin = origin_is_daemon(_origin, port=int(_daemon_port))
|
|
2361
|
+
_has_browser_credential = any(
|
|
2362
|
+
headers.get(_header)
|
|
2363
|
+
for _header in (
|
|
2364
|
+
"x-slm-daemon-capability",
|
|
2365
|
+
"x-install-token",
|
|
2366
|
+
"x-slm-api-key",
|
|
2367
|
+
)
|
|
2368
|
+
)
|
|
2369
|
+
if not _ok_origin and origin_is_loopback(_origin) and _has_browser_credential:
|
|
2370
|
+
_ok_origin = True
|
|
1961
2371
|
if not _ok_origin:
|
|
1962
2372
|
from superlocalmemory.core.remote_mode import is_remote_origin_allowed
|
|
1963
2373
|
_ok_origin = is_remote_origin_allowed(_origin)
|
|
@@ -2038,30 +2448,11 @@ def _register_dashboard_routes(application: FastAPI) -> None:
|
|
|
2038
2448
|
# single-operator installs are unaffected. Owner (no session) reads
|
|
2039
2449
|
# freely unless company mode (require_login) is on; a logged-in user
|
|
2040
2450
|
# 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:
|
|
2451
|
+
# Config, learning, and behavioral reads expose installation,
|
|
2452
|
+
# preference, workflow, source-reputation, or outcome data.
|
|
2453
|
+
if _is_sensitive_dashboard_read(
|
|
2454
|
+
request.method, request.url.path,
|
|
2455
|
+
):
|
|
2065
2456
|
_resp = _rbac_read_gate(request, application.state)
|
|
2066
2457
|
if _resp is not None:
|
|
2067
2458
|
return _resp
|
|
@@ -2370,7 +2761,7 @@ def _register_daemon_routes(application: FastAPI) -> None:
|
|
|
2370
2761
|
request: Request,
|
|
2371
2762
|
q: str = "", query: str = "", limit: int = CANONICAL_RECALL_LIMIT,
|
|
2372
2763
|
session_id: str = "",
|
|
2373
|
-
fast: bool =
|
|
2764
|
+
fast: bool = True,
|
|
2374
2765
|
full: bool = False,
|
|
2375
2766
|
include_source: bool = False,
|
|
2376
2767
|
include_global: bool | None = None,
|
|
@@ -2409,7 +2800,7 @@ def _register_daemon_routes(application: FastAPI) -> None:
|
|
|
2409
2800
|
# recall (reranker timeout, cold embedder) blocks ALL endpoints.
|
|
2410
2801
|
import asyncio
|
|
2411
2802
|
_begin_recall()
|
|
2412
|
-
# v3.4.53:
|
|
2803
|
+
# v3.4.53: Opt-in deep recalls are gated by a semaphore to
|
|
2413
2804
|
# prevent resource oversaturation. Ollama serialises concurrent
|
|
2414
2805
|
# embedding calls and the reranker subprocess has a single lock —
|
|
2415
2806
|
# queuing more than ~3 concurrent full recalls just adds latency.
|
|
@@ -2526,7 +2917,7 @@ def _register_daemon_routes(application: FastAPI) -> None:
|
|
|
2526
2917
|
if isinstance(extra, dict):
|
|
2527
2918
|
meta.update(extra)
|
|
2528
2919
|
command = build_engine_ingestion_command(engine)
|
|
2529
|
-
|
|
2920
|
+
ingestion_request = IngestionRequest(
|
|
2530
2921
|
content=req.content,
|
|
2531
2922
|
profile_id=engine._profile_id,
|
|
2532
2923
|
source_type="http",
|
|
@@ -2536,9 +2927,42 @@ def _register_daemon_routes(application: FastAPI) -> None:
|
|
|
2536
2927
|
shared_with=tuple(shared_with or ()),
|
|
2537
2928
|
trusted_actor_id=trusted_actor_id,
|
|
2538
2929
|
session_id=req.session_id,
|
|
2539
|
-
)
|
|
2930
|
+
)
|
|
2931
|
+
# SQLite admission is usually milliseconds, but it can wait on a
|
|
2932
|
+
# concurrent migration or writer. Keep that wait out of ASGI so
|
|
2933
|
+
# dashboard navigation and recall stay responsive.
|
|
2934
|
+
receipt = await asyncio.to_thread(command.submit, ingestion_request)
|
|
2935
|
+
result = receipt
|
|
2936
|
+
wait_budget_exhausted = False
|
|
2937
|
+
if wait:
|
|
2938
|
+
materialization_task = asyncio.create_task(
|
|
2939
|
+
asyncio.to_thread(command.materialize, receipt.operation_id)
|
|
2940
|
+
)
|
|
2941
|
+
try:
|
|
2942
|
+
result = await asyncio.wait_for(
|
|
2943
|
+
asyncio.shield(materialization_task),
|
|
2944
|
+
timeout=_REMEMBER_ENRICHMENT_WAIT_SECONDS,
|
|
2945
|
+
)
|
|
2946
|
+
except TimeoutError:
|
|
2947
|
+
# The task retains the M018 lease and continues outside
|
|
2948
|
+
# this request. Return the durable receipt honestly;
|
|
2949
|
+
# the normal materializer can also reclaim it after a
|
|
2950
|
+
# lease expiry if the request-owned worker dies.
|
|
2951
|
+
wait_budget_exhausted = True
|
|
2952
|
+
|
|
2953
|
+
def _log_background_materialization(task):
|
|
2954
|
+
try:
|
|
2955
|
+
task.result()
|
|
2956
|
+
except Exception as exc:
|
|
2957
|
+
logger.warning(
|
|
2958
|
+
"bounded remember enrichment failed for %s: %s",
|
|
2959
|
+
receipt.operation_id,
|
|
2960
|
+
exc,
|
|
2961
|
+
)
|
|
2540
2962
|
|
|
2541
|
-
|
|
2963
|
+
materialization_task.add_done_callback(
|
|
2964
|
+
_log_background_materialization
|
|
2965
|
+
)
|
|
2542
2966
|
fact_ids = list(result.fact_ids)
|
|
2543
2967
|
# The queryable write is a separate durable transaction. A cold
|
|
2544
2968
|
# optional enrichment dependency (most often the local embedding
|
|
@@ -2581,10 +3005,13 @@ def _register_daemon_routes(application: FastAPI) -> None:
|
|
|
2581
3005
|
"note": (
|
|
2582
3006
|
"canonical ingestion complete"
|
|
2583
3007
|
if completed
|
|
3008
|
+
else "queryable now; enrichment continues after the wait budget"
|
|
3009
|
+
if wait_budget_exhausted
|
|
2584
3010
|
else "queryable now; canonical enrichment will retry"
|
|
2585
3011
|
if enrichment_deferred
|
|
2586
3012
|
else "queryable now; canonical enrichment pending"
|
|
2587
3013
|
),
|
|
3014
|
+
"wait_budget_exhausted": wait_budget_exhausted,
|
|
2588
3015
|
}
|
|
2589
3016
|
except Exception as exc:
|
|
2590
3017
|
raise HTTPException(500, detail=str(exc))
|