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.
Files changed (118) hide show
  1. package/CHANGELOG.md +60 -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 +1 -1
  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 +1 -1
  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 +1 -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 +1 -1
  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/src/superlocalmemory/__init__.py +1 -1
  33. package/src/superlocalmemory/cli/commands.py +134 -7
  34. package/src/superlocalmemory/cli/daemon.py +7 -0
  35. package/src/superlocalmemory/cli/loop_cmd.py +2 -7
  36. package/src/superlocalmemory/cli/main.py +10 -4
  37. package/src/superlocalmemory/cli/version_banner.py +17 -3
  38. package/src/superlocalmemory/core/backend_orchestrator.py +18 -16
  39. package/src/superlocalmemory/core/embedding_worker.py +4 -5
  40. package/src/superlocalmemory/core/embeddings.py +119 -45
  41. package/src/superlocalmemory/core/engine.py +24 -21
  42. package/src/superlocalmemory/core/engine_ingestion.py +332 -45
  43. package/src/superlocalmemory/core/ingestion_command.py +134 -25
  44. package/src/superlocalmemory/core/injection.py +12 -7
  45. package/src/superlocalmemory/core/maintenance_scheduler.py +17 -7
  46. package/src/superlocalmemory/core/recall_pipeline.py +5 -3
  47. package/src/superlocalmemory/core/store_pipeline.py +153 -20
  48. package/src/superlocalmemory/hooks/hook_handlers.py +6 -1
  49. package/src/superlocalmemory/hooks/portable_kit.py +34 -2
  50. package/src/superlocalmemory/learning/model_rollback.py +3 -0
  51. package/src/superlocalmemory/learning/ranker_retrain_online.py +2 -0
  52. package/src/superlocalmemory/learning/reward.py +50 -0
  53. package/src/superlocalmemory/learning/source_quality.py +523 -1
  54. package/src/superlocalmemory/loops/ledger.py +25 -5
  55. package/src/superlocalmemory/mcp/server.py +11 -30
  56. package/src/superlocalmemory/mcp/tools_core.py +4 -3
  57. package/src/superlocalmemory/mcp/tools_learning.py +2 -2
  58. package/src/superlocalmemory/retrieval/engine.py +53 -21
  59. package/src/superlocalmemory/retrieval/reranker.py +3 -4
  60. package/src/superlocalmemory/server/config_file.py +90 -0
  61. package/src/superlocalmemory/server/origin.py +50 -0
  62. package/src/superlocalmemory/server/routes/backup.py +293 -70
  63. package/src/superlocalmemory/server/routes/behavioral.py +336 -59
  64. package/src/superlocalmemory/server/routes/brain.py +57 -16
  65. package/src/superlocalmemory/server/routes/config_api.py +84 -82
  66. package/src/superlocalmemory/server/routes/entity.py +100 -23
  67. package/src/superlocalmemory/server/routes/evolution.py +103 -100
  68. package/src/superlocalmemory/server/routes/learning.py +276 -105
  69. package/src/superlocalmemory/server/routes/learning_telemetry.py +153 -0
  70. package/src/superlocalmemory/server/routes/mesh.py +121 -32
  71. package/src/superlocalmemory/server/routes/ratelimit.py +33 -25
  72. package/src/superlocalmemory/server/routes/stats.py +93 -155
  73. package/src/superlocalmemory/server/routes/token.py +3 -13
  74. package/src/superlocalmemory/server/routes/v3_api.py +64 -20
  75. package/src/superlocalmemory/server/unified_daemon.py +467 -40
  76. package/src/superlocalmemory/storage/migration_runner.py +79 -1
  77. package/src/superlocalmemory/storage/migrations/M010_evolution_config.py +5 -0
  78. package/src/superlocalmemory/storage/migrations/M028_fact_entity_associations.py +270 -0
  79. package/src/superlocalmemory/storage/migrations/M029_behavioral_history_indexes.py +137 -0
  80. package/src/superlocalmemory/storage/migrations/M030_entity_explorer_indexes.py +93 -0
  81. package/src/superlocalmemory/storage/migrations/__init__.py +4 -0
  82. package/src/superlocalmemory/storage/schema.py +49 -1
  83. package/src/superlocalmemory/storage/schema_v32.py +2 -0
  84. package/src/superlocalmemory/storage/schema_v347.py +4 -0
  85. package/src/superlocalmemory/ui/index.html +3 -6
  86. package/src/superlocalmemory/ui/js/core.js +52 -9
  87. package/src/superlocalmemory/ui/js/dashboard.js +169 -82
  88. package/src/superlocalmemory/ui/js/od-backup.js +156 -65
  89. package/src/superlocalmemory/ui/js/od-brain.js +88 -51
  90. package/src/superlocalmemory/ui/js/od-entities.js +22 -22
  91. package/src/superlocalmemory/ui/js/od-graph.js +11 -4
  92. package/src/superlocalmemory/ui/js/od-memories.js +47 -5
  93. package/src/superlocalmemory/ui/js/od-mesh.js +23 -9
  94. package/src/superlocalmemory/ui/js/od-settings.js +113 -59
  95. package/src/superlocalmemory/ui/js/od-shell.js +249 -33
  96. package/src/superlocalmemory/ui/js/od-skills.js +44 -17
  97. package/src/superlocalmemory/ui/js/settings.js +15 -1
  98. package/plugin-src/.mcp.json +0 -12
  99. package/plugin-src/agents/slm-governance-advisor.md +0 -80
  100. package/plugin-src/agents/slm-loop-runner.md +0 -71
  101. package/plugin-src/agents/slm-memory-advisor.md +0 -49
  102. package/plugin-src/agents/slm-optimize-advisor.md +0 -44
  103. package/plugin-src/commands/slm-loop.md +0 -31
  104. package/plugin-src/hooks/.gitkeep +0 -0
  105. package/plugin-src/hooks/hooks.json +0 -102
  106. package/plugin-src/manifest.json +0 -30
  107. package/plugin-src/requirements.txt +0 -1
  108. package/plugin-src/rules/CLAUDE.md.fragment +0 -44
  109. package/plugin-src/scripts/ensure-venv.bat +0 -122
  110. package/plugin-src/scripts/ensure-venv.sh +0 -105
  111. package/plugin-src/scripts/slm-launch +0 -62
  112. package/plugin-src/scripts/slm-launch.bat +0 -23
  113. package/plugin-src/settings.json +0 -25
  114. package/plugin-src/skills/slm-governance/SKILL.md +0 -248
  115. package/plugin-src/skills/slm-loop/SKILL.md +0 -99
  116. package/plugin-src/skills/slm-mesh/SKILL.md +0 -282
  117. package/plugin-src/skills/slm-profile/SKILL.md +0 -148
  118. 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
- yield
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
- # 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.
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
- _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
- ))
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
- _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:
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 = False,
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: Full (non-fast) recalls are gated by a semaphore to
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
- receipt = command.submit(IngestionRequest(
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
- result = command.materialize(receipt.operation_id) if wait else receipt
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))