superlocalmemory 3.7.8 → 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 (280) hide show
  1. package/.claude-plugin/marketplace.json +1 -1
  2. package/ATTRIBUTION.md +1 -3
  3. package/CHANGELOG.md +129 -0
  4. package/README.md +205 -123
  5. package/package.json +12 -3
  6. package/plugin/.claude-plugin/plugin.json +2 -3
  7. package/plugin/CLAUDE.md +8 -8
  8. package/plugin/agents/slm-governance-advisor.md +80 -0
  9. package/plugin/agents/slm-loop-runner.md +71 -0
  10. package/plugin/agents/slm-memory-advisor.md +10 -5
  11. package/plugin/agents/slm-optimize-advisor.md +9 -3
  12. package/plugin/commands/slm-loop.md +31 -0
  13. package/plugin/hooks/hooks.json +79 -0
  14. package/plugin/requirements.txt +1 -1
  15. package/plugin/scripts/slm-launch +46 -7
  16. package/plugin/settings.json +9 -0
  17. package/plugin/skills/slm-cache/SKILL.md +9 -1
  18. package/plugin/skills/slm-compress/SKILL.md +8 -1
  19. package/plugin/skills/slm-governance/SKILL.md +248 -0
  20. package/plugin/skills/slm-graph/SKILL.md +17 -3
  21. package/plugin/skills/slm-loop/SKILL.md +99 -0
  22. package/plugin/skills/slm-mesh/SKILL.md +282 -0
  23. package/plugin/skills/slm-profile/SKILL.md +148 -0
  24. package/plugin/skills/slm-recall/SKILL.md +46 -10
  25. package/plugin/skills/slm-remember/SKILL.md +48 -1
  26. package/plugin/skills/slm-scope/SKILL.md +176 -0
  27. package/plugin/skills/slm-session/SKILL.md +24 -1
  28. package/plugin/skills/slm-status/SKILL.md +18 -1
  29. package/plugin-src/rules/AGENTS.md +57 -18
  30. package/plugin-src/skills/slm-cache/SKILL.md +9 -1
  31. package/plugin-src/skills/slm-compress/SKILL.md +8 -1
  32. package/plugin-src/skills/slm-graph/SKILL.md +17 -3
  33. package/plugin-src/skills/slm-recall/SKILL.md +46 -10
  34. package/plugin-src/skills/slm-remember/SKILL.md +48 -1
  35. package/plugin-src/skills/slm-session/SKILL.md +24 -1
  36. package/plugin-src/skills/slm-status/SKILL.md +18 -1
  37. package/pyproject.toml +2 -1
  38. package/scripts/postinstall/validation.js +2 -0
  39. package/scripts/postinstall-interactive.js +74 -2
  40. package/src/superlocalmemory/__init__.py +1 -1
  41. package/src/superlocalmemory/access/__init__.py +3 -0
  42. package/src/superlocalmemory/access/rbac.py +477 -0
  43. package/src/superlocalmemory/cli/commands.py +228 -17
  44. package/src/superlocalmemory/cli/compress_cmd.py +17 -7
  45. package/src/superlocalmemory/cli/daemon.py +7 -0
  46. package/src/superlocalmemory/cli/loop_cmd.py +187 -0
  47. package/src/superlocalmemory/cli/main.py +49 -8
  48. package/src/superlocalmemory/cli/mesh_cmd.py +38 -0
  49. package/src/superlocalmemory/cli/optimize_cmd.py +3 -0
  50. package/src/superlocalmemory/cli/pending_store.py +49 -13
  51. package/src/superlocalmemory/cli/proxy_cmd.py +4 -0
  52. package/src/superlocalmemory/cli/scale_engine_cmd.py +6 -0
  53. package/src/superlocalmemory/cli/setup_wizard.py +22 -13
  54. package/src/superlocalmemory/cli/version_banner.py +17 -3
  55. package/src/superlocalmemory/compliance/audit.py +6 -0
  56. package/src/superlocalmemory/compliance/gdpr.py +128 -138
  57. package/src/superlocalmemory/compliance/retention.py +176 -45
  58. package/src/superlocalmemory/core/backend_orchestrator.py +23 -59
  59. package/src/superlocalmemory/core/community_summary.py +267 -0
  60. package/src/superlocalmemory/core/config.py +216 -3
  61. package/src/superlocalmemory/core/consolidation_engine.py +95 -22
  62. package/src/superlocalmemory/core/context_cache.py +61 -18
  63. package/src/superlocalmemory/core/embedding_worker.py +21 -7
  64. package/src/superlocalmemory/core/embeddings.py +131 -46
  65. package/src/superlocalmemory/core/engine.py +41 -22
  66. package/src/superlocalmemory/core/engine_ingestion.py +359 -43
  67. package/src/superlocalmemory/core/engine_wiring.py +13 -0
  68. package/src/superlocalmemory/core/entity_community.py +178 -0
  69. package/src/superlocalmemory/core/graph_analyzer.py +39 -2
  70. package/src/superlocalmemory/core/graph_pruner.py +13 -8
  71. package/src/superlocalmemory/core/ingestion_command.py +134 -25
  72. package/src/superlocalmemory/core/injection.py +12 -7
  73. package/src/superlocalmemory/core/key_expander.py +138 -0
  74. package/src/superlocalmemory/core/maintenance.py +23 -0
  75. package/src/superlocalmemory/core/maintenance_scheduler.py +17 -7
  76. package/src/superlocalmemory/core/modes.py +1 -1
  77. package/src/superlocalmemory/core/mutations.py +2 -2
  78. package/src/superlocalmemory/core/pii.py +105 -0
  79. package/src/superlocalmemory/core/progressive_abstraction.py +208 -0
  80. package/src/superlocalmemory/core/recall_pipeline.py +7 -3
  81. package/src/superlocalmemory/core/recall_worker.py +20 -6
  82. package/src/superlocalmemory/core/scale_engine.py +60 -1
  83. package/src/superlocalmemory/core/security_primitives.py +40 -2
  84. package/src/superlocalmemory/core/store_pipeline.py +186 -29
  85. package/src/superlocalmemory/core/worker_pool.py +21 -6
  86. package/src/superlocalmemory/encoding/entity_reflexion.py +200 -0
  87. package/src/superlocalmemory/encoding/entity_resolver.py +34 -24
  88. package/src/superlocalmemory/encoding/fact_extractor.py +26 -1
  89. package/src/superlocalmemory/encoding/temporal_validator.py +64 -1
  90. package/src/superlocalmemory/evolution/evolution_store.py +122 -45
  91. package/src/superlocalmemory/evolution/llm_dispatch.py +12 -1
  92. package/src/superlocalmemory/evolution/model_selection.py +160 -0
  93. package/src/superlocalmemory/evolution/mutation_generator.py +16 -0
  94. package/src/superlocalmemory/evolution/skill_evolver.py +127 -42
  95. package/src/superlocalmemory/evolution/triggers.py +22 -13
  96. package/src/superlocalmemory/graph/cozo_backend.py +43 -20
  97. package/src/superlocalmemory/hooks/adapter_base.py +5 -1
  98. package/src/superlocalmemory/hooks/auto_recall.py +13 -1
  99. package/src/superlocalmemory/hooks/claude_code_hooks.py +11 -0
  100. package/src/superlocalmemory/hooks/codex_assets.py +64 -5
  101. package/src/superlocalmemory/hooks/hook_daemon.py +20 -3
  102. package/src/superlocalmemory/hooks/hook_handlers.py +6 -1
  103. package/src/superlocalmemory/hooks/memory_protocol.py +54 -0
  104. package/src/superlocalmemory/hooks/portable_kit.py +148 -3
  105. package/src/superlocalmemory/infra/backup.py +12 -1
  106. package/src/superlocalmemory/infra/daemon_identity.py +40 -4
  107. package/src/superlocalmemory/infra/data_root.py +43 -4
  108. package/src/superlocalmemory/infra/event_bus.py +107 -24
  109. package/src/superlocalmemory/infra/rate_limiter.py +93 -0
  110. package/src/superlocalmemory/ingestion/adapter_manager.py +4 -1
  111. package/src/superlocalmemory/ingestion/credentials.py +1 -1
  112. package/src/superlocalmemory/learning/cross_project.py +28 -19
  113. package/src/superlocalmemory/learning/model_rollback.py +3 -0
  114. package/src/superlocalmemory/learning/ranker_retrain_online.py +2 -0
  115. package/src/superlocalmemory/learning/reward.py +50 -0
  116. package/src/superlocalmemory/learning/reward_proxy.py +42 -9
  117. package/src/superlocalmemory/learning/source_quality.py +523 -1
  118. package/src/superlocalmemory/loops/__init__.py +56 -0
  119. package/src/superlocalmemory/loops/budget.py +58 -0
  120. package/src/superlocalmemory/loops/engine.py +164 -0
  121. package/src/superlocalmemory/loops/ledger.py +263 -0
  122. package/src/superlocalmemory/loops/models.py +152 -0
  123. package/src/superlocalmemory/loops/rules.py +52 -0
  124. package/src/superlocalmemory/mcp/_daemon_proxy.py +3 -0
  125. package/src/superlocalmemory/mcp/_pool_adapter.py +2 -0
  126. package/src/superlocalmemory/mcp/profiles.py +103 -0
  127. package/src/superlocalmemory/mcp/server.py +32 -79
  128. package/src/superlocalmemory/mcp/tools_active.py +4 -7
  129. package/src/superlocalmemory/mcp/tools_code_graph.py +51 -5
  130. package/src/superlocalmemory/mcp/tools_core.py +12 -4
  131. package/src/superlocalmemory/mcp/tools_evolution.py +6 -3
  132. package/src/superlocalmemory/mcp/tools_learning.py +2 -2
  133. package/src/superlocalmemory/mcp/tools_loops.py +300 -0
  134. package/src/superlocalmemory/mcp/tools_mesh.py +140 -4
  135. package/src/superlocalmemory/mcp/tools_optimize.py +15 -8
  136. package/src/superlocalmemory/mesh/broker.py +237 -129
  137. package/src/superlocalmemory/mesh/remote_sync.py +50 -8
  138. package/src/superlocalmemory/optimize/NOTICE +1 -6
  139. package/src/superlocalmemory/optimize/adapters/anthropic_adapter.py +1 -4
  140. package/src/superlocalmemory/optimize/adapters/openai_adapter.py +1 -4
  141. package/src/superlocalmemory/optimize/cache/semantic.py +27 -19
  142. package/src/superlocalmemory/optimize/compress/align.py +32 -26
  143. package/src/superlocalmemory/optimize/compress/ccr.py +14 -71
  144. package/src/superlocalmemory/optimize/compress/router.py +105 -22
  145. package/src/superlocalmemory/optimize/config/defaults.py +1 -1
  146. package/src/superlocalmemory/optimize/config/schema.py +87 -4
  147. package/src/superlocalmemory/optimize/metrics/counters.py +13 -4
  148. package/src/superlocalmemory/optimize/metrics/estimator.py +0 -3
  149. package/src/superlocalmemory/optimize/proxy/_helpers.py +31 -4
  150. package/src/superlocalmemory/optimize/storage/db.py +38 -9
  151. package/src/superlocalmemory/optimize/storage/schema.py +10 -0
  152. package/src/superlocalmemory/parameterization/pattern_extractor.py +6 -3
  153. package/src/superlocalmemory/retrieval/agentic.py +1 -1
  154. package/src/superlocalmemory/retrieval/bm25_channel.py +68 -10
  155. package/src/superlocalmemory/retrieval/engine.py +221 -47
  156. package/src/superlocalmemory/retrieval/entity_channel.py +7 -5
  157. package/src/superlocalmemory/retrieval/hopfield_channel.py +9 -2
  158. package/src/superlocalmemory/retrieval/reranker.py +3 -4
  159. package/src/superlocalmemory/retrieval/semantic_channel.py +114 -21
  160. package/src/superlocalmemory/retrieval/spreading_activation.py +11 -2
  161. package/src/superlocalmemory/retrieval/temporal_channel.py +48 -9
  162. package/src/superlocalmemory/retrieval/temporal_frame.py +102 -0
  163. package/src/superlocalmemory/retrieval/temporal_validity_filter.py +135 -0
  164. package/src/superlocalmemory/retrieval/time_window.py +181 -0
  165. package/src/superlocalmemory/server/api.py +4 -4
  166. package/src/superlocalmemory/server/config_file.py +90 -0
  167. package/src/superlocalmemory/server/origin.py +50 -0
  168. package/src/superlocalmemory/server/profile_runtime.py +125 -8
  169. package/src/superlocalmemory/server/rbac_enforce.py +142 -0
  170. package/src/superlocalmemory/server/recall_health.py +24 -3
  171. package/src/superlocalmemory/server/recall_serializer.py +19 -1
  172. package/src/superlocalmemory/server/routes/abstraction.py +115 -0
  173. package/src/superlocalmemory/server/routes/agents.py +128 -38
  174. package/src/superlocalmemory/server/routes/backup.py +317 -70
  175. package/src/superlocalmemory/server/routes/behavioral.py +349 -71
  176. package/src/superlocalmemory/server/routes/brain.py +69 -12
  177. package/src/superlocalmemory/server/routes/chat.py +10 -5
  178. package/src/superlocalmemory/server/routes/compliance.py +171 -21
  179. package/src/superlocalmemory/server/routes/config_api.py +438 -0
  180. package/src/superlocalmemory/server/routes/data_io.py +30 -8
  181. package/src/superlocalmemory/server/routes/entity.py +108 -26
  182. package/src/superlocalmemory/server/routes/events.py +24 -8
  183. package/src/superlocalmemory/server/routes/evolution.py +189 -68
  184. package/src/superlocalmemory/server/routes/helpers.py +16 -1
  185. package/src/superlocalmemory/server/routes/ingest.py +7 -4
  186. package/src/superlocalmemory/server/routes/insights.py +3 -3
  187. package/src/superlocalmemory/server/routes/learning.py +289 -118
  188. package/src/superlocalmemory/server/routes/learning_telemetry.py +153 -0
  189. package/src/superlocalmemory/server/routes/lifecycle.py +59 -8
  190. package/src/superlocalmemory/server/routes/memories.py +182 -57
  191. package/src/superlocalmemory/server/routes/mesh.py +200 -31
  192. package/src/superlocalmemory/server/routes/optimize.py +33 -1
  193. package/src/superlocalmemory/server/routes/prewarm.py +2 -0
  194. package/src/superlocalmemory/server/routes/profiles.py +63 -17
  195. package/src/superlocalmemory/server/routes/ratelimit.py +132 -0
  196. package/src/superlocalmemory/server/routes/rbac.py +367 -0
  197. package/src/superlocalmemory/server/routes/stats.py +103 -158
  198. package/src/superlocalmemory/server/routes/tiers.py +11 -9
  199. package/src/superlocalmemory/server/routes/token.py +3 -13
  200. package/src/superlocalmemory/server/routes/v3_api.py +247 -89
  201. package/src/superlocalmemory/server/routes/ws.py +5 -2
  202. package/src/superlocalmemory/server/security_middleware.py +12 -5
  203. package/src/superlocalmemory/server/ui.py +20 -5
  204. package/src/superlocalmemory/server/unified_daemon.py +827 -72
  205. package/src/superlocalmemory/server/write_identity.py +38 -8
  206. package/src/superlocalmemory/storage/database.py +265 -53
  207. package/src/superlocalmemory/storage/migration_runner.py +132 -1
  208. package/src/superlocalmemory/storage/migrations/M010_evolution_config.py +5 -0
  209. package/src/superlocalmemory/storage/migrations/M021_ingestion_log_profile.py +108 -0
  210. package/src/superlocalmemory/storage/migrations/M022_entity_aliases_profile.py +86 -0
  211. package/src/superlocalmemory/storage/migrations/M023_mesh_profile_isolation.py +194 -0
  212. package/src/superlocalmemory/storage/migrations/M024_rbac_users_roles.py +87 -0
  213. package/src/superlocalmemory/storage/migrations/M025_perf_indexes.py +90 -0
  214. package/src/superlocalmemory/storage/migrations/M026_rbac_memberships_fk.py +136 -0
  215. package/src/superlocalmemory/storage/migrations/M027_transferable_patterns_profile.py +163 -0
  216. package/src/superlocalmemory/storage/migrations/M028_fact_entity_associations.py +270 -0
  217. package/src/superlocalmemory/storage/migrations/M029_behavioral_history_indexes.py +137 -0
  218. package/src/superlocalmemory/storage/migrations/M030_entity_explorer_indexes.py +93 -0
  219. package/src/superlocalmemory/storage/migrations/__init__.py +4 -0
  220. package/src/superlocalmemory/storage/models.py +4 -0
  221. package/src/superlocalmemory/storage/schema.py +136 -1
  222. package/src/superlocalmemory/storage/schema_v32.py +2 -0
  223. package/src/superlocalmemory/storage/schema_v343.py +24 -12
  224. package/src/superlocalmemory/storage/schema_v347.py +4 -0
  225. package/src/superlocalmemory/trust/gate.py +49 -8
  226. package/src/superlocalmemory/ui/assets/slm-icon-white.svg +64 -0
  227. package/src/superlocalmemory/ui/assets/slm-icon.svg +36 -0
  228. package/src/superlocalmemory/ui/css/design-system.css +621 -0
  229. package/src/superlocalmemory/ui/css/neural-glass.css +6 -0
  230. package/src/superlocalmemory/ui/css/od-bridge.css +158 -0
  231. package/src/superlocalmemory/ui/favicon.svg +35 -4
  232. package/src/superlocalmemory/ui/index.html +303 -173
  233. package/src/superlocalmemory/ui/js/brain.js +5 -20
  234. package/src/superlocalmemory/ui/js/core.js +100 -41
  235. package/src/superlocalmemory/ui/js/dashboard.js +403 -65
  236. package/src/superlocalmemory/ui/js/event-delegation.js +102 -0
  237. package/src/superlocalmemory/ui/js/knowledge-graph.js +11 -11
  238. package/src/superlocalmemory/ui/js/math-health.js +1 -1
  239. package/src/superlocalmemory/ui/js/memories.js +15 -4
  240. package/src/superlocalmemory/ui/js/memory-chat.js +7 -7
  241. package/src/superlocalmemory/ui/js/ng-entities.js +6 -8
  242. package/src/superlocalmemory/ui/js/ng-ingestion.js +4 -4
  243. package/src/superlocalmemory/ui/js/ng-mesh.js +4 -9
  244. package/src/superlocalmemory/ui/js/ng-shell.js +8 -8
  245. package/src/superlocalmemory/ui/js/ng-skills.js +54 -2
  246. package/src/superlocalmemory/ui/js/od-agents.js +544 -0
  247. package/src/superlocalmemory/ui/js/od-auth-gate.js +257 -0
  248. package/src/superlocalmemory/ui/js/od-backup.js +871 -0
  249. package/src/superlocalmemory/ui/js/od-brain.js +816 -0
  250. package/src/superlocalmemory/ui/js/od-entities.js +579 -0
  251. package/src/superlocalmemory/ui/js/od-graph.js +600 -0
  252. package/src/superlocalmemory/ui/js/od-health.js +539 -0
  253. package/src/superlocalmemory/ui/js/od-mcp.js +508 -0
  254. package/src/superlocalmemory/ui/js/od-memories.js +929 -0
  255. package/src/superlocalmemory/ui/js/od-mesh.js +553 -0
  256. package/src/superlocalmemory/ui/js/od-operations.js +1250 -0
  257. package/src/superlocalmemory/ui/js/od-optimize.js +787 -0
  258. package/src/superlocalmemory/ui/js/od-settings.js +1107 -0
  259. package/src/superlocalmemory/ui/js/od-shell.js +809 -0
  260. package/src/superlocalmemory/ui/js/od-skills.js +600 -0
  261. package/src/superlocalmemory/ui/js/od-team.js +258 -0
  262. package/src/superlocalmemory/ui/js/profiles.js +159 -46
  263. package/src/superlocalmemory/ui/js/settings.js +17 -3
  264. package/src/superlocalmemory/ui/js/timeline.js +34 -5
  265. package/src/superlocalmemory/ui/js/trust-dashboard.js +2 -2
  266. package/src/superlocalmemory/vector/lancedb_backend.py +8 -6
  267. package/plugin-src/.mcp.json +0 -12
  268. package/plugin-src/agents/slm-memory-advisor.md +0 -44
  269. package/plugin-src/agents/slm-optimize-advisor.md +0 -38
  270. package/plugin-src/hooks/.gitkeep +0 -0
  271. package/plugin-src/hooks/hooks.json +0 -23
  272. package/plugin-src/manifest.json +0 -25
  273. package/plugin-src/requirements.txt +0 -1
  274. package/plugin-src/rules/CLAUDE.md.fragment +0 -44
  275. package/plugin-src/scripts/ensure-venv.bat +0 -122
  276. package/plugin-src/scripts/ensure-venv.sh +0 -105
  277. package/plugin-src/scripts/slm-launch +0 -23
  278. package/plugin-src/scripts/slm-launch.bat +0 -23
  279. package/plugin-src/settings.json +0 -16
  280. package/src/superlocalmemory/learning/behavioral_listener.py +0 -94
@@ -13,11 +13,29 @@ from __future__ import annotations
13
13
  import hashlib
14
14
  import hmac
15
15
  import os
16
+ import sys
16
17
  from typing import Any
17
18
 
18
19
  from fastapi import HTTPException, Request
19
20
 
20
21
 
22
+ # H-04 (3.7.9): the test-client auth bypass must be impossible in a real daemon
23
+ # even if SLM_TEST_ISOLATION=1 leaks into a production environment. Anchor it to
24
+ # pytest being present in the running interpreter — a production daemon process
25
+ # never imports pytest — decided once at import time so it cannot be toggled per
26
+ # request at runtime.
27
+ _TEST_ISOLATION_ALLOWED = (
28
+ os.environ.get("SLM_TEST_ISOLATION") == "1" and "pytest" in sys.modules
29
+ )
30
+
31
+ # H-01 (3.7.9): by default a same-user loopback process is a trusted principal
32
+ # and may write without a credential. Operators who want stricter isolation
33
+ # (e.g. a shared machine, or binding beyond loopback) set
34
+ # SLM_REQUIRE_CREDENTIALS=1 so that even loopback must present a daemon
35
+ # capability, install token, or API key. Default OFF — unchanged behaviour.
36
+ _REQUIRE_CREDENTIALS = os.environ.get("SLM_REQUIRE_CREDENTIALS") == "1"
37
+
38
+
21
39
  def _header(request: Request, name: str) -> str:
22
40
  headers = request.headers
23
41
  value = headers.get(name, "")
@@ -60,9 +78,21 @@ def require_write_actor(
60
78
  from superlocalmemory.core.security_primitives import verify_install_token
61
79
 
62
80
  if verify_install_token(_header(request, "X-Install-Token")):
63
- from superlocalmemory.core.engine_ingestion import local_trusted_actor_id
64
-
65
- return local_trusted_actor_id(actor_kind)
81
+ # The install token is embedded in the dashboard JS served over HTTP, so
82
+ # a LAN observer can read it. Accept it ONLY from a loopback caller (the
83
+ # local dashboard/CLI). A non-loopback holder must use an API key — this
84
+ # closes LAN privilege escalation via a leaked install token. In-process
85
+ # tests (testclient) keep the bypass only under SLM_TEST_ISOLATION.
86
+ client_host = request.client.host if request.client else ""
87
+ is_test_client = client_host == "testclient" and _TEST_ISOLATION_ALLOWED
88
+ # SEC-L-02: do NOT treat an empty client_host as loopback here — a
89
+ # missing peer address (e.g. behind a proxy that strips it) must not be
90
+ # trusted just because an install token is presented. require_http_mutation_actor
91
+ # already excludes "" from its loopback set; keep the two paths consistent.
92
+ if is_test_client or client_host in ("127.0.0.1", "::1", "localhost"):
93
+ from superlocalmemory.core.engine_ingestion import local_trusted_actor_id
94
+
95
+ return local_trusted_actor_id(actor_kind)
66
96
 
67
97
  from superlocalmemory.infra.auth_middleware import verify_api_key
68
98
 
@@ -103,11 +133,11 @@ def require_http_mutation_actor(
103
133
  )
104
134
 
105
135
  client_host = request.client.host if request.client else ""
106
- is_test_client = (
107
- client_host == "testclient"
108
- and os.environ.get("SLM_TEST_ISOLATION") == "1"
109
- )
110
- if client_host in ("127.0.0.1", "::1", "localhost") or is_test_client:
136
+ is_test_client = client_host == "testclient" and _TEST_ISOLATION_ALLOWED
137
+ loopback = client_host in ("127.0.0.1", "::1", "localhost")
138
+ # H-01: the loopback trusted-actor bypass is suppressed when the operator
139
+ # opts into SLM_REQUIRE_CREDENTIALS; in-process tests keep the bypass.
140
+ if is_test_client or (loopback and not _REQUIRE_CREDENTIALS):
111
141
  from superlocalmemory.core.engine_ingestion import local_trusted_actor_id
112
142
 
113
143
  return local_trusted_actor_id(actor_kind)
@@ -70,6 +70,17 @@ _MAX_RETRIES = _env_int("SLM_DB_MAX_RETRIES", 5) # retry on SQLIT
70
70
  _RETRY_BASE_DELAY = _env_float("SLM_DB_RETRY_BASE_DELAY", 0.1) # backoff base (s)
71
71
 
72
72
 
73
+ def _unbounded_facts_ceiling() -> int:
74
+ """Hard upper bound applied when a fact fetch is called with limit=None, so
75
+ a large tenant can never materialize the whole table into memory. Tunable
76
+ via SLM_MAX_FACTS_UNBOUNDED (default 50_000)."""
77
+ import os as _os
78
+ try:
79
+ return max(1, int(_os.environ.get("SLM_MAX_FACTS_UNBOUNDED", "50000")))
80
+ except (TypeError, ValueError):
81
+ return 50000
82
+
83
+
73
84
  def _scope_where(
74
85
  profile_id: str,
75
86
  *,
@@ -147,6 +158,14 @@ class DatabaseManager:
147
158
  conn.commit()
148
159
  finally:
149
160
  conn.close()
161
+ # C4 encryption-at-rest defense-in-depth: sensitive DB files shipped
162
+ # world-readable (0644). Restrict to owner-only now that the file (and
163
+ # its WAL sidecars) exist. Best-effort, never blocks DB open.
164
+ try:
165
+ from superlocalmemory.core.security_primitives import harden_db_perms
166
+ harden_db_perms(self.db_path)
167
+ except Exception:
168
+ pass
150
169
 
151
170
  def initialize(self, schema_module: ModuleType) -> None:
152
171
  """Create all tables. *schema_module* must expose ``create_all_tables(conn)``."""
@@ -431,17 +450,16 @@ class DatabaseManager:
431
450
  include_global=include_global,
432
451
  include_shared=include_shared,
433
452
  )
434
- if limit is not None:
435
- rows = self.execute(
436
- f"SELECT * FROM atomic_facts WHERE {where} "
437
- "ORDER BY created_at DESC LIMIT ?",
438
- (*params, int(limit)),
439
- )
440
- else:
441
- rows = self.execute(
442
- f"SELECT * FROM atomic_facts WHERE {where} ORDER BY created_at DESC",
443
- (*params,),
444
- )
453
+ # memory-bounding-02 + perf M-04: an unbounded fetch materializes the
454
+ # whole table (hundreds of MB with embeddings at 50k+ facts). Apply a
455
+ # hard, env-tunable ceiling even when the caller passes limit=None.
456
+ if limit is None:
457
+ limit = _unbounded_facts_ceiling()
458
+ rows = self.execute(
459
+ f"SELECT * FROM atomic_facts WHERE {where} "
460
+ "ORDER BY created_at DESC LIMIT ?",
461
+ (*params, int(limit)),
462
+ )
445
463
  return [self._row_to_fact(r) for r in rows]
446
464
 
447
465
  def get_external_visible_facts(
@@ -525,10 +543,19 @@ class DatabaseManager:
525
543
  "source_turn_ids_json", "session_id", "embedding",
526
544
  "fisher_mean", "fisher_variance", "lifecycle", "langevin_position",
527
545
  "emotional_valence", "emotional_arousal", "signal_type",
546
+ # Multi-scope (M016): allow re-scoping a fact after creation so a memory
547
+ # can be shared with a team or made global from the dashboard.
548
+ "scope", "shared_with",
528
549
  })
529
550
 
530
- def update_fact(self, fact_id: str, updates: dict[str, Any]) -> None:
531
- """Partial update on a fact. JSON-serializes list/dict values."""
551
+ def update_fact(self, fact_id: str, updates: dict[str, Any],
552
+ profile_id: str | None = None) -> None:
553
+ """Partial update on a fact. JSON-serializes list/dict values.
554
+
555
+ Tenant safety: when ``profile_id`` is supplied the UPDATE is constrained
556
+ to that tenant, so a fact_id belonging to another profile cannot be
557
+ mutated. Authorized routes always pass it.
558
+ """
532
559
  if not updates:
533
560
  raise ValueError("updates dict must not be empty")
534
561
  bad_keys = set(updates) - self._UPDATABLE_FACT_COLUMNS
@@ -543,21 +570,44 @@ class DatabaseManager:
543
570
  else:
544
571
  clean[k] = v
545
572
  set_clause = ", ".join(f"{k} = ?" for k in clean)
546
- self.execute(
547
- f"UPDATE atomic_facts SET {set_clause} WHERE fact_id = ?",
548
- (*clean.values(), fact_id),
549
- )
573
+ if profile_id is not None:
574
+ self.execute(
575
+ f"UPDATE atomic_facts SET {set_clause} WHERE fact_id = ? AND profile_id = ?",
576
+ (*clean.values(), fact_id, profile_id),
577
+ )
578
+ else:
579
+ self.execute(
580
+ f"UPDATE atomic_facts SET {set_clause} WHERE fact_id = ?",
581
+ (*clean.values(), fact_id),
582
+ )
550
583
 
551
- def delete_fact(self, fact_id: str) -> None:
584
+ def delete_fact(self, fact_id: str, profile_id: str | None = None) -> None:
552
585
  """Hard-delete a fact.
553
586
 
554
587
  DatabaseManager connections enforce FKs (PRAGMA foreign_keys=ON), so
555
588
  embedding_metadata / fact_retention / edges cascade. The explicit
556
589
  embedding_metadata delete below is belt-and-suspenders for the case a
557
590
  future caller routes through a connection without FK enforcement.
591
+
592
+ Tenant safety: when ``profile_id`` is supplied the delete is constrained
593
+ to that tenant (the fact must belong to it), so a fact_id from another
594
+ profile cannot be destroyed. Authorized routes always pass it.
558
595
  """
596
+ if profile_id is not None:
597
+ row = self.execute(
598
+ "SELECT 1 FROM atomic_facts WHERE fact_id = ? AND profile_id = ?",
599
+ (fact_id, profile_id),
600
+ )
601
+ if not row:
602
+ return # not this tenant's fact — no-op
559
603
  self.execute("DELETE FROM embedding_metadata WHERE fact_id = ?", (fact_id,))
560
- self.execute("DELETE FROM atomic_facts WHERE fact_id = ?", (fact_id,))
604
+ if profile_id is not None:
605
+ self.execute(
606
+ "DELETE FROM atomic_facts WHERE fact_id = ? AND profile_id = ?",
607
+ (fact_id, profile_id),
608
+ )
609
+ else:
610
+ self.execute("DELETE FROM atomic_facts WHERE fact_id = ?", (fact_id,))
561
611
 
562
612
  def gc_orphaned_embedding_metadata(self) -> int:
563
613
  """Remove embedding_metadata rows whose parent atomic_fact is gone.
@@ -626,35 +676,55 @@ class DatabaseManager:
626
676
  fact_count=d["fact_count"],
627
677
  )
628
678
 
629
- def store_alias(self, alias: EntityAlias) -> str:
630
- """Persist an entity alias. Returns alias_id."""
679
+ def store_alias(self, alias: EntityAlias, profile_id: str) -> str:
680
+ """Persist an entity alias under a profile. Returns alias_id.
681
+
682
+ profile_id scopes the alias so the same entity_id appearing in two
683
+ profiles never shares aliases.
684
+ """
631
685
  self.execute(
632
686
  "INSERT OR REPLACE INTO entity_aliases "
633
- "(alias_id, entity_id, alias, confidence, source) VALUES (?,?,?,?,?)",
634
- (alias.alias_id, alias.entity_id, alias.alias,
687
+ "(alias_id, profile_id, entity_id, alias, confidence, source) "
688
+ "VALUES (?,?,?,?,?,?)",
689
+ (alias.alias_id, profile_id, alias.entity_id, alias.alias,
635
690
  alias.confidence, alias.source),
636
691
  )
637
692
  return alias.alias_id
638
693
 
639
- def get_aliases_for_entity(self, entity_id: str) -> list[EntityAlias]:
640
- """All aliases for a canonical entity."""
694
+ def get_aliases_for_entity(
695
+ self, entity_id: str, profile_id: str,
696
+ ) -> list[EntityAlias]:
697
+ """All aliases for a canonical entity within one profile."""
641
698
  rows = self.execute(
642
- "SELECT * FROM entity_aliases WHERE entity_id = ?", (entity_id,),
699
+ "SELECT * FROM entity_aliases WHERE entity_id = ? AND profile_id = ?",
700
+ (entity_id, profile_id),
643
701
  )
644
702
  return [
645
703
  EntityAlias(**{k: dict(r)[k] for k in ("alias_id", "entity_id", "alias", "confidence", "source")})
646
704
  for r in rows
647
705
  ]
648
706
 
649
- def get_memory_content_batch(self, memory_ids: list[str]) -> dict[str, str]:
650
- """Batch-fetch original memory text. Returns {memory_id: content}."""
707
+ def get_memory_content_batch(
708
+ self, memory_ids: list[str], profile_id: str,
709
+ include_global: bool = False, include_shared: bool = False,
710
+ ) -> dict[str, str]:
711
+ """Batch-fetch original memory text. Returns {memory_id: content}.
712
+
713
+ C4 hardening: this exposes raw memory *content* and is reachable from
714
+ HTTP routes, so it is strictly tenant-scoped — a memory_id belonging to
715
+ another profile is never resolved. Widen only via the scope flags.
716
+ """
651
717
  if not memory_ids:
652
718
  return {}
653
719
  unique_ids = list(set(memory_ids))
654
720
  ph = ','.join('?' * len(unique_ids))
721
+ where, sparams = _scope_where(
722
+ profile_id, include_global=include_global, include_shared=include_shared,
723
+ )
655
724
  rows = self.execute(
656
- f"SELECT memory_id, content FROM memories WHERE memory_id IN ({ph})",
657
- tuple(unique_ids),
725
+ f"SELECT memory_id, content FROM memories "
726
+ f"WHERE memory_id IN ({ph}) AND {where}",
727
+ (*unique_ids, *sparams),
658
728
  )
659
729
  return {dict(r)["memory_id"]: dict(r)["content"] for r in rows}
660
730
 
@@ -876,11 +946,23 @@ class DatabaseManager:
876
946
  # Phase 0.6: Missing methods (BLOCKER / CRITICAL / HIGH)
877
947
  # ------------------------------------------------------------------
878
948
 
879
- def get_fact(self, fact_id: str) -> AtomicFact | None:
880
- """Get a single fact by ID."""
881
- rows = self.execute(
882
- "SELECT * FROM atomic_facts WHERE fact_id = ?", (fact_id,),
883
- )
949
+ def get_fact(self, fact_id: str, profile_id: str | None = None) -> AtomicFact | None:
950
+ """Get a single fact by ID.
951
+
952
+ C4 defense-in-depth: when ``profile_id`` is provided the lookup is
953
+ tenant-scoped so a fact_id from another profile cannot resolve. Left
954
+ optional (fact_id is a random UUID sourced from already-scoped queries)
955
+ to avoid destabilizing the core store/consolidation write path.
956
+ """
957
+ if profile_id is not None:
958
+ rows = self.execute(
959
+ "SELECT * FROM atomic_facts WHERE fact_id = ? AND profile_id = ?",
960
+ (fact_id, profile_id),
961
+ )
962
+ else:
963
+ rows = self.execute(
964
+ "SELECT * FROM atomic_facts WHERE fact_id = ?", (fact_id,),
965
+ )
884
966
  return self._row_to_fact(rows[0]) if rows else None
885
967
 
886
968
  def get_facts_by_ids(
@@ -1017,12 +1099,17 @@ class DatabaseManager:
1017
1099
  out.setdefault(fid, []).append(scene)
1018
1100
  return out
1019
1101
 
1020
- def increment_entity_fact_count(self, entity_id: str) -> None:
1021
- """Atomically increment fact_count for a canonical entity."""
1102
+ def increment_entity_fact_count(self, entity_id: str, profile_id: str = "default") -> None:
1103
+ """Atomically increment fact_count for a canonical entity scoped to profile.
1104
+
1105
+ L-01 fix: the original query had no profile_id guard; any entity_id match
1106
+ would be updated regardless of owner. The AND profile_id = ? clause prevents
1107
+ cross-profile mutations via shared entity_id values.
1108
+ """
1022
1109
  self.execute(
1023
1110
  "UPDATE canonical_entities SET fact_count = fact_count + 1 "
1024
- "WHERE entity_id = ?",
1025
- (entity_id,),
1111
+ "WHERE entity_id = ? AND profile_id = ?",
1112
+ (entity_id, profile_id),
1026
1113
  )
1027
1114
 
1028
1115
  def store_trust_score(self, ts: TrustScore) -> str:
@@ -1121,11 +1208,17 @@ class DatabaseManager:
1121
1208
  (fact_id, profile_id, contextual_description, keywords, generated_by),
1122
1209
  )
1123
1210
 
1124
- def get_fact_context(self, fact_id: str) -> dict | None:
1125
- """Get contextual description for a fact."""
1126
- rows = self.execute(
1127
- "SELECT * FROM fact_context WHERE fact_id = ?", (fact_id,),
1128
- )
1211
+ def get_fact_context(self, fact_id: str, profile_id: str | None = None) -> dict | None:
1212
+ """Get contextual description for a fact (C4: optionally tenant-scoped)."""
1213
+ if profile_id is not None:
1214
+ rows = self.execute(
1215
+ "SELECT * FROM fact_context WHERE fact_id = ? AND profile_id = ?",
1216
+ (fact_id, profile_id),
1217
+ )
1218
+ else:
1219
+ rows = self.execute(
1220
+ "SELECT * FROM fact_context WHERE fact_id = ?", (fact_id,),
1221
+ )
1129
1222
  return dict(rows[0]) if rows else None
1130
1223
 
1131
1224
  def get_all_fact_contexts(self, profile_id: str) -> list[dict]:
@@ -1277,12 +1370,18 @@ class DatabaseManager:
1277
1370
  (fact_id, profile_id, valid_from, valid_until),
1278
1371
  )
1279
1372
 
1280
- def get_temporal_validity(self, fact_id: str) -> dict | None:
1281
- """Get temporal validity record for a fact."""
1282
- rows = self.execute(
1283
- "SELECT * FROM fact_temporal_validity WHERE fact_id = ?",
1284
- (fact_id,),
1285
- )
1373
+ def get_temporal_validity(self, fact_id: str, profile_id: str | None = None) -> dict | None:
1374
+ """Get temporal validity record for a fact (C4: optionally tenant-scoped)."""
1375
+ if profile_id is not None:
1376
+ rows = self.execute(
1377
+ "SELECT * FROM fact_temporal_validity WHERE fact_id = ? AND profile_id = ?",
1378
+ (fact_id, profile_id),
1379
+ )
1380
+ else:
1381
+ rows = self.execute(
1382
+ "SELECT * FROM fact_temporal_validity WHERE fact_id = ?",
1383
+ (fact_id,),
1384
+ )
1286
1385
  return dict(rows[0]) if rows else None
1287
1386
 
1288
1387
  def get_all_temporal_validity(self, profile_id: str) -> list[dict]:
@@ -1316,18 +1415,106 @@ class DatabaseManager:
1316
1415
  """Get fact_ids that are currently valid (not expired).
1317
1416
 
1318
1417
  Returns facts that either have no temporal record (assumed valid)
1319
- or have valid_until IS NULL and system_expired_at IS NULL.
1418
+ or whose temporal record satisfies:
1419
+ - valid_until IS NULL (open-ended) OR valid_until > now() (still in window)
1420
+ - system_expired_at IS NULL (not system-invalidated)
1421
+
1422
+ M-02 fix: the original query used ``tv.valid_until IS NULL`` which
1423
+ incorrectly excluded future-dated (still valid) facts. The correct
1424
+ predicate is a date comparison against the current timestamp.
1320
1425
  """
1321
1426
  rows = self.execute(
1322
1427
  "SELECT f.fact_id FROM atomic_facts f "
1323
1428
  "LEFT JOIN fact_temporal_validity tv ON f.fact_id = tv.fact_id "
1324
1429
  "WHERE f.profile_id = ? "
1325
- " AND (tv.fact_id IS NULL OR tv.valid_until IS NULL) "
1326
- " AND (tv.fact_id IS NULL OR tv.system_expired_at IS NULL)",
1430
+ " AND (tv.fact_id IS NULL "
1431
+ " OR ( "
1432
+ " (tv.valid_until IS NULL "
1433
+ " OR tv.valid_until > strftime('%Y-%m-%dT%H:%M:%SZ', 'now')) "
1434
+ " AND tv.system_expired_at IS NULL "
1435
+ " ))",
1327
1436
  (profile_id,),
1328
1437
  )
1329
1438
  return [dict(r)["fact_id"] for r in rows]
1330
1439
 
1440
+ def get_invalidated_fact_ids(
1441
+ self, fact_ids: list[str], profile_id: str,
1442
+ ) -> set[str]:
1443
+ """Return the subset of ``fact_ids`` that are system-invalidated.
1444
+
1445
+ A fact is system-invalidated when ``system_expired_at`` is set — i.e.
1446
+ it was superseded/contradicted by a newer fact (see
1447
+ ``invalidate_fact_temporal``). Such facts are wrong/outdated and must be
1448
+ excluded from default retrieval (T1, Phase 4).
1449
+
1450
+ Bounded + indexed: only the supplied candidate ids are queried (never a
1451
+ full-table scan), keyed on the ``fact_id`` PK with the
1452
+ ``idx_temporal_system_expired`` index covering the predicate. Chunked to
1453
+ stay well under SQLite's ~999 bound-parameter limit. Facts with no
1454
+ temporal record — or a record whose ``system_expired_at`` is NULL — are
1455
+ NOT returned (treated as valid), so existing DBs need no backfill.
1456
+
1457
+ Event-time expiry (``valid_until`` in the past) is intentionally NOT
1458
+ applied here: it is query-scoped (historical queries legitimately want
1459
+ expired facts, per ``include_expired_in_history``) and handled by the
1460
+ time-window path, not by this blanket admission filter.
1461
+ """
1462
+ if not fact_ids:
1463
+ return set()
1464
+ invalid: set[str] = set()
1465
+ chunk = 900
1466
+ for start in range(0, len(fact_ids), chunk):
1467
+ batch = fact_ids[start:start + chunk]
1468
+ placeholders = ",".join("?" for _ in batch)
1469
+ rows = self.execute(
1470
+ f"SELECT fact_id FROM fact_temporal_validity "
1471
+ f"WHERE fact_id IN ({placeholders}) "
1472
+ f" AND profile_id = ? "
1473
+ f" AND system_expired_at IS NOT NULL",
1474
+ (*batch, profile_id),
1475
+ )
1476
+ for r in rows:
1477
+ invalid.add(dict(r)["fact_id"])
1478
+ return invalid
1479
+
1480
+ def get_fact_event_times(
1481
+ self, fact_ids: list[str], profile_id: str,
1482
+ ) -> dict[str, str]:
1483
+ """Map each candidate fact_id to its best-available event time.
1484
+
1485
+ Priority (most specific first): ``referenced_date`` (the date the fact
1486
+ is *about*) → ``observation_date`` (when it was observed) →
1487
+ ``valid_from`` (bi-temporal event start) → ``created_at`` (storage
1488
+ time, always present). Used by time-window recall to prune candidates
1489
+ by when the underlying event happened, falling back to capture time for
1490
+ undated facts.
1491
+
1492
+ Bounded + indexed (candidate ids only, ``fact_id`` PK), chunked under
1493
+ SQLite's bound-parameter limit. Facts absent from the result (unknown
1494
+ id / wrong profile) are simply omitted.
1495
+ """
1496
+ if not fact_ids:
1497
+ return {}
1498
+ out: dict[str, str] = {}
1499
+ chunk = 900
1500
+ for start in range(0, len(fact_ids), chunk):
1501
+ batch = fact_ids[start:start + chunk]
1502
+ placeholders = ",".join("?" for _ in batch)
1503
+ rows = self.execute(
1504
+ f"SELECT f.fact_id AS fact_id, "
1505
+ f"COALESCE(f.referenced_date, f.observation_date, "
1506
+ f" tv.valid_from, f.created_at) AS event_time "
1507
+ f"FROM atomic_facts f "
1508
+ f"LEFT JOIN fact_temporal_validity tv ON f.fact_id = tv.fact_id "
1509
+ f"WHERE f.fact_id IN ({placeholders}) AND f.profile_id = ?",
1510
+ (*batch, profile_id),
1511
+ )
1512
+ for r in rows:
1513
+ d = dict(r)
1514
+ if d.get("event_time"):
1515
+ out[d["fact_id"]] = d["event_time"]
1516
+ return out
1517
+
1331
1518
  def delete_temporal_validity(self, fact_id: str) -> None:
1332
1519
  """Delete temporal validity record (for testing/rollback only)."""
1333
1520
  self.execute(
@@ -1335,6 +1522,31 @@ class DatabaseManager:
1335
1522
  (fact_id,),
1336
1523
  )
1337
1524
 
1525
+ # ------------------------------------------------------------------
1526
+ # Phase 4 (T3b): fact-augmented key expansion (BM25 alt-keys)
1527
+ # ------------------------------------------------------------------
1528
+
1529
+ def upsert_fact_expansion(self, fact_id: str, alt_keys: str) -> None:
1530
+ """Store/replace a fact's alternate keys in ``fact_expansion_fts``.
1531
+
1532
+ Standalone FTS5 (no external-content triggers), so we replace by hand:
1533
+ delete any prior row for the fact, then insert the new alt-keys. An
1534
+ empty/blank ``alt_keys`` clears the fact's expansion entry. Fail-soft:
1535
+ a missing FTS table (legacy DB) never breaks the write path.
1536
+ """
1537
+ try:
1538
+ self.execute(
1539
+ "DELETE FROM fact_expansion_fts WHERE fact_id = ?", (fact_id,)
1540
+ )
1541
+ if alt_keys and alt_keys.strip():
1542
+ self.execute(
1543
+ "INSERT INTO fact_expansion_fts (fact_id, alt_keys) "
1544
+ "VALUES (?, ?)",
1545
+ (fact_id, alt_keys.strip()),
1546
+ )
1547
+ except Exception as exc: # pragma: no cover — legacy/missing FTS table
1548
+ logger.debug("upsert_fact_expansion skipped for %s: %s", fact_id, exc)
1549
+
1338
1550
  # ------------------------------------------------------------------
1339
1551
  # Phase 5: Core Memory Blocks CRUD (Rule 15)
1340
1552
  # ------------------------------------------------------------------