superlocalmemory 3.7.7 → 3.8.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 (262) hide show
  1. package/.claude-plugin/marketplace.json +1 -1
  2. package/ATTRIBUTION.md +1 -3
  3. package/CHANGELOG.md +85 -0
  4. package/README.md +199 -29
  5. package/package.json +4 -2
  6. package/plugin/.claude-plugin/plugin.json +2 -2
  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/agents/slm-governance-advisor.md +80 -0
  30. package/plugin-src/agents/slm-loop-runner.md +71 -0
  31. package/plugin-src/agents/slm-memory-advisor.md +10 -5
  32. package/plugin-src/agents/slm-optimize-advisor.md +9 -3
  33. package/plugin-src/commands/slm-loop.md +31 -0
  34. package/plugin-src/hooks/hooks.json +79 -0
  35. package/plugin-src/manifest.json +7 -2
  36. package/plugin-src/requirements.txt +1 -1
  37. package/plugin-src/rules/AGENTS.md +57 -18
  38. package/plugin-src/rules/CLAUDE.md.fragment +8 -8
  39. package/plugin-src/scripts/slm-launch +46 -7
  40. package/plugin-src/settings.json +9 -0
  41. package/plugin-src/skills/slm-cache/SKILL.md +9 -1
  42. package/plugin-src/skills/slm-compress/SKILL.md +8 -1
  43. package/plugin-src/skills/slm-governance/SKILL.md +248 -0
  44. package/plugin-src/skills/slm-graph/SKILL.md +17 -3
  45. package/plugin-src/skills/slm-loop/SKILL.md +99 -0
  46. package/plugin-src/skills/slm-mesh/SKILL.md +282 -0
  47. package/plugin-src/skills/slm-profile/SKILL.md +148 -0
  48. package/plugin-src/skills/slm-recall/SKILL.md +46 -10
  49. package/plugin-src/skills/slm-remember/SKILL.md +48 -1
  50. package/plugin-src/skills/slm-scope/SKILL.md +176 -0
  51. package/plugin-src/skills/slm-session/SKILL.md +24 -1
  52. package/plugin-src/skills/slm-status/SKILL.md +18 -1
  53. package/pyproject.toml +1 -2
  54. package/scripts/postinstall/validation.js +2 -0
  55. package/scripts/postinstall-interactive.js +74 -2
  56. package/src/superlocalmemory/__init__.py +1 -1
  57. package/src/superlocalmemory/access/__init__.py +3 -0
  58. package/src/superlocalmemory/access/rbac.py +477 -0
  59. package/src/superlocalmemory/cli/commands.py +96 -12
  60. package/src/superlocalmemory/cli/compress_cmd.py +17 -7
  61. package/src/superlocalmemory/cli/loop_cmd.py +192 -0
  62. package/src/superlocalmemory/cli/main.py +39 -4
  63. package/src/superlocalmemory/cli/mesh_cmd.py +38 -0
  64. package/src/superlocalmemory/cli/optimize_cmd.py +3 -0
  65. package/src/superlocalmemory/cli/pending_store.py +49 -13
  66. package/src/superlocalmemory/cli/proxy_cmd.py +4 -0
  67. package/src/superlocalmemory/cli/scale_engine_cmd.py +6 -0
  68. package/src/superlocalmemory/cli/setup_wizard.py +22 -13
  69. package/src/superlocalmemory/compliance/audit.py +6 -0
  70. package/src/superlocalmemory/compliance/gdpr.py +128 -138
  71. package/src/superlocalmemory/compliance/retention.py +176 -45
  72. package/src/superlocalmemory/core/backend_orchestrator.py +5 -43
  73. package/src/superlocalmemory/core/community_summary.py +267 -0
  74. package/src/superlocalmemory/core/config.py +216 -3
  75. package/src/superlocalmemory/core/consolidation_engine.py +95 -22
  76. package/src/superlocalmemory/core/context_cache.py +61 -18
  77. package/src/superlocalmemory/core/embedding_worker.py +17 -2
  78. package/src/superlocalmemory/core/embeddings.py +12 -1
  79. package/src/superlocalmemory/core/engine.py +17 -1
  80. package/src/superlocalmemory/core/engine_ingestion.py +29 -0
  81. package/src/superlocalmemory/core/engine_wiring.py +13 -0
  82. package/src/superlocalmemory/core/entity_community.py +178 -0
  83. package/src/superlocalmemory/core/graph_analyzer.py +39 -2
  84. package/src/superlocalmemory/core/graph_pruner.py +13 -8
  85. package/src/superlocalmemory/core/key_expander.py +138 -0
  86. package/src/superlocalmemory/core/maintenance.py +23 -0
  87. package/src/superlocalmemory/core/modes.py +1 -1
  88. package/src/superlocalmemory/core/mutations.py +2 -2
  89. package/src/superlocalmemory/core/pii.py +105 -0
  90. package/src/superlocalmemory/core/progressive_abstraction.py +208 -0
  91. package/src/superlocalmemory/core/recall_pipeline.py +2 -0
  92. package/src/superlocalmemory/core/recall_worker.py +20 -6
  93. package/src/superlocalmemory/core/scale_engine.py +60 -1
  94. package/src/superlocalmemory/core/security_primitives.py +40 -2
  95. package/src/superlocalmemory/core/store_pipeline.py +35 -11
  96. package/src/superlocalmemory/core/worker_pool.py +21 -6
  97. package/src/superlocalmemory/encoding/entity_reflexion.py +200 -0
  98. package/src/superlocalmemory/encoding/entity_resolver.py +34 -24
  99. package/src/superlocalmemory/encoding/fact_extractor.py +26 -1
  100. package/src/superlocalmemory/encoding/temporal_validator.py +64 -1
  101. package/src/superlocalmemory/evolution/evolution_store.py +122 -45
  102. package/src/superlocalmemory/evolution/llm_dispatch.py +12 -1
  103. package/src/superlocalmemory/evolution/model_selection.py +160 -0
  104. package/src/superlocalmemory/evolution/mutation_generator.py +16 -0
  105. package/src/superlocalmemory/evolution/skill_evolver.py +127 -42
  106. package/src/superlocalmemory/evolution/triggers.py +22 -13
  107. package/src/superlocalmemory/graph/cozo_backend.py +43 -20
  108. package/src/superlocalmemory/hooks/adapter_base.py +5 -1
  109. package/src/superlocalmemory/hooks/auto_recall.py +13 -1
  110. package/src/superlocalmemory/hooks/claude_code_hooks.py +11 -0
  111. package/src/superlocalmemory/hooks/codex_assets.py +64 -5
  112. package/src/superlocalmemory/hooks/hook_daemon.py +20 -3
  113. package/src/superlocalmemory/hooks/memory_protocol.py +54 -0
  114. package/src/superlocalmemory/hooks/portable_kit.py +114 -1
  115. package/src/superlocalmemory/infra/auth_middleware.py +28 -0
  116. package/src/superlocalmemory/infra/backup.py +12 -1
  117. package/src/superlocalmemory/infra/daemon_identity.py +40 -4
  118. package/src/superlocalmemory/infra/data_root.py +43 -4
  119. package/src/superlocalmemory/infra/event_bus.py +107 -24
  120. package/src/superlocalmemory/infra/rate_limiter.py +93 -0
  121. package/src/superlocalmemory/ingestion/adapter_manager.py +4 -1
  122. package/src/superlocalmemory/ingestion/credentials.py +1 -1
  123. package/src/superlocalmemory/learning/cross_project.py +28 -19
  124. package/src/superlocalmemory/learning/reward_proxy.py +42 -9
  125. package/src/superlocalmemory/loops/__init__.py +56 -0
  126. package/src/superlocalmemory/loops/budget.py +58 -0
  127. package/src/superlocalmemory/loops/engine.py +164 -0
  128. package/src/superlocalmemory/loops/ledger.py +243 -0
  129. package/src/superlocalmemory/loops/models.py +152 -0
  130. package/src/superlocalmemory/loops/rules.py +52 -0
  131. package/src/superlocalmemory/mcp/_daemon_proxy.py +3 -0
  132. package/src/superlocalmemory/mcp/_pool_adapter.py +2 -0
  133. package/src/superlocalmemory/mcp/profiles.py +103 -0
  134. package/src/superlocalmemory/mcp/server.py +21 -49
  135. package/src/superlocalmemory/mcp/tools_active.py +4 -7
  136. package/src/superlocalmemory/mcp/tools_code_graph.py +51 -5
  137. package/src/superlocalmemory/mcp/tools_core.py +50 -5
  138. package/src/superlocalmemory/mcp/tools_evolution.py +6 -3
  139. package/src/superlocalmemory/mcp/tools_loops.py +300 -0
  140. package/src/superlocalmemory/mcp/tools_mesh.py +140 -4
  141. package/src/superlocalmemory/mcp/tools_optimize.py +15 -8
  142. package/src/superlocalmemory/mesh/broker.py +237 -129
  143. package/src/superlocalmemory/mesh/remote_sync.py +50 -8
  144. package/src/superlocalmemory/optimize/NOTICE +1 -6
  145. package/src/superlocalmemory/optimize/adapters/anthropic_adapter.py +1 -4
  146. package/src/superlocalmemory/optimize/adapters/openai_adapter.py +1 -4
  147. package/src/superlocalmemory/optimize/cache/semantic.py +27 -19
  148. package/src/superlocalmemory/optimize/compress/align.py +32 -26
  149. package/src/superlocalmemory/optimize/compress/ccr.py +14 -71
  150. package/src/superlocalmemory/optimize/compress/router.py +105 -22
  151. package/src/superlocalmemory/optimize/config/defaults.py +1 -1
  152. package/src/superlocalmemory/optimize/config/schema.py +87 -4
  153. package/src/superlocalmemory/optimize/metrics/counters.py +13 -4
  154. package/src/superlocalmemory/optimize/metrics/estimator.py +0 -3
  155. package/src/superlocalmemory/optimize/proxy/_helpers.py +31 -4
  156. package/src/superlocalmemory/optimize/storage/db.py +38 -9
  157. package/src/superlocalmemory/optimize/storage/schema.py +10 -0
  158. package/src/superlocalmemory/parameterization/pattern_extractor.py +6 -3
  159. package/src/superlocalmemory/retrieval/agentic.py +1 -1
  160. package/src/superlocalmemory/retrieval/bm25_channel.py +68 -10
  161. package/src/superlocalmemory/retrieval/engine.py +168 -26
  162. package/src/superlocalmemory/retrieval/entity_channel.py +7 -5
  163. package/src/superlocalmemory/retrieval/hopfield_channel.py +9 -2
  164. package/src/superlocalmemory/retrieval/semantic_channel.py +114 -21
  165. package/src/superlocalmemory/retrieval/spreading_activation.py +11 -2
  166. package/src/superlocalmemory/retrieval/temporal_channel.py +48 -9
  167. package/src/superlocalmemory/retrieval/temporal_frame.py +102 -0
  168. package/src/superlocalmemory/retrieval/temporal_validity_filter.py +135 -0
  169. package/src/superlocalmemory/retrieval/time_window.py +181 -0
  170. package/src/superlocalmemory/server/api.py +21 -4
  171. package/src/superlocalmemory/server/profile_runtime.py +125 -8
  172. package/src/superlocalmemory/server/rbac_enforce.py +142 -0
  173. package/src/superlocalmemory/server/recall_health.py +24 -3
  174. package/src/superlocalmemory/server/recall_serializer.py +19 -1
  175. package/src/superlocalmemory/server/routes/abstraction.py +115 -0
  176. package/src/superlocalmemory/server/routes/agents.py +128 -38
  177. package/src/superlocalmemory/server/routes/backup.py +34 -10
  178. package/src/superlocalmemory/server/routes/behavioral.py +13 -12
  179. package/src/superlocalmemory/server/routes/brain.py +21 -5
  180. package/src/superlocalmemory/server/routes/chat.py +72 -16
  181. package/src/superlocalmemory/server/routes/compliance.py +171 -21
  182. package/src/superlocalmemory/server/routes/config_api.py +436 -0
  183. package/src/superlocalmemory/server/routes/data_io.py +30 -8
  184. package/src/superlocalmemory/server/routes/entity.py +9 -4
  185. package/src/superlocalmemory/server/routes/events.py +24 -8
  186. package/src/superlocalmemory/server/routes/evolution.py +135 -17
  187. package/src/superlocalmemory/server/routes/helpers.py +16 -1
  188. package/src/superlocalmemory/server/routes/ingest.py +7 -4
  189. package/src/superlocalmemory/server/routes/insights.py +3 -3
  190. package/src/superlocalmemory/server/routes/learning.py +14 -14
  191. package/src/superlocalmemory/server/routes/lifecycle.py +59 -8
  192. package/src/superlocalmemory/server/routes/memories.py +221 -49
  193. package/src/superlocalmemory/server/routes/mesh.py +95 -15
  194. package/src/superlocalmemory/server/routes/optimize.py +33 -1
  195. package/src/superlocalmemory/server/routes/prewarm.py +2 -0
  196. package/src/superlocalmemory/server/routes/profiles.py +63 -17
  197. package/src/superlocalmemory/server/routes/ratelimit.py +124 -0
  198. package/src/superlocalmemory/server/routes/rbac.py +367 -0
  199. package/src/superlocalmemory/server/routes/stats.py +13 -6
  200. package/src/superlocalmemory/server/routes/tiers.py +11 -9
  201. package/src/superlocalmemory/server/routes/v3_api.py +194 -81
  202. package/src/superlocalmemory/server/routes/ws.py +5 -2
  203. package/src/superlocalmemory/server/security_middleware.py +12 -5
  204. package/src/superlocalmemory/server/ui.py +30 -5
  205. package/src/superlocalmemory/server/unified_daemon.py +431 -75
  206. package/src/superlocalmemory/server/write_identity.py +38 -8
  207. package/src/superlocalmemory/storage/database.py +265 -53
  208. package/src/superlocalmemory/storage/migration_runner.py +53 -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/models.py +4 -0
  217. package/src/superlocalmemory/storage/schema.py +87 -0
  218. package/src/superlocalmemory/storage/schema_v32.py +0 -9
  219. package/src/superlocalmemory/storage/schema_v343.py +24 -12
  220. package/src/superlocalmemory/trust/gate.py +49 -8
  221. package/src/superlocalmemory/ui/assets/slm-icon-white.svg +64 -0
  222. package/src/superlocalmemory/ui/assets/slm-icon.svg +36 -0
  223. package/src/superlocalmemory/ui/css/design-system.css +621 -0
  224. package/src/superlocalmemory/ui/css/neural-glass.css +6 -0
  225. package/src/superlocalmemory/ui/css/od-bridge.css +158 -0
  226. package/src/superlocalmemory/ui/favicon.svg +35 -4
  227. package/src/superlocalmemory/ui/index.html +306 -173
  228. package/src/superlocalmemory/ui/js/brain.js +5 -20
  229. package/src/superlocalmemory/ui/js/core.js +47 -31
  230. package/src/superlocalmemory/ui/js/dashboard.js +314 -63
  231. package/src/superlocalmemory/ui/js/event-delegation.js +102 -0
  232. package/src/superlocalmemory/ui/js/knowledge-graph.js +11 -11
  233. package/src/superlocalmemory/ui/js/math-health.js +1 -1
  234. package/src/superlocalmemory/ui/js/memories.js +15 -4
  235. package/src/superlocalmemory/ui/js/memory-chat.js +7 -7
  236. package/src/superlocalmemory/ui/js/ng-entities.js +6 -8
  237. package/src/superlocalmemory/ui/js/ng-ingestion.js +4 -4
  238. package/src/superlocalmemory/ui/js/ng-mesh.js +4 -9
  239. package/src/superlocalmemory/ui/js/ng-shell.js +8 -8
  240. package/src/superlocalmemory/ui/js/ng-skills.js +54 -2
  241. package/src/superlocalmemory/ui/js/od-agents.js +544 -0
  242. package/src/superlocalmemory/ui/js/od-auth-gate.js +257 -0
  243. package/src/superlocalmemory/ui/js/od-backup.js +780 -0
  244. package/src/superlocalmemory/ui/js/od-brain.js +779 -0
  245. package/src/superlocalmemory/ui/js/od-entities.js +579 -0
  246. package/src/superlocalmemory/ui/js/od-graph.js +593 -0
  247. package/src/superlocalmemory/ui/js/od-health.js +539 -0
  248. package/src/superlocalmemory/ui/js/od-mcp.js +508 -0
  249. package/src/superlocalmemory/ui/js/od-memories.js +887 -0
  250. package/src/superlocalmemory/ui/js/od-mesh.js +539 -0
  251. package/src/superlocalmemory/ui/js/od-operations.js +1250 -0
  252. package/src/superlocalmemory/ui/js/od-optimize.js +787 -0
  253. package/src/superlocalmemory/ui/js/od-settings.js +1053 -0
  254. package/src/superlocalmemory/ui/js/od-shell.js +593 -0
  255. package/src/superlocalmemory/ui/js/od-skills.js +573 -0
  256. package/src/superlocalmemory/ui/js/od-team.js +258 -0
  257. package/src/superlocalmemory/ui/js/profiles.js +159 -46
  258. package/src/superlocalmemory/ui/js/settings.js +2 -2
  259. package/src/superlocalmemory/ui/js/timeline.js +34 -5
  260. package/src/superlocalmemory/ui/js/trust-dashboard.js +2 -2
  261. package/src/superlocalmemory/vector/lancedb_backend.py +8 -6
  262. package/src/superlocalmemory/learning/behavioral_listener.py +0 -94
@@ -236,6 +236,11 @@ class EncodingConfig:
236
236
  # Entropy gate
237
237
  entropy_threshold: float = 0.95
238
238
 
239
+ # Entity reflexion (Wave Q1) — Mode B/C self-review of extracted entities.
240
+ # Fail-open; one extra bounded LLM call per chunk. Mode A is unaffected.
241
+ enable_entity_reflexion: bool = True
242
+ reflexion_max_facts: int = 8
243
+
239
244
 
240
245
  # ---------------------------------------------------------------------------
241
246
  # Retrieval Config
@@ -297,6 +302,11 @@ class RetrievalConfig:
297
302
  recall_per_fact_max_chars: int = 2400 # ~600 tokens; head 70% + tail 30%
298
303
  recall_total_max_chars: int = 12000 # ~3K tokens; stubs beyond this
299
304
 
305
+ # Wave Q2b: attach a precomputed community summary as thematic context when
306
+ # the top results cluster in one community. Read-only lookup, gated, and
307
+ # fail-open — never a per-query LLM call. Kill-switch for tuning.
308
+ enable_community_context: bool = True
309
+
300
310
 
301
311
  # ---------------------------------------------------------------------------
302
312
  # Math Config
@@ -659,6 +669,14 @@ class TemporalValidatorConfig:
659
669
  # Sheaf contradiction threshold
660
670
  contradiction_threshold: float = 0.45 # Mode A threshold (768d)
661
671
 
672
+ # P5-INT-01: superseded facts are DEMOTED in recall, not hidden. A fact
673
+ # marked system_expired_at keeps its channel evidence but its per-channel
674
+ # score is multiplied by this factor, so current facts rank above it while
675
+ # nothing valid silently vanishes (Mem0-2026 non-destructive design;
676
+ # retrieval-time recency resolves conflicts). 0.0 restores the old hide
677
+ # behaviour; 1.0 disables demotion.
678
+ superseded_demotion_factor: float = 0.25
679
+
662
680
  # LLM pre-filter threshold (lower to catch more candidates)
663
681
  llm_prefilter_threshold: float = 0.30
664
682
 
@@ -677,18 +695,31 @@ class EvolutionConfig:
677
695
  """Configuration for Skill Evolution Engine (v3.4.10).
678
696
 
679
697
  OFF by default — opt in via `slm setup` (interactive) or
680
- `slm config set evolution.enabled true` (CLI).
698
+ `slm config set evolution.enabled true` (CLI). Enabling makes
699
+ background LLM calls; the enable flow surfaces a cost advisory.
681
700
 
682
701
  Backend auto-detection priority:
683
702
  1. `claude` CLI available → spawn `claude --model haiku` (ECC pattern, free)
684
703
  2. Ollama running → use Ollama (free, local)
685
704
  3. API key set → use Anthropic/OpenAI API (paid)
686
705
  4. Nothing → dashboard-only (show candidates, manual evolution)
706
+
707
+ Model selection (v3.7.9): each pipeline step is configurable and
708
+ defaults to the lowest-cost capable model for the active backend
709
+ (Claude→haiku, Ollama→local). Leave a field empty ("") for "auto".
710
+ The blind verifier is kept on a *different* model from the generator
711
+ so it can't grade its own homework; see
712
+ ``evolution.model_selection.resolve_evolution_models``.
687
713
  """
688
714
 
689
715
  enabled: bool = False # OFF by default, opt-in
690
716
  backend: str = "auto" # auto, claude, ollama, anthropic, openai
691
717
  max_evolutions_per_cycle: int = 3 # Budget cap per consolidation
718
+ # Empty string == "auto" (resolve cheapest capable model at runtime).
719
+ # Accepts short aliases ("haiku"/"sonnet") or allow-listed model ids.
720
+ mutation_model: str = "" # generator (quality-sensitive)
721
+ verify_model: str = "" # blind verifier (must differ from generator)
722
+ confirm_model: str = "" # cheap yes/no gate
692
723
 
693
724
 
694
725
  @dataclass(frozen=True)
@@ -743,6 +774,152 @@ class AutoInvokeConfig:
743
774
  relevance_threshold: float = 0.3 # Legacy compat with AutoRecall
744
775
 
745
776
 
777
+ # ---------------------------------------------------------------------------
778
+ # Deployment Config (v3.8.0)
779
+ # ---------------------------------------------------------------------------
780
+
781
+ _VALID_DEPLOYMENT_MODES = ("personal", "enterprise")
782
+
783
+
784
+ @dataclass(frozen=True)
785
+ class DeploymentConfig:
786
+ """Deployment mode configuration.
787
+
788
+ Personal (default, safe defaults):
789
+ Single-user install. No login required, no PII redaction, no
790
+ retention scheduler. Behaviour is identical to pre-3.8.0 installs.
791
+
792
+ Enterprise:
793
+ Multi-user / team / company install. Login required, PII redacted,
794
+ retention scheduler active, audit enabled.
795
+
796
+ SAFE DEFAULTS = Personal — an existing config.toml with no [deployment]
797
+ section continues to behave exactly as before. Adding a [deployment]
798
+ section is opt-in; removing it reverts to Personal automatically.
799
+ """
800
+
801
+ mode: str = "personal" # "personal" | "enterprise"
802
+ require_login: bool = False
803
+ pii_redaction: bool = False
804
+ retention_enabled: bool = False
805
+ audit: bool = True
806
+
807
+ def __post_init__(self) -> None:
808
+ if self.mode not in _VALID_DEPLOYMENT_MODES:
809
+ raise ValueError(
810
+ f"DeploymentConfig.mode must be one of {_VALID_DEPLOYMENT_MODES!r}, "
811
+ f"got {self.mode!r}"
812
+ )
813
+
814
+ @property
815
+ def is_personal(self) -> bool:
816
+ """True when operating in personal (single-user) mode."""
817
+ return self.mode == "personal"
818
+
819
+ @property
820
+ def is_enterprise(self) -> bool:
821
+ """True when operating in enterprise (team/company) mode."""
822
+ return self.mode == "enterprise"
823
+
824
+ def as_dict(self) -> dict:
825
+ return {
826
+ "mode": self.mode,
827
+ "require_login": self.require_login,
828
+ "pii_redaction": self.pii_redaction,
829
+ "retention_enabled": self.retention_enabled,
830
+ "audit": self.audit,
831
+ }
832
+
833
+
834
+ #: Canonical Personal preset — all permissive defaults.
835
+ DEPLOYMENT_PERSONAL = DeploymentConfig(
836
+ mode="personal",
837
+ require_login=False,
838
+ pii_redaction=False,
839
+ retention_enabled=False,
840
+ audit=True,
841
+ )
842
+
843
+ #: Canonical Enterprise preset — all enforcement defaults.
844
+ DEPLOYMENT_ENTERPRISE = DeploymentConfig(
845
+ mode="enterprise",
846
+ require_login=True,
847
+ pii_redaction=True,
848
+ retention_enabled=True,
849
+ audit=True,
850
+ )
851
+
852
+
853
+ def load_deployment_config(
854
+ config_toml_path: Path | None = None,
855
+ ) -> DeploymentConfig:
856
+ """Parse [deployment] from config.toml; return Personal defaults if absent.
857
+
858
+ config.toml is the installer-written performance config (separate from the
859
+ daemon's config.json managed by SLMConfig). This function reads ONLY the
860
+ [deployment] section. All other sections are ignored.
861
+
862
+ Args:
863
+ config_toml_path: Explicit path to config.toml. When None, resolved to
864
+ ``~/.superlocalmemory/config.toml`` via the canonical data root.
865
+
866
+ Returns:
867
+ DeploymentConfig — Personal preset when the file is absent, the section
868
+ is missing, or any parse error occurs (fail-open, non-destructive).
869
+ """
870
+ if config_toml_path is None:
871
+ try:
872
+ config_toml_path = _runtime_base_dir() / "config.toml"
873
+ except Exception:
874
+ return DEPLOYMENT_PERSONAL
875
+
876
+ if not config_toml_path.exists():
877
+ return DEPLOYMENT_PERSONAL
878
+
879
+ try:
880
+ import tomllib as _tomllib
881
+ raw = config_toml_path.read_text(encoding="utf-8")
882
+ data = _tomllib.loads(raw)
883
+ except Exception as exc:
884
+ logger.warning(
885
+ "load_deployment_config: failed to parse %s: %s", config_toml_path, exc
886
+ )
887
+ return DEPLOYMENT_PERSONAL
888
+
889
+ dep = data.get("deployment", {})
890
+ if not dep:
891
+ # No [deployment] section — personal defaults, no behaviour change.
892
+ return DEPLOYMENT_PERSONAL
893
+
894
+ raw_mode = str(dep.get("mode", "personal")).lower()
895
+ if raw_mode not in _VALID_DEPLOYMENT_MODES:
896
+ logger.warning(
897
+ "load_deployment_config: unknown mode %r in %s — defaulting to personal",
898
+ raw_mode, config_toml_path,
899
+ )
900
+ raw_mode = "personal"
901
+
902
+ # Use the preset for the mode as the base so omitted keys inherit
903
+ # sensible values (enterprise → require_login=True etc.).
904
+ base = DEPLOYMENT_ENTERPRISE if raw_mode == "enterprise" else DEPLOYMENT_PERSONAL
905
+
906
+ try:
907
+ return DeploymentConfig(
908
+ mode=raw_mode,
909
+ require_login=bool(dep.get("require_login", base.require_login)),
910
+ pii_redaction=bool(dep.get("pii_redaction", base.pii_redaction)),
911
+ retention_enabled=bool(dep.get("retention_enabled", base.retention_enabled)),
912
+ audit=bool(dep.get("audit", base.audit)),
913
+ )
914
+ except (ValueError, TypeError) as exc:
915
+ logger.warning(
916
+ "load_deployment_config: invalid values in [deployment] in %s: %s — "
917
+ "falling back to personal",
918
+ config_toml_path, exc,
919
+ )
920
+ return DEPLOYMENT_PERSONAL
921
+
922
+
746
923
  # ---------------------------------------------------------------------------
747
924
  # Health Config (v3.6.9 BUG-A)
748
925
  # ---------------------------------------------------------------------------
@@ -849,6 +1026,35 @@ class SLMConfig:
849
1026
  path = config_path or (_runtime_base / "config.json")
850
1027
  if not path.exists():
851
1028
  return cls.for_mode(Mode.A, base_dir=_runtime_base)
1029
+
1030
+ # DASH-V4 (3.7.9): the ``current_mode`` file is the single source of
1031
+ # truth for the ACTIVE mode. If config.json drifted from it (a writer
1032
+ # bypassed switch_mode and reset the active config), load the
1033
+ # authoritative per-mode file so daemon/CLI/dashboard all run the user's
1034
+ # chosen mode — with its settings — instead of a stale one. Scoped to
1035
+ # config.json so explicit per-mode loads (switch_mode) are untouched.
1036
+ # Fail-open: any error falls through to the normal load below.
1037
+ if path.name == "config.json":
1038
+ try:
1039
+ import json as _json
1040
+ _disk_mode = str(
1041
+ _json.loads(path.read_text()).get("mode", "")
1042
+ ).lower()
1043
+ _active_mode = cls.read_current_mode(path.parent)
1044
+ if _disk_mode and _active_mode and _disk_mode != _active_mode:
1045
+ _mode_path = cls._mode_config_path(
1046
+ path.parent, Mode(_active_mode)
1047
+ )
1048
+ if _mode_path.exists():
1049
+ logger.warning(
1050
+ "config.json mode=%s disagrees with current_mode=%s;"
1051
+ " loading authoritative %s",
1052
+ _disk_mode, _active_mode, _mode_path.name,
1053
+ )
1054
+ return cls.load(_mode_path)
1055
+ except Exception:
1056
+ pass # fail-open — fall through to the normal load below
1057
+
852
1058
  import json
853
1059
  try:
854
1060
  data = json.loads(path.read_text())
@@ -1070,6 +1276,9 @@ class SLMConfig:
1070
1276
  "enabled": self.evolution.enabled,
1071
1277
  "backend": self.evolution.backend,
1072
1278
  "max_evolutions_per_cycle": self.evolution.max_evolutions_per_cycle,
1279
+ "mutation_model": self.evolution.mutation_model,
1280
+ "verify_model": self.evolution.verify_model,
1281
+ "confirm_model": self.evolution.confirm_model,
1073
1282
  }
1074
1283
 
1075
1284
  # V3.4.65: Persist injection config
@@ -1264,6 +1473,7 @@ class SLMConfig:
1264
1473
  _c_emb = EmbeddingConfig(
1265
1474
  model_name=embedding_model_name or "text-embedding-3-large",
1266
1475
  dimension=embedding_dimension or 3072,
1476
+ provider=_c_emb_provider,
1267
1477
  api_endpoint=embedding_endpoint,
1268
1478
  api_key=embedding_key,
1269
1479
  deployment_name=embedding_deployment,
@@ -1275,8 +1485,11 @@ class SLMConfig:
1275
1485
  # 768-dim Ollama embedder, which made ingestion fail at vector
1276
1486
  # materialization. Cloud embeddings remain an explicit opt-in.
1277
1487
  _c_emb = EmbeddingConfig(
1278
- model_name="nomic-ai/nomic-embed-text-v1.5",
1279
- dimension=768,
1488
+ # Honour an on-disk embedding model when one was configured (the
1489
+ # load() path passes it through); default to the local nomic
1490
+ # model so Mode C never silently requires a paid cloud embedder.
1491
+ model_name=embedding_model_name or "nomic-ai/nomic-embed-text-v1.5",
1492
+ dimension=embedding_dimension or 768,
1280
1493
  )
1281
1494
  return cls(
1282
1495
  mode=mode,
@@ -49,6 +49,59 @@ if TYPE_CHECKING:
49
49
  logger = logging.getLogger(__name__)
50
50
 
51
51
 
52
+ def _recompute_entity_communities(
53
+ db: Any, profile_id: str, summarizer: Any = None,
54
+ ) -> dict[str, int]:
55
+ """Wave Q: rebuild the entity-community backbone + summaries (fail-open).
56
+
57
+ Shared spine for Q2 community summaries and Q3 progressive abstraction.
58
+ Runs in the background consolidation lane; never blocks store/recall.
59
+ Community detection and summary generation are independently fail-open.
60
+ """
61
+ result: dict[str, int] = {"entity_count": 0, "community_count": 0}
62
+ try:
63
+ from superlocalmemory.core.entity_community import EntityCommunityBuilder
64
+
65
+ result = EntityCommunityBuilder(db).compute_and_store(profile_id)
66
+ logger.info(
67
+ "Background entity-community: %d entities, %d communities",
68
+ result.get("entity_count", 0),
69
+ result.get("community_count", 0),
70
+ )
71
+ except Exception as exc:
72
+ logger.debug("Entity-community recompute failed (non-fatal): %s", exc)
73
+ return result
74
+
75
+ # Wave Q2: one synthesized report per community (rides on the backbone).
76
+ try:
77
+ from superlocalmemory.core.community_summary import CommunitySummaryBuilder
78
+
79
+ summ = CommunitySummaryBuilder(db, summarizer=summarizer).compute_and_store(
80
+ profile_id,
81
+ )
82
+ result["summaries_written"] = summ.get("summaries_written", 0)
83
+ logger.info(
84
+ "Background community summaries: %d written",
85
+ summ.get("summaries_written", 0),
86
+ )
87
+ except Exception as exc:
88
+ logger.debug("Community summaries failed (non-fatal): %s", exc)
89
+
90
+ # Wave Q3: persona roll-up (top tier over the community summaries).
91
+ try:
92
+ from superlocalmemory.core.progressive_abstraction import (
93
+ ProgressiveAbstraction,
94
+ )
95
+
96
+ pa = ProgressiveAbstraction(db, summarizer=summarizer).compute_and_store(
97
+ profile_id,
98
+ )
99
+ result["persona_built"] = bool(pa.get("built", False))
100
+ except Exception as exc:
101
+ logger.debug("Persona roll-up failed (non-fatal): %s", exc)
102
+ return result
103
+
104
+
52
105
  class ConsolidationEngine:
53
106
  """Sleep-time memory consolidation with 6-step cycle.
54
107
 
@@ -254,21 +307,27 @@ class ConsolidationEngine:
254
307
  activation. Takes ~200-800ms, runs on daemon thread, zero impact
255
308
  on store/recall latency.
256
309
  """
257
- if self._graph_analyzer is None:
258
- return
259
310
  analyzer = self._graph_analyzer
260
311
  pid = profile_id
312
+ db = self._db
313
+ summarizer = self._summarizer
261
314
 
262
315
  def _run() -> None:
263
- try:
264
- result = analyzer.compute_and_store(pid)
265
- logger.info(
266
- "Background graph analysis complete: %d nodes, %d communities",
267
- result.get("node_count", 0),
268
- result.get("community_count", 0),
269
- )
270
- except Exception as exc:
271
- logger.debug("Background graph analysis failed (non-fatal): %s", exc)
316
+ if analyzer is not None:
317
+ try:
318
+ result = analyzer.compute_and_store(pid)
319
+ logger.info(
320
+ "Background graph analysis complete: %d nodes, "
321
+ "%d communities",
322
+ result.get("node_count", 0),
323
+ result.get("community_count", 0),
324
+ )
325
+ except Exception as exc:
326
+ logger.debug(
327
+ "Background graph analysis failed (non-fatal): %s", exc,
328
+ )
329
+ # Wave Q: entity-community backbone + summaries (Q2/Q3 spine).
330
+ _recompute_entity_communities(db, pid, summarizer)
272
331
 
273
332
  t = threading.Thread(target=_run, daemon=True, name="graph-analysis-bg")
274
333
  t.start()
@@ -510,15 +569,21 @@ class ConsolidationEngine:
510
569
  fact_id, profile_id,
511
570
  )
512
571
 
513
- # Fallback: direct SQL check
572
+ # Fallback: direct SQL check. Must consider BOTH valid_until (valid-time
573
+ # expiry) AND system_expired_at (transaction-time expiry) — a fact that
574
+ # was invalidated/erased sets system_expired_at, and ignoring it would
575
+ # let a GDPR-erased/superseded fact be promoted back into warm lifecycle.
514
576
  rows = self._db.execute(
515
- "SELECT valid_until FROM fact_temporal_validity "
577
+ "SELECT valid_until, system_expired_at FROM fact_temporal_validity "
516
578
  "WHERE fact_id = ? AND profile_id = ?",
517
579
  (fact_id, profile_id),
518
580
  )
519
581
  if not rows:
520
582
  return True # No temporal record = valid
521
- valid_until = dict(rows[0]).get("valid_until")
583
+ row = dict(rows[0])
584
+ if row.get("system_expired_at") is not None:
585
+ return False # transaction-time expired (invalidated/erased)
586
+ valid_until = row.get("valid_until")
522
587
  if valid_until is None:
523
588
  return True # Open-ended validity
524
589
  try:
@@ -555,14 +620,22 @@ class ConsolidationEngine:
555
620
  def _step5_recompute_graph(
556
621
  self, profile_id: str,
557
622
  ) -> dict[str, Any]:
558
- """Recompute PageRank + communities. Delegates to GraphAnalyzer."""
559
- if self._graph_analyzer is None:
560
- return {"node_count": 0, "community_count": 0}
561
- try:
562
- return self._graph_analyzer.compute_and_store(profile_id)
563
- except Exception as exc:
564
- logger.warning("Graph recompute failed: %s", exc)
565
- return {"node_count": 0, "community_count": 0}
623
+ """Recompute PageRank + communities. Delegates to GraphAnalyzer.
624
+
625
+ Wave Q: also rebuilds the entity-community backbone (Q2/Q3 spine).
626
+ """
627
+ result: dict[str, Any] = {"node_count": 0, "community_count": 0}
628
+ if self._graph_analyzer is not None:
629
+ try:
630
+ result = self._graph_analyzer.compute_and_store(profile_id)
631
+ except Exception as exc:
632
+ logger.warning("Graph recompute failed: %s", exc)
633
+ ec = _recompute_entity_communities(
634
+ self._db, profile_id, self._summarizer,
635
+ )
636
+ result["entity_community_count"] = ec.get("community_count", 0)
637
+ result["community_summaries"] = ec.get("summaries_written", 0)
638
+ return result
566
639
 
567
640
  # ------------------------------------------------------------------
568
641
  # Step 6: Derive Associations
@@ -65,6 +65,18 @@ class CacheEntry:
65
65
  provenance: str = "tool_observation"
66
66
  computed_at: int = 0
67
67
  byte_size: int = 0
68
+ profile_id: str = "default"
69
+
70
+
71
+ def _active_profile_fallback(home: Path) -> str:
72
+ """Resolve the active profile for the cache reader hot path (stdlib only,
73
+ never raises). Two profiles can share a session_id, so cached context MUST
74
+ be keyed by profile or one tenant reads another's context."""
75
+ try:
76
+ raw = (home / "profiles.json").read_text(encoding="utf-8")
77
+ return json.loads(raw).get("active_profile", "default") or "default"
78
+ except Exception:
79
+ return "default"
68
80
 
69
81
 
70
82
  # ---------------------------------------------------------------------------
@@ -199,9 +211,24 @@ class ContextCache:
199
211
  return conn
200
212
 
201
213
  def _bootstrap_schema_and_meta(self) -> None:
214
+ # Isolation: cached context is keyed by profile so two tenants sharing a
215
+ # session_id cannot read each other's context. Older cache files lack
216
+ # the profile_id column — the cache is ephemeral (120s TTL), so drop and
217
+ # recreate rather than run a rebuild migration.
218
+ try:
219
+ cols = {
220
+ r[1] for r in self._write_conn.execute(
221
+ "PRAGMA table_info(context_entries)"
222
+ ).fetchall()
223
+ }
224
+ if cols and "profile_id" not in cols:
225
+ self._write_conn.execute("DROP TABLE context_entries")
226
+ except sqlite3.Error: # pragma: no cover — defensive
227
+ pass
202
228
  self._write_conn.executescript(
203
229
  """
204
230
  CREATE TABLE IF NOT EXISTS context_entries (
231
+ profile_id TEXT NOT NULL DEFAULT 'default',
205
232
  session_id TEXT NOT NULL,
206
233
  topic_sig TEXT NOT NULL,
207
234
  content TEXT NOT NULL,
@@ -209,11 +236,11 @@ class ContextCache:
209
236
  provenance TEXT NOT NULL DEFAULT 'tool_observation',
210
237
  computed_at INTEGER NOT NULL,
211
238
  byte_size INTEGER NOT NULL,
212
- PRIMARY KEY (session_id, topic_sig)
239
+ PRIMARY KEY (profile_id, session_id, topic_sig)
213
240
  ) WITHOUT ROWID;
214
241
 
215
242
  CREATE INDEX IF NOT EXISTS idx_ctx_session_time
216
- ON context_entries(session_id, computed_at);
243
+ ON context_entries(profile_id, session_id, computed_at);
217
244
  CREATE INDEX IF NOT EXISTS idx_ctx_time
218
245
  ON context_entries(computed_at);
219
246
 
@@ -263,26 +290,38 @@ class ContextCache:
263
290
  self._write_conn.execute(
264
291
  """
265
292
  INSERT OR REPLACE INTO context_entries
266
- (session_id, topic_sig, content, fact_ids,
293
+ (profile_id, session_id, topic_sig, content, fact_ids,
267
294
  provenance, computed_at, byte_size)
268
- VALUES (?, ?, ?, ?, ?, ?, ?)
295
+ VALUES (?, ?, ?, ?, ?, ?, ?, ?)
269
296
  """,
270
- (entry.session_id, entry.topic_sig, content, fact_ids_json,
271
- entry.provenance, computed_at, byte_size),
297
+ (entry.profile_id or "default", entry.session_id, entry.topic_sig,
298
+ content, fact_ids_json, entry.provenance, computed_at, byte_size),
272
299
  )
273
300
 
274
301
  # -- Cleanup ------------------------------------------------------------
275
302
 
276
303
  def cleanup_session(
277
304
  self, session_id: str, *, older_than: int = CLEANUP_HORIZON_SECONDS,
305
+ profile_id: str | None = None,
278
306
  ) -> int:
279
- """Delete rows for ``session_id`` older than ``older_than`` seconds."""
307
+ """Delete rows for ``session_id`` older than ``older_than`` seconds.
308
+
309
+ When ``profile_id`` is given the delete is tenant-scoped so a cleanup on
310
+ a shared session_id cannot wipe another profile's cached context.
311
+ """
280
312
  cutoff = int(time.time()) - older_than
281
- cur = self._write_conn.execute(
282
- "DELETE FROM context_entries "
283
- "WHERE session_id=? AND computed_at < ?",
284
- (session_id, cutoff),
285
- )
313
+ if profile_id is not None:
314
+ cur = self._write_conn.execute(
315
+ "DELETE FROM context_entries "
316
+ "WHERE profile_id=? AND session_id=? AND computed_at < ?",
317
+ (profile_id, session_id, cutoff),
318
+ )
319
+ else:
320
+ cur = self._write_conn.execute(
321
+ "DELETE FROM context_entries "
322
+ "WHERE session_id=? AND computed_at < ?",
323
+ (session_id, cutoff),
324
+ )
286
325
  return cur.rowcount
287
326
 
288
327
  def cleanup_global_lru(self) -> int:
@@ -309,17 +348,17 @@ class ContextCache:
309
348
  target = int(MAX_BYTES * 0.9)
310
349
  while total > target:
311
350
  rows = self._write_conn.execute(
312
- "SELECT session_id, topic_sig, byte_size "
351
+ "SELECT profile_id, session_id, topic_sig, byte_size "
313
352
  "FROM context_entries "
314
353
  "ORDER BY computed_at ASC LIMIT 100",
315
354
  ).fetchall()
316
355
  if not rows: # pragma: no cover — reached only if table empties mid-sweep
317
356
  break
318
- for sess, sig, size in rows:
357
+ for pid, sess, sig, size in rows:
319
358
  self._write_conn.execute(
320
359
  "DELETE FROM context_entries "
321
- "WHERE session_id=? AND topic_sig=?",
322
- (sess, sig),
360
+ "WHERE profile_id=? AND session_id=? AND topic_sig=?",
361
+ (pid, sess, sig),
323
362
  )
324
363
  deleted += 1
325
364
  total -= size
@@ -345,6 +384,7 @@ def read_entry_fast(
345
384
  *,
346
385
  db_path: Path | None = None,
347
386
  home_dir: Path | None = None,
387
+ profile_id: str | None = None,
348
388
  ) -> CacheEntry | None:
349
389
  """Hot-path reader used by the UserPromptSubmit hook.
350
390
 
@@ -397,14 +437,17 @@ def read_entry_fast(
397
437
  return None
398
438
 
399
439
  now = int(time.time())
440
+ # Scope to the active profile so a shared session_id cannot read
441
+ # another tenant's cached context.
442
+ pid = profile_id or _active_profile_fallback(home)
400
443
  row = conn.execute(
401
444
  """
402
445
  SELECT content, fact_ids, provenance, computed_at, byte_size
403
446
  FROM context_entries
404
- WHERE session_id=? AND topic_sig=?
447
+ WHERE profile_id=? AND session_id=? AND topic_sig=?
405
448
  AND computed_at > ?
406
449
  """,
407
- (session_id, topic_sig, now - TTL_SECONDS),
450
+ (pid, session_id, topic_sig, now - TTL_SECONDS),
408
451
  ).fetchone()
409
452
  finally:
410
453
  try:
@@ -65,6 +65,21 @@ def _embedding_backend_order() -> tuple[str, str]:
65
65
  return ("onnx", "pytorch")
66
66
 
67
67
 
68
+ # H-02 (3.7.9): trust_remote_code=_trusts_remote_code(name) runs arbitrary Python from the model
69
+ # repository at load time. Restrict it to the pinned models SLM ships that
70
+ # genuinely need custom modeling code (nomic-embed). Any other model — including
71
+ # one swapped into config by a write-path attacker — loads with
72
+ # trust_remote_code=False and therefore cannot execute repo code.
73
+ _TRUSTED_REMOTE_CODE_MODELS = frozenset({
74
+ "nomic-ai/nomic-embed-text-v1.5",
75
+ "nomic-ai/nomic-embed-text-v1",
76
+ })
77
+
78
+
79
+ def _trusts_remote_code(model_name: str) -> bool:
80
+ return model_name in _TRUSTED_REMOTE_CODE_MODELS
81
+
82
+
68
83
  def _load_embedding_model(name: str) -> tuple:
69
84
  """Load embedding model. ONNX CPU-only first, PyTorch fallback.
70
85
 
@@ -84,14 +99,14 @@ def _load_embedding_model(name: str) -> tuple:
84
99
  m = SentenceTransformer(
85
100
  name,
86
101
  backend="onnx",
87
- trust_remote_code=True,
102
+ trust_remote_code=_trusts_remote_code(name),
88
103
  model_kwargs={"provider": "CPUExecutionProvider"},
89
104
  )
90
105
  else:
91
106
  import torch
92
107
  with torch.inference_mode():
93
108
  m = SentenceTransformer(
94
- name, trust_remote_code=True, device="cpu",
109
+ name, trust_remote_code=_trusts_remote_code(name), device="cpu",
95
110
  )
96
111
  return m, backend
97
112
  except Exception:
@@ -295,7 +295,13 @@ class EmbeddingService:
295
295
  never hangs indefinitely on cold model loads or network issues.
296
296
  """
297
297
  with self._lock:
298
- if not self._available:
298
+ # Only an explicit terminal disable (``False``) short-circuits. A
299
+ # ``None`` availability is the recall-health self-heal's "re-probe"
300
+ # signal (recall_health._heal_embedder) — it must fall through and
301
+ # respawn the worker, matching OllamaEmbedder's tri-state
302
+ # convention. Using ``not self._available`` here bricked the local
303
+ # worker on the first heal tick, because ``None`` is falsy.
304
+ if self._available is False:
299
305
  return None
300
306
  # Worker recycling: restart after N requests to prevent
301
307
  # C++ allocator fragmentation over long-running sessions.
@@ -348,6 +354,11 @@ class EmbeddingService:
348
354
  self._available = False
349
355
  self._kill_worker()
350
356
  return None
357
+ # A successful embed proves the worker is healthy, so clear any
358
+ # transient/``None`` availability left by a self-heal re-probe
359
+ # back to a definite ``True``. Without this the flag lingers at
360
+ # ``None`` and the next ``not``-style check elsewhere re-blocks.
361
+ self._available = True
351
362
  self._reset_idle_timer()
352
363
  self._request_count += 1
353
364
  return resp["vectors"]