superlocalmemory 3.8.13 → 4.0.0

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 (212) hide show
  1. package/ATTRIBUTION.md +4 -4
  2. package/CHANGELOG.md +113 -121
  3. package/README.md +65 -63
  4. package/docs/pi-dev-integration.md +1 -1
  5. package/package.json +6 -1
  6. package/plugin/.claude-plugin/plugin.json +1 -1
  7. package/plugin/CLAUDE.md +3 -3
  8. package/plugin/agents/slm-governance-advisor.md +1 -1
  9. package/plugin/agents/slm-loop-runner.md +1 -1
  10. package/plugin/agents/slm-memory-advisor.md +1 -1
  11. package/plugin/agents/slm-optimize-advisor.md +1 -1
  12. package/plugin/requirements.txt +1 -1
  13. package/plugin/skills/slm-cache/SKILL.md +1 -1
  14. package/plugin/skills/slm-compress/SKILL.md +1 -1
  15. package/plugin/skills/slm-governance/SKILL.md +1 -1
  16. package/plugin/skills/slm-graph/SKILL.md +1 -1
  17. package/plugin/skills/slm-loop/SKILL.md +1 -1
  18. package/plugin/skills/slm-mesh/SKILL.md +1 -1
  19. package/plugin/skills/slm-profile/SKILL.md +1 -1
  20. package/plugin/skills/slm-recall/SKILL.md +1 -1
  21. package/plugin/skills/slm-remember/SKILL.md +1 -1
  22. package/plugin/skills/slm-scope/SKILL.md +1 -1
  23. package/plugin/skills/slm-session/SKILL.md +1 -1
  24. package/plugin/skills/slm-status/SKILL.md +1 -1
  25. package/plugin-src/rules/AGENTS.md +1 -1
  26. package/plugin-src/skills/slm-cache/SKILL.md +1 -1
  27. package/plugin-src/skills/slm-compress/SKILL.md +1 -1
  28. package/plugin-src/skills/slm-governance/SKILL.md +248 -0
  29. package/plugin-src/skills/slm-graph/SKILL.md +1 -1
  30. package/plugin-src/skills/slm-loop/SKILL.md +99 -0
  31. package/plugin-src/skills/slm-mesh/SKILL.md +282 -0
  32. package/plugin-src/skills/slm-profile/SKILL.md +148 -0
  33. package/plugin-src/skills/slm-recall/SKILL.md +1 -1
  34. package/plugin-src/skills/slm-remember/SKILL.md +1 -1
  35. package/plugin-src/skills/slm-scope/SKILL.md +176 -0
  36. package/plugin-src/skills/slm-session/SKILL.md +1 -1
  37. package/plugin-src/skills/slm-status/SKILL.md +1 -1
  38. package/pyproject.toml +11 -4
  39. package/src/superlocalmemory/__init__.py +1 -1
  40. package/src/superlocalmemory/cli/commands.py +125 -11
  41. package/src/superlocalmemory/cli/daemon.py +5 -1
  42. package/src/superlocalmemory/cli/main.py +35 -2
  43. package/src/superlocalmemory/cli/ops_cmd.py +281 -0
  44. package/src/superlocalmemory/cli/setup_wizard.py +1 -1
  45. package/src/superlocalmemory/compliance/audit.py +65 -0
  46. package/src/superlocalmemory/compliance/eu_ai_act.py +27 -57
  47. package/src/superlocalmemory/compliance/gdpr.py +416 -20
  48. package/src/superlocalmemory/compliance/retention.py +74 -22
  49. package/src/superlocalmemory/compliance/scheduler.py +78 -9
  50. package/src/superlocalmemory/core/actor_context.py +166 -0
  51. package/src/superlocalmemory/core/admission.py +549 -0
  52. package/src/superlocalmemory/core/backend_orchestrator.py +23 -10
  53. package/src/superlocalmemory/core/config.py +202 -24
  54. package/src/superlocalmemory/core/consolidation_engine.py +13 -13
  55. package/src/superlocalmemory/core/context_cache.py +28 -0
  56. package/src/superlocalmemory/core/embeddings.py +64 -2
  57. package/src/superlocalmemory/core/engine.py +7 -2
  58. package/src/superlocalmemory/core/engine_ingestion.py +65 -3
  59. package/src/superlocalmemory/core/engine_wiring.py +36 -9
  60. package/src/superlocalmemory/core/ingest_policy.py +38 -0
  61. package/src/superlocalmemory/core/maintenance.py +255 -0
  62. package/src/superlocalmemory/core/modes.py +40 -13
  63. package/src/superlocalmemory/core/mutations.py +437 -44
  64. package/src/superlocalmemory/core/operation_policy.py +92 -0
  65. package/src/superlocalmemory/core/operation_policy_registry.py +542 -0
  66. package/src/superlocalmemory/core/operation_request.py +127 -0
  67. package/src/superlocalmemory/core/ops_remediation.py +542 -0
  68. package/src/superlocalmemory/core/recall_pipeline.py +7 -0
  69. package/src/superlocalmemory/core/remember_runtime.py +202 -4
  70. package/src/superlocalmemory/core/remote_mode.py +20 -5
  71. package/src/superlocalmemory/core/store_pipeline.py +150 -0
  72. package/src/superlocalmemory/core/topic_signature.py +19 -4
  73. package/src/superlocalmemory/core/transactions/__init__.py +78 -0
  74. package/src/superlocalmemory/core/transactions/concrete_owners.py +597 -0
  75. package/src/superlocalmemory/core/transactions/erasure.py +825 -0
  76. package/src/superlocalmemory/core/transactions/manifest.py +255 -0
  77. package/src/superlocalmemory/core/transactions/manifest_key.py +155 -0
  78. package/src/superlocalmemory/core/transactions/obligations.py +272 -0
  79. package/src/superlocalmemory/core/transactions/owners.py +114 -0
  80. package/src/superlocalmemory/core/transactions/reconciler.py +285 -0
  81. package/src/superlocalmemory/core/transactions/service.py +330 -0
  82. package/src/superlocalmemory/core/worker_pool.py +33 -5
  83. package/src/superlocalmemory/encoding/cognitive_consolidator.py +70 -28
  84. package/src/superlocalmemory/encoding/emotional.py +75 -14
  85. package/src/superlocalmemory/encoding/scene_builder.py +115 -13
  86. package/src/superlocalmemory/encoding/temporal_parser.py +4 -0
  87. package/src/superlocalmemory/evolution/blind_verifier.py +11 -4
  88. package/src/superlocalmemory/evolution/evolution_store.py +244 -4
  89. package/src/superlocalmemory/evolution/llm_dispatch.py +40 -0
  90. package/src/superlocalmemory/evolution/model_selection.py +18 -3
  91. package/src/superlocalmemory/evolution/mutation_generator.py +3 -0
  92. package/src/superlocalmemory/evolution/skill_activator.py +270 -0
  93. package/src/superlocalmemory/evolution/skill_evolver.py +281 -59
  94. package/src/superlocalmemory/evolution/types.py +30 -8
  95. package/src/superlocalmemory/graph/cozo_backend.py +17 -9
  96. package/src/superlocalmemory/hooks/auto_invoker.py +2 -1
  97. package/src/superlocalmemory/hooks/auto_recall.py +64 -30
  98. package/src/superlocalmemory/hooks/codex_assets.py +14 -1
  99. package/src/superlocalmemory/infra/backup.py +434 -7
  100. package/src/superlocalmemory/infra/process_reaper.py +18 -0
  101. package/src/superlocalmemory/infra/self_heal.py +401 -0
  102. package/src/superlocalmemory/learning/feedback.py +52 -9
  103. package/src/superlocalmemory/loops/engine.py +10 -0
  104. package/src/superlocalmemory/mcp/_daemon_proxy.py +3 -0
  105. package/src/superlocalmemory/mcp/http_transport.py +30 -331
  106. package/src/superlocalmemory/mcp/profiles.py +5 -0
  107. package/src/superlocalmemory/mcp/resources.py +8 -0
  108. package/src/superlocalmemory/mcp/server.py +51 -4
  109. package/src/superlocalmemory/mcp/shared.py +19 -0
  110. package/src/superlocalmemory/mcp/tools_active.py +25 -4
  111. package/src/superlocalmemory/mcp/tools_code_graph.py +26 -18
  112. package/src/superlocalmemory/mcp/tools_context.py +50 -8
  113. package/src/superlocalmemory/mcp/tools_core.py +69 -21
  114. package/src/superlocalmemory/mcp/tools_evolution.py +9 -2
  115. package/src/superlocalmemory/mcp/tools_learning.py +21 -10
  116. package/src/superlocalmemory/mcp/tools_loops.py +29 -18
  117. package/src/superlocalmemory/mcp/tools_mesh.py +8 -0
  118. package/src/superlocalmemory/mcp/tools_ops.py +115 -0
  119. package/src/superlocalmemory/mcp/tools_optimize.py +4 -0
  120. package/src/superlocalmemory/mcp/tools_v28.py +10 -3
  121. package/src/superlocalmemory/mcp/tools_v3.py +34 -14
  122. package/src/superlocalmemory/mcp/tools_v33.py +18 -33
  123. package/src/superlocalmemory/mesh/broker.py +124 -46
  124. package/src/superlocalmemory/mesh/broker_security.py +470 -0
  125. package/src/superlocalmemory/mesh/discovery.py +365 -0
  126. package/src/superlocalmemory/mesh/lock_protocol.py +313 -0
  127. package/src/superlocalmemory/mesh/node_identity.py +97 -0
  128. package/src/superlocalmemory/mesh/outbox_remote.py +429 -0
  129. package/src/superlocalmemory/mesh/remote_sync.py +511 -28
  130. package/src/superlocalmemory/mesh/state_sync.py +286 -0
  131. package/src/superlocalmemory/optimize/config/store.py +45 -0
  132. package/src/superlocalmemory/parameterization/cross_project.py +12 -0
  133. package/src/superlocalmemory/parameterization/prompt_injector.py +13 -11
  134. package/src/superlocalmemory/parameterization/prompt_lifecycle.py +8 -2
  135. package/src/superlocalmemory/parameterization/workflow_miner.py +17 -0
  136. package/src/superlocalmemory/retrieval/ann_index.py +5 -0
  137. package/src/superlocalmemory/retrieval/bm25_channel.py +49 -2
  138. package/src/superlocalmemory/retrieval/engine.py +19 -4
  139. package/src/superlocalmemory/retrieval/fusion.py +4 -1
  140. package/src/superlocalmemory/retrieval/hopfield_channel.py +9 -3
  141. package/src/superlocalmemory/retrieval/remote_reranker.py +47 -22
  142. package/src/superlocalmemory/retrieval/reranker.py +32 -1
  143. package/src/superlocalmemory/retrieval/temporal_channel.py +16 -3
  144. package/src/superlocalmemory/retrieval/temporal_utils.py +107 -0
  145. package/src/superlocalmemory/retrieval/temporal_validity_filter.py +155 -42
  146. package/src/superlocalmemory/retrieval/vector_store.py +214 -8
  147. package/src/superlocalmemory/server/api.py +5 -5
  148. package/src/superlocalmemory/server/egress_policy.py +258 -0
  149. package/src/superlocalmemory/server/rbac_enforce.py +32 -0
  150. package/src/superlocalmemory/server/route_mutations.py +20 -0
  151. package/src/superlocalmemory/server/routes/compliance.py +153 -7
  152. package/src/superlocalmemory/server/routes/data_io.py +43 -2
  153. package/src/superlocalmemory/server/routes/events.py +15 -0
  154. package/src/superlocalmemory/server/routes/memories.py +56 -3
  155. package/src/superlocalmemory/server/routes/mesh.py +82 -1
  156. package/src/superlocalmemory/server/routes/mesh_lock.py +54 -0
  157. package/src/superlocalmemory/server/routes/mesh_state.py +63 -0
  158. package/src/superlocalmemory/server/routes/v3_api.py +50 -27
  159. package/src/superlocalmemory/server/routes/ws.py +86 -0
  160. package/src/superlocalmemory/server/ui.py +6 -6
  161. package/src/superlocalmemory/server/unified_daemon.py +942 -119
  162. package/src/superlocalmemory/storage/_migration_internals.py +568 -0
  163. package/src/superlocalmemory/storage/_schema_version.py +110 -0
  164. package/src/superlocalmemory/storage/database.py +329 -24
  165. package/src/superlocalmemory/storage/embedding_migrator.py +246 -51
  166. package/src/superlocalmemory/storage/erasure_fence.py +45 -0
  167. package/src/superlocalmemory/storage/generation_fence.py +63 -0
  168. package/src/superlocalmemory/storage/migration_runner.py +140 -417
  169. package/src/superlocalmemory/storage/migrations/M009_model_lineage.py +40 -0
  170. package/src/superlocalmemory/storage/migrations/M033_projection_transactions.py +148 -0
  171. package/src/superlocalmemory/storage/migrations/M034_obligation_integrity.py +58 -0
  172. package/src/superlocalmemory/storage/migrations/M035_erasure_receipts.py +113 -0
  173. package/src/superlocalmemory/storage/migrations/M036_vector_row_map.py +107 -0
  174. package/src/superlocalmemory/storage/migrations/M037_manifest_hmac_version.py +162 -0
  175. package/src/superlocalmemory/storage/migrations/{M033_learning_feedback_channel.py → M038_learning_feedback_channel.py} +3 -3
  176. package/src/superlocalmemory/storage/migrations/M039_scene_fact_members.py +137 -0
  177. package/src/superlocalmemory/storage/migrations/__init__.py +4 -2
  178. package/src/superlocalmemory/storage/schema.py +67 -0
  179. package/src/superlocalmemory/storage/write_coordinator.py +125 -0
  180. package/src/superlocalmemory/trust/scorer.py +28 -4
  181. package/src/superlocalmemory/ui/index.html +14 -3
  182. package/src/superlocalmemory/ui/js/auto-settings.js +12 -1
  183. package/src/superlocalmemory/ui/js/brain.js +6 -4
  184. package/src/superlocalmemory/ui/js/compliance.js +66 -12
  185. package/src/superlocalmemory/ui/js/dashboard.js +13 -3
  186. package/src/superlocalmemory/ui/js/feedback.js +8 -2
  187. package/src/superlocalmemory/ui/js/lifecycle.js +7 -1
  188. package/src/superlocalmemory/ui/js/modal.js +272 -5
  189. package/src/superlocalmemory/ui/js/od-backup.js +9 -2
  190. package/src/superlocalmemory/ui/js/od-compliance-ext.js +301 -0
  191. package/src/superlocalmemory/ui/js/od-operations.js +154 -23
  192. package/src/superlocalmemory/ui/js/od-ops-health.js +417 -0
  193. package/src/superlocalmemory/ui/js/od-optimize.js +35 -21
  194. package/src/superlocalmemory/ui/js/od-team.js +9 -2
  195. package/src/superlocalmemory/ui/js/optimize.js +13 -16
  196. package/src/superlocalmemory/ui/js/profiles.js +7 -3
  197. package/src/superlocalmemory/ui/js/settings.js +7 -1
  198. package/src/superlocalmemory/vector/lancedb_backend.py +19 -9
  199. package/src/superlocalmemory/attribution/mathematical_dna.py +0 -235
  200. package/src/superlocalmemory/cli/post_install.py +0 -114
  201. package/src/superlocalmemory/core/clock_monitor.py +0 -45
  202. package/src/superlocalmemory/core/db_pool.py +0 -80
  203. package/src/superlocalmemory/core/error_catalog.py +0 -113
  204. package/src/superlocalmemory/core/loop_watchdog.py +0 -56
  205. package/src/superlocalmemory/core/priority_queue.py +0 -61
  206. package/src/superlocalmemory/core/pruning_engine.py +0 -216
  207. package/src/superlocalmemory/core/queue_dispatcher.py +0 -73
  208. package/src/superlocalmemory/core/slmignore.py +0 -125
  209. package/src/superlocalmemory/infra/heartbeat_monitor.py +0 -140
  210. package/src/superlocalmemory/infra/webhook_dispatcher.py +0 -247
  211. package/src/superlocalmemory/learning/quantization_scheduler.py +0 -320
  212. package/src/superlocalmemory/storage/access_control.py +0 -182
@@ -283,8 +283,9 @@ class RetrievalConfig:
283
283
  # It is now read, validated, and — when it disagrees with the backend —
284
284
  # reported as a loud configuration error instead of nothing at all.
285
285
  cross_encoder_endpoint: str = ""
286
- # Optional bearer token. Prefer the SLM_CROSS_ENCODER_API_KEY environment
287
- # variable it takes precedence and keeps the secret out of config.json.
286
+ # Optional bearer token. SLM_CROSS_ENCODER_API_KEY takes precedence.
287
+ # When persisted for backward compatibility, config.json is atomically
288
+ # forced to owner-only mode 0600 by save().
288
289
  cross_encoder_api_key: str = ""
289
290
  # Per-request read budget for the remote reranker. Recall is interactive,
290
291
  # so this stays tight: a slow reranker degrades to fusion scores rather
@@ -386,6 +387,12 @@ class MathConfig:
386
387
 
387
388
  # Hopfield
388
389
 
390
+ # Ebbinghaus-Langevin coupling in maintenance (Phase 5 — P1-ELC)
391
+ # When True, the maintenance cycle computes the combined Ebbinghaus-Langevin
392
+ # coupled state for each fact and writes back the lifecycle zone.
393
+ # Defaults to False to preserve existing maintenance behaviour.
394
+ ebbinghaus_langevin_coupling_enabled: bool = False
395
+
389
396
  # Sheaf (at encoding time, NOT retrieval)
390
397
  sheaf_at_encoding: bool = True
391
398
  sheaf_contradiction_threshold: float = 0.45
@@ -744,6 +751,11 @@ class TemporalValidatorConfig:
744
751
  # Include expired facts in historical queries
745
752
  include_expired_in_history: bool = True
746
753
 
754
+ # Event-time (valid_until) demotion factor, applied like
755
+ # superseded_demotion_factor but for facts whose stated validity window has
756
+ # elapsed. Default matches the prior hardcoded constant (0.5).
757
+ event_time_demotion_factor: float = 0.5
758
+
747
759
 
748
760
  @dataclass(frozen=True)
749
761
  class EvolutionConfig:
@@ -908,7 +920,7 @@ DEPLOYMENT_ENTERPRISE = DeploymentConfig(
908
920
  def load_deployment_config(
909
921
  config_toml_path: Path | None = None,
910
922
  ) -> DeploymentConfig:
911
- """Parse [deployment] from config.toml; return Personal defaults if absent.
923
+ """Parse [deployment] from config.toml with fail-closed invalid-state handling.
912
924
 
913
925
  config.toml is the installer-written performance config (separate from the
914
926
  daemon's config.json managed by SLMConfig). This function reads ONLY the
@@ -919,8 +931,9 @@ def load_deployment_config(
919
931
  ``~/.superlocalmemory/config.toml`` via the canonical data root.
920
932
 
921
933
  Returns:
922
- DeploymentConfig — Personal preset when the file is absent, the section
923
- is missing, or any parse error occurs (fail-open, non-destructive).
934
+ DeploymentConfig — Personal only when the file or deployment section is
935
+ absent. A present but invalid deployment configuration resolves to the
936
+ enterprise preset so startup controls cannot silently downgrade.
924
937
  """
925
938
  if config_toml_path is None:
926
939
  try:
@@ -939,40 +952,55 @@ def load_deployment_config(
939
952
  logger.warning(
940
953
  "load_deployment_config: failed to parse %s: %s", config_toml_path, exc
941
954
  )
942
- return DEPLOYMENT_PERSONAL
955
+ return DEPLOYMENT_ENTERPRISE
943
956
 
944
- dep = data.get("deployment", {})
945
- if not dep:
957
+ if "deployment" not in data:
946
958
  # No [deployment] section — personal defaults, no behaviour change.
947
959
  return DEPLOYMENT_PERSONAL
960
+ dep = data["deployment"]
961
+ if not isinstance(dep, dict):
962
+ logger.warning(
963
+ "load_deployment_config: [deployment] in %s is not a table — "
964
+ "failing closed to enterprise",
965
+ config_toml_path,
966
+ )
967
+ return DEPLOYMENT_ENTERPRISE
948
968
 
949
- raw_mode = str(dep.get("mode", "personal")).lower()
969
+ raw_mode = str(dep.get("mode", "")).lower()
950
970
  if raw_mode not in _VALID_DEPLOYMENT_MODES:
951
971
  logger.warning(
952
- "load_deployment_config: unknown mode %r in %s — defaulting to personal",
972
+ "load_deployment_config: unknown mode %r in %s — failing closed to enterprise",
953
973
  raw_mode, config_toml_path,
954
974
  )
955
- raw_mode = "personal"
975
+ return DEPLOYMENT_ENTERPRISE
956
976
 
957
977
  # Use the preset for the mode as the base so omitted keys inherit
958
978
  # sensible values (enterprise → require_login=True etc.).
959
979
  base = DEPLOYMENT_ENTERPRISE if raw_mode == "enterprise" else DEPLOYMENT_PERSONAL
960
980
 
981
+ def _strict_bool(name: str, default: bool) -> bool:
982
+ value = dep.get(name, default)
983
+ if not isinstance(value, bool):
984
+ raise TypeError(f"{name} must be a TOML boolean")
985
+ return value
986
+
961
987
  try:
962
988
  return DeploymentConfig(
963
989
  mode=raw_mode,
964
- require_login=bool(dep.get("require_login", base.require_login)),
965
- pii_redaction=bool(dep.get("pii_redaction", base.pii_redaction)),
966
- retention_enabled=bool(dep.get("retention_enabled", base.retention_enabled)),
967
- audit=bool(dep.get("audit", base.audit)),
990
+ require_login=_strict_bool("require_login", base.require_login),
991
+ pii_redaction=_strict_bool("pii_redaction", base.pii_redaction),
992
+ retention_enabled=_strict_bool(
993
+ "retention_enabled", base.retention_enabled
994
+ ),
995
+ audit=_strict_bool("audit", base.audit),
968
996
  )
969
997
  except (ValueError, TypeError) as exc:
970
998
  logger.warning(
971
999
  "load_deployment_config: invalid values in [deployment] in %s: %s — "
972
- "falling back to personal",
1000
+ "failing closed to enterprise",
973
1001
  config_toml_path, exc,
974
1002
  )
975
- return DEPLOYMENT_PERSONAL
1003
+ return DEPLOYMENT_ENTERPRISE
976
1004
 
977
1005
 
978
1006
  # ---------------------------------------------------------------------------
@@ -1085,6 +1113,13 @@ class SLMConfig:
1085
1113
  daemon_legacy_port: int = 8767 # Backward-compat redirect port
1086
1114
  daemon_enable_legacy_port: bool = True # Set False to disable 8767 redirect
1087
1115
 
1116
+ # v4: auto-close orphaned application sessions during maintenance.
1117
+ # A session is stale when it has had no new atomic_facts for this many
1118
+ # hours. close_session is only invoked by the MCP tool otherwise, so
1119
+ # without this pass temporal summaries never form for abandoned sessions.
1120
+ session_idle_close_hours: float = 24.0
1121
+ session_idle_close_max_per_pass: int = 50
1122
+
1088
1123
  # v3.4.3: Entity compilation
1089
1124
  entity_compilation_enabled: bool = True
1090
1125
  entity_compilation_retrieval_boost: float = 1.0 # 1.0 = disabled. >1.0 = boost score.
@@ -1092,6 +1127,11 @@ class SLMConfig:
1092
1127
  # v3.4.3: Mesh
1093
1128
  mesh_enabled: bool = True
1094
1129
 
1130
+ # Deployment policy overlay. Personal installs remain unchanged; the
1131
+ # daemon upgrades this to True before engine initialization when the
1132
+ # enterprise deployment preset requests PII redaction.
1133
+ pii_redaction: bool = False
1134
+
1095
1135
  def __post_init__(self) -> None:
1096
1136
  if self.db_path is None:
1097
1137
  self.db_path = self.base_dir / DEFAULT_DB_NAME
@@ -1208,6 +1248,65 @@ class SLMConfig:
1208
1248
  if k in ForgettingConfig.__dataclass_fields__
1209
1249
  })
1210
1250
 
1251
+ # quantization: nested PolarQuantConfig + QJLConfig are reconstructed
1252
+ # from their serialized dicts; scalar fields are passed through directly.
1253
+ # Unknown subkeys are filtered out for forward-compat safety.
1254
+ qt_raw = data.get("quantization", {})
1255
+ if qt_raw:
1256
+ try:
1257
+ polar_raw = qt_raw.get("polar", {})
1258
+ qjl_raw = qt_raw.get("qjl", {})
1259
+ polar = PolarQuantConfig(**{
1260
+ k: v for k, v in polar_raw.items()
1261
+ if k in PolarQuantConfig.__dataclass_fields__
1262
+ })
1263
+ qjl = QJLConfig(**{
1264
+ k: v for k, v in qjl_raw.items()
1265
+ if k in QJLConfig.__dataclass_fields__
1266
+ })
1267
+ qt_scalars = {
1268
+ k: v for k, v in qt_raw.items()
1269
+ if k in QuantizationConfig.__dataclass_fields__
1270
+ and k not in ("polar", "qjl")
1271
+ }
1272
+ config.quantization = QuantizationConfig(polar=polar, qjl=qjl, **qt_scalars)
1273
+ except (TypeError, ValueError) as exc:
1274
+ logger.warning(
1275
+ "Ignoring invalid quantization config (%s) — using defaults", exc
1276
+ )
1277
+
1278
+ # sagq: valid_bit_widths is serialized as a list by dataclasses.asdict()
1279
+ # and must be coerced back to a tuple to satisfy the field annotation and
1280
+ # preserve identity through a round-trip.
1281
+ sq_raw = data.get("sagq", {})
1282
+ if sq_raw:
1283
+ try:
1284
+ sq_kwargs = {
1285
+ k: v for k, v in sq_raw.items()
1286
+ if k in SAGQConfig.__dataclass_fields__
1287
+ }
1288
+ if "valid_bit_widths" in sq_kwargs:
1289
+ sq_kwargs["valid_bit_widths"] = tuple(sq_kwargs["valid_bit_widths"])
1290
+ config.sagq = SAGQConfig(**sq_kwargs)
1291
+ except (TypeError, ValueError) as exc:
1292
+ logger.warning(
1293
+ "Ignoring invalid sagq config (%s) — using defaults", exc
1294
+ )
1295
+
1296
+ # auto_invoke: dict fields (weights, act_r_weights, mode_a_weights) are
1297
+ # preserved as-is by asdict(); no special reconstruction required.
1298
+ ai_raw = data.get("auto_invoke", {})
1299
+ if ai_raw:
1300
+ try:
1301
+ config.auto_invoke = AutoInvokeConfig(**{
1302
+ k: v for k, v in ai_raw.items()
1303
+ if k in AutoInvokeConfig.__dataclass_fields__
1304
+ })
1305
+ except (TypeError, ValueError) as exc:
1306
+ logger.warning(
1307
+ "Ignoring invalid auto_invoke config (%s) — using defaults", exc
1308
+ )
1309
+
1211
1310
  rt = data.get("retrieval", {})
1212
1311
  if rt:
1213
1312
  # V3.3.2 migration: add ONNX cross-encoder backend field.
@@ -1227,6 +1326,12 @@ class SLMConfig:
1227
1326
  # V3.4.3 config fields (additive — missing keys get dataclass defaults)
1228
1327
  config.daemon_idle_timeout = data.get("daemon_idle_timeout", 0)
1229
1328
  config.daemon_port = data.get("daemon_port", 8765)
1329
+ config.session_idle_close_hours = float(
1330
+ data.get("session_idle_close_hours", 24.0) or 24.0
1331
+ )
1332
+ config.session_idle_close_max_per_pass = int(
1333
+ data.get("session_idle_close_max_per_pass", 50) or 50
1334
+ )
1230
1335
  config.daemon_legacy_port = data.get("daemon_legacy_port", 8767)
1231
1336
  config.daemon_enable_legacy_port = data.get("daemon_enable_legacy_port", True)
1232
1337
  config.entity_compilation_enabled = data.get("entity_compilation_enabled", True)
@@ -1324,6 +1429,14 @@ class SLMConfig:
1324
1429
  "Ignoring invalid scope config (%s) — using shared-off defaults", exc
1325
1430
  )
1326
1431
 
1432
+ # Preserve the raw loaded dict so save() can return every key that was
1433
+ # present in the file, including unknown forward-compat keys and nested
1434
+ # sections whose subkeys this version does not model. The attribute is
1435
+ # intentionally private (leading underscore) and is not a declared field,
1436
+ # so dataclasses.asdict() and dataclasses.replace() ignore it — callers
1437
+ # that clone a config via replace() will lose unknown-key preservation,
1438
+ # which is acceptable: the primary contract is load→save round-trip.
1439
+ config._raw_preserved: dict = dict(data) # type: ignore[attr-defined]
1327
1440
  return config
1328
1441
 
1329
1442
  def save(
@@ -1441,17 +1554,82 @@ class SLMConfig:
1441
1554
  "enabled": self.graph_pruning.enabled,
1442
1555
  }
1443
1556
 
1444
- # Preserve existing V3.3 config sections that aren't in for_mode()
1445
- for key in ("forgetting", "quantization", "sagq", "embedding_signature", "auto_invoke"):
1446
- if key in existing:
1447
- data[key] = existing[key]
1557
+ # Daemon, entity-compilation, and mesh scalar settings were loaded by
1558
+ # load() but omitted from earlier versions of this method. Write them
1559
+ # explicitly so a load-then-save is lossless for these fields.
1560
+ data["daemon_idle_timeout"] = self.daemon_idle_timeout
1561
+ data["daemon_port"] = self.daemon_port
1562
+ data["session_idle_close_hours"] = self.session_idle_close_hours
1563
+ data["session_idle_close_max_per_pass"] = self.session_idle_close_max_per_pass
1564
+ data["daemon_legacy_port"] = self.daemon_legacy_port
1565
+ data["daemon_enable_legacy_port"] = self.daemon_enable_legacy_port
1566
+ data["entity_compilation_enabled"] = self.entity_compilation_enabled
1567
+ data["entity_compilation_retrieval_boost"] = self.entity_compilation_retrieval_boost
1568
+ data["mesh_enabled"] = self.mesh_enabled
1569
+
1570
+ # Typed in-memory config sections. Preserve any on-disk subkeys this
1571
+ # version does not model (forward-compat or externally-tuned fields such
1572
+ # as forgetting.half_life_days), then overlay the current in-memory
1573
+ # dataclass values so a programmatic mutation still survives a
1574
+ # save/load cycle while unmodeled subkeys are never dropped.
1575
+ # dataclasses.asdict() recurses into nested dataclasses
1576
+ # (quantization.polar, quantization.qjl).
1577
+ #
1578
+ # embedding_signature: has no typed in-memory model — it is an opaque blob
1579
+ # written by external tooling (see below).
1580
+ for _section, _obj in (
1581
+ ("forgetting", self.forgetting),
1582
+ ("quantization", self.quantization),
1583
+ ("sagq", self.sagq),
1584
+ ("auto_invoke", self.auto_invoke),
1585
+ ):
1586
+ _base = existing.get(_section)
1587
+ _merged = dict(_base) if isinstance(_base, dict) else {}
1588
+ _merged.update(asdict(_obj))
1589
+ data[_section] = _merged
1590
+
1591
+ # Merge any keys from the last load() that are not covered by the
1592
+ # explicit serialization above. This includes unknown forward-compat
1593
+ # top-level keys and nested sections whose subkeys this version does
1594
+ # not model (e.g. a "health" dict that carries vendor-extension fields).
1595
+ # Explicitly serialized keys always take precedence; preserved keys are
1596
+ # only written when the slot is vacant in the output dict.
1597
+ _raw = getattr(self, "_raw_preserved", None)
1598
+ if _raw:
1599
+ for _k, _v in _raw.items():
1600
+ if _k not in data:
1601
+ data[_k] = _v
1602
+
1603
+ # embedding_signature has no typed in-memory model — it is an opaque blob
1604
+ # written by external tooling. Preserve the on-disk value verbatim so a
1605
+ # load→save cycle does not erase externally written metadata.
1606
+ if "embedding_signature" in existing:
1607
+ data["embedding_signature"] = existing["embedding_signature"]
1448
1608
 
1449
1609
  # Atomic write: a crash mid-write must NOT leave a truncated/corrupt
1450
1610
  # config.json (which would make every subsequent `slm` call fail to load).
1451
1611
  import os as _os
1452
- _tmp = path.with_suffix(path.suffix + ".tmp")
1453
- _tmp.write_text(json.dumps(data, indent=2))
1454
- _os.replace(_tmp, path)
1612
+ import tempfile as _tempfile
1613
+
1614
+ _fd, _tmp_name = _tempfile.mkstemp(
1615
+ prefix=f".{path.name}.", suffix=".tmp", dir=path.parent,
1616
+ )
1617
+ _tmp = Path(_tmp_name)
1618
+ try:
1619
+ with _os.fdopen(_fd, "w", encoding="utf-8") as _handle:
1620
+ json.dump(data, _handle, indent=2)
1621
+ _handle.write("\n")
1622
+ _handle.flush()
1623
+ _os.fsync(_handle.fileno())
1624
+ _os.chmod(_tmp, 0o600)
1625
+ _os.replace(_tmp, path)
1626
+ _os.chmod(path, 0o600)
1627
+ except BaseException:
1628
+ try:
1629
+ _tmp.unlink(missing_ok=True)
1630
+ except OSError:
1631
+ pass
1632
+ raise
1455
1633
 
1456
1634
  @staticmethod
1457
1635
  def provider_presets() -> dict[str, dict[str, str]]:
@@ -132,13 +132,13 @@ class ConsolidationEngine:
132
132
  ccq_worker: CCQWorker | None = None,
133
133
  ) -> None:
134
134
  self._db = db
135
- self._config = config
135
+ self._consolidation_config = config
136
136
  self._summarizer = summarizer
137
137
  self._behavioral = behavioral_store
138
138
  self._auto_linker = auto_linker
139
139
  self._graph_analyzer = graph_analyzer
140
140
  self._temporal_validator = temporal_validator
141
- self._slm_config = slm_config
141
+ self._config = slm_config
142
142
  self._ccq_worker = ccq_worker
143
143
  self._mode = slm_config.mode.value if slm_config else "a"
144
144
  self._store_count: int = 0 # For step-count trigger (L7)
@@ -213,7 +213,7 @@ class ConsolidationEngine:
213
213
  def mine(self, *a, **kw): return []
214
214
  from superlocalmemory.hooks.auto_parameterize import AutoParameterizeHook
215
215
  from superlocalmemory.core.config import ParameterizationConfig
216
- p_config = getattr(self._slm_config, "parameterization", ParameterizationConfig())
216
+ p_config = getattr(self._config, "parameterization", ParameterizationConfig())
217
217
  from superlocalmemory.infra.data_root import state_path
218
218
  learning_db = str(state_path("learning.db"))
219
219
  beh_store = BehavioralPatternStore(learning_db)
@@ -246,7 +246,7 @@ class ConsolidationEngine:
246
246
  # Never on recall/remember hot path. Budget: max 3 per cycle.
247
247
  try:
248
248
  from superlocalmemory.evolution.skill_evolver import SkillEvolver
249
- evolver = SkillEvolver(self._db.db_path)
249
+ evolver = SkillEvolver(self._db.db_path, self._config)
250
250
  results["skill_evolution"] = evolver.run_consolidation_cycle(profile_id)
251
251
  except Exception as exc:
252
252
  logger.debug("Skill evolution (non-fatal): %s", exc)
@@ -286,11 +286,11 @@ class ConsolidationEngine:
286
286
 
287
287
  Returns True if lightweight consolidation was triggered.
288
288
  """
289
- if not self._config.enabled:
289
+ if not self._consolidation_config.enabled:
290
290
  return False
291
291
 
292
292
  self._store_count += 1
293
- if self._store_count >= self._config.step_count_trigger:
293
+ if self._store_count >= self._consolidation_config.step_count_trigger:
294
294
  self._store_count = 0
295
295
  self.consolidate(profile_id, lightweight=True)
296
296
  # V3.4.2: Queue graph analysis in background (non-blocking)
@@ -388,7 +388,7 @@ class ConsolidationEngine:
388
388
  - learned_preferences: opinion facts with confidence >= threshold
389
389
  """
390
390
  blocks_compiled = 0
391
- block_limit = self._config.block_char_limit
391
+ block_limit = self._consolidation_config.block_char_limit
392
392
 
393
393
  # 1. user_profile: top semantic/opinion facts by access
394
394
  user_facts = self._get_top_facts(
@@ -439,7 +439,7 @@ class ConsolidationEngine:
439
439
  "GROUP BY f.fact_id "
440
440
  "HAVING COUNT(a.log_id) >= ? "
441
441
  "ORDER BY COUNT(a.log_id) DESC LIMIT 5",
442
- (profile_id, self._config.promotion_min_access),
442
+ (profile_id, self._consolidation_config.promotion_min_access),
443
443
  )
444
444
  self._store_core_block(
445
445
  profile_id, "active_decisions",
@@ -454,7 +454,7 @@ class ConsolidationEngine:
454
454
  "WHERE profile_id = ? AND fact_type = 'opinion' "
455
455
  "AND confidence >= ? AND lifecycle = 'active' "
456
456
  "ORDER BY confidence DESC LIMIT 5",
457
- (profile_id, self._config.promotion_min_trust),
457
+ (profile_id, self._consolidation_config.promotion_min_trust),
458
458
  )
459
459
  self._store_core_block(
460
460
  profile_id, "learned_preferences",
@@ -491,7 +491,7 @@ class ConsolidationEngine:
491
491
  summary = self._summarizer.summarize_cluster(fact_dicts)
492
492
  self._store_core_block(
493
493
  profile_id, block_type,
494
- summary[:self._config.block_char_limit],
494
+ summary[:self._consolidation_config.block_char_limit],
495
495
  [f["fact_id"] for f in facts],
496
496
  compiled_by="llm",
497
497
  )
@@ -524,7 +524,7 @@ class ConsolidationEngine:
524
524
  "WHERE f.profile_id = ? AND f.lifecycle = 'active' "
525
525
  "GROUP BY f.fact_id "
526
526
  "HAVING COUNT(a.log_id) >= ?",
527
- (profile_id, self._config.promotion_min_access),
527
+ (profile_id, self._consolidation_config.promotion_min_access),
528
528
  )
529
529
 
530
530
  promoted = 0
@@ -537,7 +537,7 @@ class ConsolidationEngine:
537
537
  continue
538
538
 
539
539
  # Trust check
540
- if d.get("confidence", 0) < self._config.promotion_min_trust:
540
+ if d.get("confidence", 0) < self._consolidation_config.promotion_min_trust:
541
541
  continue
542
542
 
543
543
  # Promote: active -> warm (lifecycle transition)
@@ -606,7 +606,7 @@ class ConsolidationEngine:
606
606
  return {"decayed": 0}
607
607
  try:
608
608
  decayed = self._auto_linker.decay_unused(
609
- profile_id, days_threshold=self._config.decay_days_threshold,
609
+ profile_id, days_threshold=self._consolidation_config.decay_days_threshold,
610
610
  )
611
611
  return {"decayed": decayed}
612
612
  except Exception as exc:
@@ -478,6 +478,33 @@ def read_entry_fast(
478
478
  return None
479
479
 
480
480
 
481
+ def purge_profile_from_cache_db(db_path: Path, profile_id: str) -> int:
482
+ """Delete all ``context_entries`` rows for *profile_id* from a cache DB file.
483
+
484
+ Opens the file with a direct write connection (no URI, no read-only flag)
485
+ so it works even when the daemon is not running. Returns the number of rows
486
+ deleted. Never raises — any error returns 0 so callers can remain
487
+ fail-open.
488
+ """
489
+ try:
490
+ if not Path(db_path).exists():
491
+ return 0
492
+ conn = sqlite3.connect(str(db_path), isolation_level=None, timeout=2.0)
493
+ try:
494
+ cur = conn.execute(
495
+ "DELETE FROM context_entries WHERE profile_id = ?",
496
+ (profile_id,),
497
+ )
498
+ return cur.rowcount
499
+ finally:
500
+ try:
501
+ conn.close()
502
+ except sqlite3.Error:
503
+ pass
504
+ except Exception:
505
+ return 0
506
+
507
+
481
508
  __all__ = (
482
509
  "CACHE_DB_DEFAULT",
483
510
  "CLEANUP_HORIZON_SECONDS",
@@ -487,5 +514,6 @@ __all__ = (
487
514
  "MAX_CONTENT_CHARS",
488
515
  "SCHEMA_VERSION",
489
516
  "TTL_SECONDS",
517
+ "purge_profile_from_cache_db",
490
518
  "read_entry_fast",
491
519
  )
@@ -543,7 +543,47 @@ class EmbeddingService:
543
543
 
544
544
  @staticmethod
545
545
  def _readline_with_timeout(stream, timeout_seconds: float) -> str:
546
- """Read a line from stream with a timeout. Returns '' on timeout."""
546
+ """Read a line from stream with a timeout. Returns '' on timeout.
547
+
548
+ Prefer a deadline-driven selector poll of the stream's file descriptor
549
+ (POSIX pipes). That path never spawns a helper thread, so a hung
550
+ embedding worker cannot leak reader threads or pin the pipe FD across
551
+ timeouts. A thread fallback remains only for streams without a usable
552
+ fileno (unit-test mocks) and for Windows, where selectors cannot wait
553
+ on pipes.
554
+ """
555
+ import selectors
556
+
557
+ timeout_seconds = max(0.0, float(timeout_seconds))
558
+ fd: int | None
559
+ try:
560
+ raw_fd = stream.fileno()
561
+ fd = raw_fd if isinstance(raw_fd, int) else None
562
+ except (AttributeError, OSError, ValueError, TypeError):
563
+ fd = None
564
+
565
+ # Windows select()/selectors only accept sockets, not subprocess pipes.
566
+ if fd is not None and sys.platform != "win32":
567
+ try:
568
+ with selectors.DefaultSelector() as sel:
569
+ sel.register(fd, selectors.EVENT_READ)
570
+ events = sel.select(timeout=timeout_seconds)
571
+ if not events:
572
+ logger.warning(
573
+ "Embedding worker did not respond within %ds",
574
+ timeout_seconds,
575
+ )
576
+ return ""
577
+ # Readable or EOF. Protocol is one JSON line per response;
578
+ # the worker writes a complete line before we are woken.
579
+ line = stream.readline()
580
+ return line if line else ""
581
+ except (OSError, ValueError) as exc:
582
+ # Closed/invalid FD mid-wait — same as empty to the caller
583
+ # (which kills and may respawn the worker).
584
+ logger.debug("Embedding readline selector failed: %s", exc)
585
+ return ""
586
+
547
587
  result_container: list[str] = []
548
588
  error_container: list[Exception] = []
549
589
 
@@ -553,7 +593,10 @@ class EmbeddingService:
553
593
  except Exception as exc:
554
594
  error_container.append(exc)
555
595
 
556
- reader = threading.Thread(target=_read, daemon=True)
596
+ # Name contains ``_read`` so leak detectors can find abandoned readers.
597
+ reader = threading.Thread(
598
+ target=_read, daemon=True, name="slm_embed_readline_read",
599
+ )
557
600
  reader.start()
558
601
  reader.join(timeout=timeout_seconds)
559
602
 
@@ -561,6 +604,25 @@ class EmbeddingService:
561
604
  logger.warning(
562
605
  "Embedding worker did not respond within %ds", timeout_seconds,
563
606
  )
607
+ # Close/shutdown the stream so the blocked readline() returns and
608
+ # the reader thread can exit. Raising alone would leak the thread
609
+ # (and its FD) on Windows pipes and fileno-less mocks.
610
+ for closer_name in ("close", "shutdown"):
611
+ closer = getattr(stream, closer_name, None)
612
+ if not callable(closer):
613
+ continue
614
+ try:
615
+ if closer_name == "shutdown":
616
+ try:
617
+ closer(True) # type: ignore[misc]
618
+ except TypeError:
619
+ closer()
620
+ else:
621
+ closer()
622
+ except Exception:
623
+ pass
624
+ # Bound the join so a stuck closer cannot hang the caller forever.
625
+ reader.join(timeout=min(1.0, max(0.05, timeout_seconds)))
564
626
  return ""
565
627
  if error_container:
566
628
  raise error_container[0]
@@ -304,6 +304,11 @@ class MemoryEngine:
304
304
 
305
305
  self._embedder = init_embedder(self._config)
306
306
 
307
+ # Rebuild the complete vector projection before VectorStore opens it.
308
+ # This preserves the previous vec0 table until the shadow activation
309
+ # succeeds, including across embedding-dimension changes.
310
+ self._check_embedding_migration()
311
+
307
312
  if self._caps.llm_fact_extraction:
308
313
  self._llm = LLMBackbone(self._config.llm)
309
314
  if not self._llm.is_available():
@@ -394,8 +399,6 @@ class MemoryEngine:
394
399
  llm=getattr(self, "_llm", None), # v3.4.7: for CCQ worker
395
400
  )
396
401
 
397
- self._check_embedding_migration()
398
-
399
402
  # Lifecycle/tier evaluation, bounded housekeeping, and backup checks
400
403
  # must continue even when optional forgetting/math maintenance is
401
404
  # disabled. The scheduler itself gates those optional calculations.
@@ -703,6 +706,7 @@ class MemoryEngine:
703
706
  include_global: bool | None = None,
704
707
  include_shared: bool | None = None,
705
708
  window: str | tuple[str, str] | None = None,
709
+ as_of: str | None = None,
706
710
  ) -> RecallResponse:
707
711
  """Recall relevant facts for a query.
708
712
 
@@ -758,6 +762,7 @@ class MemoryEngine:
758
762
  include_global=include_global,
759
763
  include_shared=include_shared,
760
764
  window=window,
765
+ as_of=as_of,
761
766
  )
762
767
  except Exception:
763
768
  # Diagnostics are intentionally not recorded here. A recall is a