claude-smart 0.2.48 → 0.2.50

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 (193) hide show
  1. package/.claude-plugin/marketplace.json +2 -2
  2. package/README.md +11 -40
  3. package/bin/claude-smart.js +393 -55
  4. package/package.json +3 -2
  5. package/plugin/.claude-plugin/plugin.json +1 -1
  6. package/plugin/.codex-plugin/plugin.json +1 -1
  7. package/plugin/.coverage +0 -0
  8. package/plugin/README.md +4 -3
  9. package/plugin/dashboard/app/api/reflexio/[...path]/route.ts +4 -2
  10. package/plugin/dashboard/app/dashboard/page.tsx +6 -1
  11. package/plugin/dashboard/app/preferences/[id]/page.tsx +18 -6
  12. package/plugin/dashboard/app/preferences/page.tsx +32 -35
  13. package/plugin/dashboard/app/sessions/[sessionId]/page.tsx +16 -1
  14. package/plugin/dashboard/app/sessions/page.tsx +2 -0
  15. package/plugin/dashboard/app/skills/page.tsx +65 -50
  16. package/plugin/dashboard/app/skills/project/[id]/page.tsx +17 -8
  17. package/plugin/dashboard/app/skills/shared/[id]/page.tsx +1 -6
  18. package/plugin/dashboard/components/common/host-badge.tsx +118 -0
  19. package/plugin/dashboard/components/common/learning-application-badge.tsx +34 -0
  20. package/plugin/dashboard/components/common/learnings-badge.tsx +1 -1
  21. package/plugin/dashboard/components/common/page-header.tsx +3 -3
  22. package/plugin/dashboard/lib/config-file.ts +5 -1
  23. package/plugin/dashboard/lib/host-attribution.ts +62 -0
  24. package/plugin/dashboard/lib/session-reader.ts +40 -2
  25. package/plugin/dashboard/lib/types.ts +7 -1
  26. package/plugin/opencode/dist/server.mjs +60 -2
  27. package/plugin/opencode/server.mts +64 -2
  28. package/plugin/pyproject.toml +2 -2
  29. package/plugin/scripts/_lib.sh +255 -7
  30. package/plugin/scripts/backend-python-runner.py +46 -0
  31. package/plugin/scripts/backend-service.sh +766 -123
  32. package/plugin/scripts/codex-hook.js +79 -229
  33. package/plugin/scripts/dashboard-open.sh +6 -4
  34. package/plugin/scripts/dashboard-service.sh +118 -137
  35. package/plugin/scripts/ensure-plugin-root.sh +106 -8
  36. package/plugin/scripts/hook_entry.sh +3 -0
  37. package/plugin/scripts/opencode-claude-compat.js +8 -1
  38. package/plugin/scripts/smart-install.sh +116 -21
  39. package/plugin/src/README.md +1 -1
  40. package/plugin/src/claude_smart/cli.py +93 -29
  41. package/plugin/src/claude_smart/context_inject.py +3 -0
  42. package/plugin/src/claude_smart/env_config.py +56 -11
  43. package/plugin/src/claude_smart/events/post_tool.py +2 -1
  44. package/plugin/src/claude_smart/events/session_end.py +2 -1
  45. package/plugin/src/claude_smart/events/stop.py +3 -0
  46. package/plugin/src/claude_smart/events/user_prompt.py +2 -1
  47. package/plugin/src/claude_smart/internal_call.py +5 -2
  48. package/plugin/src/claude_smart/optimizer_assistant.py +59 -13
  49. package/plugin/src/claude_smart/publish.py +59 -7
  50. package/plugin/src/claude_smart/reflexio_adapter.py +137 -14
  51. package/plugin/src/claude_smart/runtime.py +15 -6
  52. package/plugin/src/claude_smart/state.py +211 -52
  53. package/plugin/uv.lock +5 -5
  54. package/plugin/vendor/reflexio/.env.example +13 -0
  55. package/plugin/vendor/reflexio/reflexio/README.md +7 -3
  56. package/plugin/vendor/reflexio/reflexio/__init__.py +12 -0
  57. package/plugin/vendor/reflexio/reflexio/cli/README.md +3 -0
  58. package/plugin/vendor/reflexio/reflexio/client/client.py +166 -9
  59. package/plugin/vendor/reflexio/reflexio/integrations/openclaw/plugin/src/openclaw_smart/state.py +10 -3
  60. package/plugin/vendor/reflexio/reflexio/integrations/openclaw/plugin/tests/test_state.py +28 -0
  61. package/plugin/vendor/reflexio/reflexio/lib/_search.py +41 -0
  62. package/plugin/vendor/reflexio/reflexio/models/api_schema/domain/entities.py +195 -25
  63. package/plugin/vendor/reflexio/reflexio/models/api_schema/eval_overview_schema.py +7 -1
  64. package/plugin/vendor/reflexio/reflexio/models/api_schema/internal_schema.py +2 -1
  65. package/plugin/vendor/reflexio/reflexio/models/api_schema/retriever_schema.py +63 -4
  66. package/plugin/vendor/reflexio/reflexio/models/api_schema/ui/converters.py +1 -0
  67. package/plugin/vendor/reflexio/reflexio/models/api_schema/ui/entities.py +8 -1
  68. package/plugin/vendor/reflexio/reflexio/models/config_schema.py +1 -1
  69. package/plugin/vendor/reflexio/reflexio/server/README.md +22 -4
  70. package/plugin/vendor/reflexio/reflexio/server/__init__.py +18 -8
  71. package/plugin/vendor/reflexio/reflexio/server/__main__.py +6 -0
  72. package/plugin/vendor/reflexio/reflexio/server/api.py +79 -5
  73. package/plugin/vendor/reflexio/reflexio/server/billing_meter.py +263 -3
  74. package/plugin/vendor/reflexio/reflexio/server/callback_executor.py +164 -0
  75. package/plugin/vendor/reflexio/reflexio/server/llm/_litellm_embedding.py +81 -81
  76. package/plugin/vendor/reflexio/reflexio/server/llm/_litellm_subprocess.py +28 -0
  77. package/plugin/vendor/reflexio/reflexio/server/llm/_litellm_text_generation.py +63 -6
  78. package/plugin/vendor/reflexio/reflexio/server/llm/embedding_service.py +19 -52
  79. package/plugin/vendor/reflexio/reflexio/server/llm/litellm_client.py +1 -0
  80. package/plugin/vendor/reflexio/reflexio/server/llm/model_defaults.py +28 -0
  81. package/plugin/vendor/reflexio/reflexio/server/llm/providers/claude_code_provider.py +9 -1
  82. package/plugin/vendor/reflexio/reflexio/server/llm/providers/embedder_warmup.py +329 -0
  83. package/plugin/vendor/reflexio/reflexio/server/llm/providers/embedding_service_provider.py +85 -10
  84. package/plugin/vendor/reflexio/reflexio/server/llm/providers/local_embedding_provider.py +199 -2
  85. package/plugin/vendor/reflexio/reflexio/server/llm/providers/nomic_embedding_provider.py +77 -9
  86. package/plugin/vendor/reflexio/reflexio/server/org_fanout.py +184 -0
  87. package/plugin/vendor/reflexio/reflexio/server/prompt/prompt_bank/document_expansion/v1.0.0.prompt.md +1 -1
  88. package/plugin/vendor/reflexio/reflexio/server/prompt/prompt_bank/document_expansion/v1.1.0.prompt.md +32 -0
  89. package/plugin/vendor/reflexio/reflexio/server/prompt/prompt_bank/playbook_consolidation/v2.3.3.prompt.md +1 -1
  90. package/plugin/vendor/reflexio/reflexio/server/prompt/prompt_bank/playbook_consolidation/v2.4.0.prompt.md +63 -0
  91. package/plugin/vendor/reflexio/reflexio/server/prompt/prompt_bank/query_reformulation/v1.0.0.prompt.md +1 -1
  92. package/plugin/vendor/reflexio/reflexio/server/prompt/prompt_bank/query_reformulation/v2.0.0.prompt.md +30 -0
  93. package/plugin/vendor/reflexio/reflexio/server/prompt/prompt_bank/retrieved_learning_impact/v1.0.0.prompt.md +51 -0
  94. package/plugin/vendor/reflexio/reflexio/server/prompt/prompt_bank/retrieved_learning_relevance/v1.0.0.prompt.md +39 -0
  95. package/plugin/vendor/reflexio/reflexio/server/prompt/prompt_bank/shadow_comparison/v1.0.0.prompt.md +1 -1
  96. package/plugin/vendor/reflexio/reflexio/server/prompt/prompt_bank/shadow_comparison/v1.1.0.prompt.md +43 -0
  97. package/plugin/vendor/reflexio/reflexio/server/routes/config.py +3 -3
  98. package/plugin/vendor/reflexio/reflexio/server/routes/evaluation.py +122 -28
  99. package/plugin/vendor/reflexio/reflexio/server/routes/interactions.py +63 -2
  100. package/plugin/vendor/reflexio/reflexio/server/routes/system.py +22 -3
  101. package/plugin/vendor/reflexio/reflexio/server/scheduling.py +64 -3
  102. package/plugin/vendor/reflexio/reflexio/server/services/README.md +6 -4
  103. package/plugin/vendor/reflexio/reflexio/server/services/agent_success_evaluation/README.md +3 -2
  104. package/plugin/vendor/reflexio/reflexio/server/services/agent_success_evaluation/_eval_health.py +41 -0
  105. package/plugin/vendor/reflexio/reflexio/server/services/agent_success_evaluation/components/retrieved_learning_evaluator.py +554 -0
  106. package/plugin/vendor/reflexio/reflexio/server/services/agent_success_evaluation/regen_jobs.py +35 -1
  107. package/plugin/vendor/reflexio/reflexio/server/services/agent_success_evaluation/runner.py +253 -101
  108. package/plugin/vendor/reflexio/reflexio/server/services/agent_success_evaluation/scheduler.py +5 -7
  109. package/plugin/vendor/reflexio/reflexio/server/services/agent_success_evaluation/service.py +27 -0
  110. package/plugin/vendor/reflexio/reflexio/server/services/base_generation/_extraction_lifecycle.py +11 -5
  111. package/plugin/vendor/reflexio/reflexio/server/services/base_generation/_should_run.py +4 -4
  112. package/plugin/vendor/reflexio/reflexio/server/services/base_generation/_usage_billing.py +6 -2
  113. package/plugin/vendor/reflexio/reflexio/server/services/base_generation_service.py +255 -74
  114. package/plugin/vendor/reflexio/reflexio/server/services/deduplication_utils.py +72 -0
  115. package/plugin/vendor/reflexio/reflexio/server/services/deferred_learning_plan.py +270 -0
  116. package/plugin/vendor/reflexio/reflexio/server/services/durable_learning/__init__.py +13 -0
  117. package/plugin/vendor/reflexio/reflexio/server/services/durable_learning/scheduler.py +142 -0
  118. package/plugin/vendor/reflexio/reflexio/server/services/durable_learning/worker.py +262 -0
  119. package/plugin/vendor/reflexio/reflexio/server/services/evaluation_overview/components/hero_state.py +2 -11
  120. package/plugin/vendor/reflexio/reflexio/server/services/evaluation_overview/components/rule_attribution.py +6 -2
  121. package/plugin/vendor/reflexio/reflexio/server/services/evaluation_overview/service.py +17 -13
  122. package/plugin/vendor/reflexio/reflexio/server/services/extraction/agent_run_records.py +18 -3
  123. package/plugin/vendor/reflexio/reflexio/server/services/extraction/outcome.py +12 -1
  124. package/plugin/vendor/reflexio/reflexio/server/services/extraction/prior_answer_search.py +1 -1
  125. package/plugin/vendor/reflexio/reflexio/server/services/extraction/resumable_agent.py +2 -1
  126. package/plugin/vendor/reflexio/reflexio/server/services/extraction/resume_scheduler.py +1 -3
  127. package/plugin/vendor/reflexio/reflexio/server/services/extraction/resume_worker.py +66 -0
  128. package/plugin/vendor/reflexio/reflexio/server/services/generation_service.py +640 -80
  129. package/plugin/vendor/reflexio/reflexio/server/services/governance/service.py +2 -0
  130. package/plugin/vendor/reflexio/reflexio/server/services/lineage/gc_scheduler.py +179 -99
  131. package/plugin/vendor/reflexio/reflexio/server/services/lineage/vector_backfill_sweep.py +139 -0
  132. package/plugin/vendor/reflexio/reflexio/server/services/operation_state_utils.py +66 -27
  133. package/plugin/vendor/reflexio/reflexio/server/services/playbook/aggregation_trigger.py +177 -0
  134. package/plugin/vendor/reflexio/reflexio/server/services/playbook/components/aggregator.py +68 -2
  135. package/plugin/vendor/reflexio/reflexio/server/services/playbook/components/consolidator.py +360 -49
  136. package/plugin/vendor/reflexio/reflexio/server/services/playbook/components/extractor.py +27 -30
  137. package/plugin/vendor/reflexio/reflexio/server/services/playbook/playbook_edit_apply.py +8 -1
  138. package/plugin/vendor/reflexio/reflexio/server/services/playbook/service.py +137 -69
  139. package/plugin/vendor/reflexio/reflexio/server/services/playbook_optimizer/optimizer.py +20 -1
  140. package/plugin/vendor/reflexio/reflexio/server/services/playbook_optimizer/scheduler.py +13 -6
  141. package/plugin/vendor/reflexio/reflexio/server/services/pre_retrieval/_query_reformulator.py +40 -25
  142. package/plugin/vendor/reflexio/reflexio/server/services/profile/components/consolidator.py +12 -39
  143. package/plugin/vendor/reflexio/reflexio/server/services/profile/components/extractor.py +30 -35
  144. package/plugin/vendor/reflexio/reflexio/server/services/profile/service.py +166 -66
  145. package/plugin/vendor/reflexio/reflexio/server/services/reflection/service.py +457 -107
  146. package/plugin/vendor/reflexio/reflexio/server/services/retrieval/session_dedup.py +127 -0
  147. package/plugin/vendor/reflexio/reflexio/server/services/retrieval/temporal.py +104 -0
  148. package/plugin/vendor/reflexio/reflexio/server/services/shadow_comparison/dispatcher.py +139 -0
  149. package/plugin/vendor/reflexio/reflexio/server/services/shadow_comparison/judge.py +16 -6
  150. package/plugin/vendor/reflexio/reflexio/server/services/shadow_comparison/worker.py +137 -0
  151. package/plugin/vendor/reflexio/reflexio/server/services/storage/governance_validation.py +12 -0
  152. package/plugin/vendor/reflexio/reflexio/server/services/storage/lifecycle_filters.py +54 -0
  153. package/plugin/vendor/reflexio/reflexio/server/services/storage/retention.py +41 -3
  154. package/plugin/vendor/reflexio/reflexio/server/services/storage/retention_mixin.py +40 -2
  155. package/plugin/vendor/reflexio/reflexio/server/services/storage/sqlite_storage/__init__.py +2 -0
  156. package/plugin/vendor/reflexio/reflexio/server/services/storage/sqlite_storage/_base.py +190 -2
  157. package/plugin/vendor/reflexio/reflexio/server/services/storage/sqlite_storage/_extras.py +11 -4
  158. package/plugin/vendor/reflexio/reflexio/server/services/storage/sqlite_storage/_governance.py +28 -1
  159. package/plugin/vendor/reflexio/reflexio/server/services/storage/sqlite_storage/_learning_jobs.py +414 -0
  160. package/plugin/vendor/reflexio/reflexio/server/services/storage/sqlite_storage/_lineage.py +11 -5
  161. package/plugin/vendor/reflexio/reflexio/server/services/storage/sqlite_storage/_requests.py +8 -3
  162. package/plugin/vendor/reflexio/reflexio/server/services/storage/sqlite_storage/_shadow_verdicts.py +4 -0
  163. package/plugin/vendor/reflexio/reflexio/server/services/storage/sqlite_storage/base/_deletion.py +16 -5
  164. package/plugin/vendor/reflexio/reflexio/server/services/storage/sqlite_storage/base/_fts_vec.py +86 -2
  165. package/plugin/vendor/reflexio/reflexio/server/services/storage/sqlite_storage/governance/_erase_execution.py +104 -42
  166. package/plugin/vendor/reflexio/reflexio/server/services/storage/sqlite_storage/governance/_subject_barrier.py +7 -1
  167. package/plugin/vendor/reflexio/reflexio/server/services/storage/sqlite_storage/playbook/_agent.py +59 -7
  168. package/plugin/vendor/reflexio/reflexio/server/services/storage/sqlite_storage/playbook/_eval_results.py +430 -5
  169. package/plugin/vendor/reflexio/reflexio/server/services/storage/sqlite_storage/playbook/_source_linkage.py +8 -3
  170. package/plugin/vendor/reflexio/reflexio/server/services/storage/sqlite_storage/playbook/_user.py +57 -12
  171. package/plugin/vendor/reflexio/reflexio/server/services/storage/sqlite_storage/profiles/_interaction_store.py +197 -12
  172. package/plugin/vendor/reflexio/reflexio/server/services/storage/sqlite_storage/profiles/_profile_store.py +70 -11
  173. package/plugin/vendor/reflexio/reflexio/server/services/storage/storage_base/__init__.py +17 -0
  174. package/plugin/vendor/reflexio/reflexio/server/services/storage/storage_base/_commit_scope.py +9 -0
  175. package/plugin/vendor/reflexio/reflexio/server/services/storage/storage_base/_extras.py +9 -2
  176. package/plugin/vendor/reflexio/reflexio/server/services/storage/storage_base/_learning_jobs.py +269 -0
  177. package/plugin/vendor/reflexio/reflexio/server/services/storage/storage_base/_operations.py +7 -0
  178. package/plugin/vendor/reflexio/reflexio/server/services/storage/storage_base/_retrieval_log.py +3 -1
  179. package/plugin/vendor/reflexio/reflexio/server/services/storage/storage_base/_shadow_verdicts.py +4 -0
  180. package/plugin/vendor/reflexio/reflexio/server/services/storage/storage_base/agent_run/_models.py +12 -1
  181. package/plugin/vendor/reflexio/reflexio/server/services/storage/storage_base/evaluation_state_keys.py +78 -0
  182. package/plugin/vendor/reflexio/reflexio/server/services/storage/storage_base/playbook/_agent.py +38 -0
  183. package/plugin/vendor/reflexio/reflexio/server/services/storage/storage_base/playbook/_eval_results.py +171 -0
  184. package/plugin/vendor/reflexio/reflexio/server/services/storage/storage_base/playbook/_user.py +52 -1
  185. package/plugin/vendor/reflexio/reflexio/server/services/storage/storage_base/profiles/_interaction_store.py +82 -1
  186. package/plugin/vendor/reflexio/reflexio/server/services/storage/storage_base/profiles/_profile_store.py +74 -2
  187. package/plugin/vendor/reflexio/reflexio/server/services/storage/storage_base/retrieved_learning_state.py +226 -0
  188. package/plugin/vendor/reflexio/reflexio/server/services/tagging/tagging_scheduler.py +5 -6
  189. package/plugin/vendor/reflexio/reflexio/server/services/unified_search_service.py +209 -29
  190. package/plugin/vendor/reflexio/reflexio/server/usage_metrics.py +3 -0
  191. package/plugin/vendor/reflexio/reflexio/test_support/llm_mock.py +61 -0
  192. package/plugin/vendor/reflexio/reflexio/test_support/llm_model_registry.py +33 -0
  193. package/plugin/vendor/reflexio/reflexio/server/services/search/__init__.py +0 -0
@@ -0,0 +1,329 @@
1
+ """Warm-before-ready readiness gate for the in-process local embedder.
2
+
3
+ Phase 2 preparation for the embedding-stability redesign. Everything here is
4
+ **dormant** until a future config flip sets ``REFLEXIO_EMBEDDING_PROVIDER=inprocess``
5
+ with a ``local/*`` default embedding model. Pre-flip (the current daemon-mode
6
+ prod state, or any cloud/off/OSS-dev deployment) none of this changes ``/health``
7
+ or startup behaviour.
8
+
9
+ Three concerns live here:
10
+
11
+ - **D5 warm-before-ready** — a process-level ``threading.Event`` set once the
12
+ in-process embedder has loaded, plus a non-blocking startup thread that loads
13
+ it. ``/health`` reports 503 while the gate is active and the embedder is not
14
+ yet warm, so the load balancer does not route embedding traffic at a worker
15
+ that would pay the ~2-8s cold-load on its first request.
16
+ - **D8 config guards** — loud startup warnings for foot-guns of the in-process
17
+ topology: multiple uvicorn workers (each loads its own model copy), a
18
+ half-configured daemon-disable / provider pair, and an in-process provider set
19
+ alongside a configured service endpoint (which the provider silently overrides).
20
+
21
+ The gate is intentionally cheap to evaluate (no network probe, no provider
22
+ auto-detection) because ``/health`` is the ALB + container health target and is
23
+ polled frequently.
24
+ """
25
+
26
+ from __future__ import annotations
27
+
28
+ import logging
29
+ import os
30
+ import threading
31
+ import time
32
+ from collections.abc import Callable
33
+
34
+ _LOGGER = logging.getLogger(__name__)
35
+
36
+ # Warm-before-ready retry: cold model load is ~2-8s and reliable in prod, but a
37
+ # bounded retry absorbs a transient blip before giving up (a give-up leaves
38
+ # /health at 503, forcing a full ECS task replacement).
39
+ _WARM_MAX_ATTEMPTS = 3
40
+ _WARM_RETRY_BACKOFF_S = 2.0
41
+
42
+ _ENV_PROVIDER = "REFLEXIO_EMBEDDING_PROVIDER"
43
+ _ENV_DISABLE_DAEMON = "REFLEXIO_DISABLE_LOCAL_EMBEDDING_DAEMON"
44
+ # Service-endpoint envs, named to match embedding_service_provider.py exactly. An
45
+ # in-process provider takes precedence over both, so either being set alongside
46
+ # PROVIDER=inprocess silently strands the configured service host.
47
+ _ENV_SERVICE_URL = "REFLEXIO_EMBEDDING_SERVICE_URL"
48
+ _ENV_DAEMON_HOST = "REFLEXIO_EMBEDDING_DAEMON_HOST"
49
+ # Recorded by ``reflexio.server.__main__`` before ``uvicorn.run`` so the guard
50
+ # can read the configured worker count from inside a worker process, where
51
+ # uvicorn exposes no worker-count env of its own.
52
+ _ENV_WORKERS = "REFLEXIO_SERVER_WORKERS"
53
+ _ENV_WEB_CONCURRENCY = "WEB_CONCURRENCY"
54
+
55
+ _INPROCESS = "inprocess"
56
+
57
+ # Process-level readiness signal: set once the in-process embedder is loaded.
58
+ _ready = threading.Event()
59
+
60
+
61
+ def mark_embedder_ready() -> None:
62
+ """Mark the in-process embedder as loaded/warm for this process."""
63
+ _ready.set()
64
+
65
+
66
+ def is_embedder_ready() -> bool:
67
+ """Return True once the in-process embedder has been warmed in this process."""
68
+ return _ready.is_set()
69
+
70
+
71
+ def reset_warmup_state_for_test() -> None:
72
+ """Clear the readiness signal. Test-only hygiene helper."""
73
+ _ready.clear()
74
+
75
+
76
+ def _provider() -> str:
77
+ return os.environ.get(_ENV_PROVIDER, "").strip().lower()
78
+
79
+
80
+ def inprocess_local_gate_active() -> bool:
81
+ """Return True iff this deployment serves embeddings via the in-process local path.
82
+
83
+ The gate is active only when BOTH hold:
84
+
85
+ - ``REFLEXIO_EMBEDDING_PROVIDER == "inprocess"`` (explicit config flip), and
86
+ - the resolved default embedding model is a ``local/*`` model.
87
+
88
+ Any other configuration (cloud, off, daemon-mode ``local_service`` /
89
+ ``internal_service``, or no explicit provider at all) returns False, keeping
90
+ the warm-before-ready behaviour dormant. Cheap by construction: neither
91
+ branch performs a network probe or provider auto-detection.
92
+
93
+ Returns:
94
+ bool: True when warm-before-ready ``/health`` gating should apply.
95
+ """
96
+ from reflexio.server.llm.providers.embedding_service_provider import (
97
+ embedding_provider_mode,
98
+ )
99
+
100
+ try:
101
+ mode = embedding_provider_mode()
102
+ except Exception: # noqa: BLE001
103
+ # An invalid REFLEXIO_EMBEDDING_PROVIDER must not turn /health (polled on
104
+ # every ALB probe) into a 500 — treat unresolvable config as gate-off.
105
+ _LOGGER.debug(
106
+ "Embedding provider mode unresolvable; gate inactive", exc_info=True
107
+ )
108
+ return False
109
+ if mode != _INPROCESS:
110
+ return False
111
+
112
+ from reflexio.server.llm.model_defaults import ModelRole, resolve_model_name
113
+
114
+ try:
115
+ model = resolve_model_name(ModelRole.EMBEDDING)
116
+ except Exception: # noqa: BLE001
117
+ # No embedding-capable provider resolvable — cannot be the in-process
118
+ # local path, so the gate stays inactive rather than wedging /health.
119
+ _LOGGER.debug("Embedding model resolution failed; gate inactive", exc_info=True)
120
+ return False
121
+ return model.startswith("local/")
122
+
123
+
124
+ def _resolve_local_embedder_loader() -> Callable[[], object] | None:
125
+ """Return a zero-arg loader for the embedder matching the resolved local model.
126
+
127
+ Dispatches on the SAME model the gate resolved so readiness reflects the
128
+ embedder that will actually serve: ``local/nomic-*`` -> ``NomicEmbedder``
129
+ (sentence-transformers), any other ``local/*`` -> ``LocalEmbedder`` (chromadb
130
+ ONNX, e.g. the OSS ``local/minilm-l6-v2`` default). Returns None when no
131
+ in-process local model resolves.
132
+
133
+ Returns:
134
+ Callable[[], object] | None: A loader that warms the correct singleton,
135
+ or None if the resolved model is not an in-process local one.
136
+ """
137
+ from reflexio.server.llm.model_defaults import ModelRole, resolve_model_name
138
+
139
+ try:
140
+ model = resolve_model_name(ModelRole.EMBEDDING)
141
+ except Exception: # noqa: BLE001
142
+ return None
143
+ if not model.startswith("local/"):
144
+ return None
145
+ if "nomic" in model:
146
+ from reflexio.server.llm.providers.nomic_embedding_provider import NomicEmbedder
147
+
148
+ return lambda: NomicEmbedder.get()._load()
149
+ from reflexio.server.llm.providers.local_embedding_provider import LocalEmbedder
150
+
151
+ return lambda: LocalEmbedder.get()._load()
152
+
153
+
154
+ def _warm_embedder() -> None:
155
+ """Load the resolved in-process local embedder, then flip the readiness signal.
156
+
157
+ Warms whichever embedder the gate's resolved ``local/*`` model maps to (see
158
+ :func:`_resolve_local_embedder_loader`), so readiness reflects the model that
159
+ will actually serve — not a hardcoded one. Fire-and-forget in a daemon thread
160
+ so a slow load never blocks startup; a bounded retry absorbs a transient blip.
161
+ On final failure the readiness signal stays clear and ``/health`` keeps
162
+ reporting not-ready — the intended fail-safe (deploy fails loud, old rev serves).
163
+ """
164
+ loader = _resolve_local_embedder_loader()
165
+ if loader is None:
166
+ _LOGGER.warning(
167
+ "Warmup gate active but no in-process local embedder resolved; "
168
+ "/health stays not-ready."
169
+ )
170
+ return
171
+ for attempt in range(1, _WARM_MAX_ATTEMPTS + 1):
172
+ try:
173
+ loader()
174
+ mark_embedder_ready()
175
+ _LOGGER.info("In-process embedder warm; /health now reports ready.")
176
+ return
177
+ except Exception: # noqa: BLE001
178
+ _LOGGER.warning(
179
+ "In-process embedder warmup attempt %d/%d failed.",
180
+ attempt,
181
+ _WARM_MAX_ATTEMPTS,
182
+ exc_info=True,
183
+ )
184
+ if attempt < _WARM_MAX_ATTEMPTS:
185
+ time.sleep(_WARM_RETRY_BACKOFF_S * attempt)
186
+ _LOGGER.error(
187
+ "In-process embedder warmup exhausted %d attempts; /health stays not-ready.",
188
+ _WARM_MAX_ATTEMPTS,
189
+ )
190
+
191
+
192
+ def maybe_start_embedder_warmup() -> bool:
193
+ """Spawn the non-blocking warm-before-ready thread when the gate is active.
194
+
195
+ Returns:
196
+ bool: True when a warmup thread was started (gate active), else False.
197
+ """
198
+ if not inprocess_local_gate_active():
199
+ return False
200
+ threading.Thread(target=_warm_embedder, daemon=True, name="embedder-warmup").start()
201
+ return True
202
+
203
+
204
+ def _detected_worker_count() -> int | None:
205
+ """Best-effort read of the configured uvicorn worker count.
206
+
207
+ uvicorn exposes no worker-count env inside a worker process, so
208
+ ``reflexio.server.__main__`` records it in ``REFLEXIO_SERVER_WORKERS``. A
209
+ ``WEB_CONCURRENCY`` fallback covers gunicorn-style entrypoints. Returns
210
+ ``None`` when neither is set (custom entrypoint) — the guard then warns that
211
+ it could not verify the count rather than refusing.
212
+
213
+ Returns:
214
+ int | None: The detected worker count, or None if undetectable.
215
+ """
216
+ for name in (_ENV_WORKERS, _ENV_WEB_CONCURRENCY):
217
+ raw = os.environ.get(name)
218
+ if raw:
219
+ try:
220
+ return int(raw)
221
+ except ValueError:
222
+ continue
223
+ return None
224
+
225
+
226
+ def _guard_workers_multiply_model() -> None:
227
+ """D8(a): warn when in-process mode runs under multiple workers.
228
+
229
+ In-process embedding loads one model copy per worker process, so a
230
+ memory-bounded host that is fine with 1 worker can OOM at N. Warn-only: the
231
+ worker count is not always reliably detectable from inside a worker.
232
+ """
233
+ if _provider() != _INPROCESS:
234
+ return
235
+ count = _detected_worker_count()
236
+ if count is not None and count > 1:
237
+ _LOGGER.warning(
238
+ "%s=%s with %d uvicorn workers: the in-process embedder loads one "
239
+ "model copy PER worker process, multiplying memory on a "
240
+ "memory-bounded task. Prefer 1 worker for in-process embedding, or "
241
+ "run the shared embedding daemon.",
242
+ _ENV_PROVIDER,
243
+ _INPROCESS,
244
+ count,
245
+ )
246
+ elif count is None:
247
+ _LOGGER.warning(
248
+ "%s=%s but the uvicorn worker count could not be verified (neither "
249
+ "%s nor %s is set). If this deployment runs multiple workers, each "
250
+ "loads its own in-process model copy.",
251
+ _ENV_PROVIDER,
252
+ _INPROCESS,
253
+ _ENV_WORKERS,
254
+ _ENV_WEB_CONCURRENCY,
255
+ )
256
+
257
+
258
+ def _guard_daemon_disable_half_pair() -> None:
259
+ """D8(b): warn when the daemon-disable flag and provider disagree.
260
+
261
+ ``REFLEXIO_DISABLE_LOCAL_EMBEDDING_DAEMON`` (truthy) and
262
+ ``REFLEXIO_EMBEDDING_PROVIDER=inprocess`` describe the same intent from two
263
+ angles and are meant to be flipped together. If exactly one is set the
264
+ topology is half-configured (e.g. the daemon is disabled but requests still
265
+ route to daemon mode, or vice-versa).
266
+ """
267
+ from reflexio.server.env_utils import env_truthy
268
+
269
+ daemon_disabled = env_truthy(os.environ.get(_ENV_DISABLE_DAEMON, ""))
270
+ inprocess = _provider() == _INPROCESS
271
+ if daemon_disabled != inprocess:
272
+ _LOGGER.warning(
273
+ "Half-configured in-process embedding: %s=%s and %s=%s disagree. "
274
+ "Set them together (disable the daemon AND select the in-process "
275
+ "provider) or neither.",
276
+ _ENV_DISABLE_DAEMON,
277
+ daemon_disabled,
278
+ _ENV_PROVIDER,
279
+ _provider() or "<unset>",
280
+ )
281
+
282
+
283
+ def _guard_inprocess_overrides_service_host() -> None:
284
+ """D8(c): warn when in-process mode is set alongside a configured service host.
285
+
286
+ ``REFLEXIO_EMBEDDING_PROVIDER=inprocess`` wins the provider-precedence race in
287
+ ``embedding_provider_mode`` outright, so a co-configured
288
+ ``REFLEXIO_EMBEDDING_SERVICE_URL`` / ``REFLEXIO_EMBEDDING_DAEMON_HOST`` is
289
+ silently ignored and this process loads its own in-process local model instead
290
+ of routing to the service. On a GPU-service / self-host fleet that means an
291
+ unintended per-instance CPU model load. Warn-only: the intended in-process
292
+ flip leaves both endpoint envs unset, so this stays silent there.
293
+ """
294
+ if _provider() != _INPROCESS:
295
+ return
296
+ configured = [
297
+ name
298
+ for name in (_ENV_SERVICE_URL, _ENV_DAEMON_HOST)
299
+ if os.environ.get(name, "").strip()
300
+ ]
301
+ if not configured:
302
+ return
303
+ _LOGGER.warning(
304
+ "%s=%s takes precedence and loads an in-process local model, silently "
305
+ "ignoring the configured service endpoint(s): %s. Unset the endpoint(s) "
306
+ "to keep the in-process embedder, or unset %s to route embeddings to the "
307
+ "service.",
308
+ _ENV_PROVIDER,
309
+ _INPROCESS,
310
+ ", ".join(configured),
311
+ _ENV_PROVIDER,
312
+ )
313
+
314
+
315
+ def run_startup_config_guards() -> None:
316
+ """Run the D8 config guards once at server startup (idempotent, warn-only)."""
317
+ _guard_workers_multiply_model()
318
+ _guard_daemon_disable_half_pair()
319
+ _guard_inprocess_overrides_service_host()
320
+
321
+
322
+ __all__ = [
323
+ "inprocess_local_gate_active",
324
+ "is_embedder_ready",
325
+ "mark_embedder_ready",
326
+ "maybe_start_embedder_warmup",
327
+ "reset_warmup_state_for_test",
328
+ "run_startup_config_guards",
329
+ ]
@@ -54,9 +54,19 @@ _LOCAL_SERVICE_PROBE_FAILURE_CACHE_SECONDS = 5.0
54
54
  # never hands out a connection the load balancer already closed.
55
55
  _HTTP_KEEPALIVE_EXPIRY_SECONDS = 50.0
56
56
  _EMBEDDING_RETRY_BACKOFF_SECONDS = 0.1
57
+ # ``embedding_provider_mode`` is called on every embedding request (often several
58
+ # times per request), and a down daemon resolves to ``inprocess`` every time, so
59
+ # an unconditional warning would flood the logs. Emit the fallback WARNING at most
60
+ # once per this interval per process.
61
+ _INPROCESS_FALLBACK_WARN_INTERVAL_SECONDS = 60.0
57
62
  _SERVICE_MODES = {"local_service", "internal_service"}
58
63
  _VALID_MODES = {"cloud", *_SERVICE_MODES, "inprocess", "off"}
59
64
  _local_service_probe_cache: tuple[float, bool, str | None] | None = None
65
+ # Reason the most recent *fresh* /health probe failed (populated by
66
+ # ``_local_service_status``), surfaced in the inprocess-fallback WARNING.
67
+ _last_probe_failure_reason: str | None = None
68
+ # Monotonic timestamp of the last inprocess-fallback WARNING, for rate limiting.
69
+ _last_inprocess_fallback_warn_at: float | None = None
60
70
  _http_client_lock = threading.Lock()
61
71
  _http_client_instance: httpx.Client | None = None
62
72
  _http_client_pid: int | None = None
@@ -154,7 +164,7 @@ def _http_client() -> httpx.Client:
154
164
 
155
165
 
156
166
  def _local_service_status() -> tuple[bool, str | None]:
157
- global _local_service_probe_cache
167
+ global _local_service_probe_cache, _last_probe_failure_reason
158
168
  now = time.monotonic()
159
169
  if _local_service_probe_cache is not None:
160
170
  cached_at, cached_reachable, cached_model = _local_service_probe_cache
@@ -166,6 +176,7 @@ def _local_service_status() -> tuple[bool, str | None]:
166
176
  if now - cached_at < ttl:
167
177
  return cached_reachable, cached_model
168
178
 
179
+ reason: str | None = None
169
180
  try:
170
181
  response = _http_client().get(
171
182
  f"{_local_service_url()}/health",
@@ -175,19 +186,53 @@ def _local_service_status() -> tuple[bool, str | None]:
175
186
  active_model = response.json().get("active_model") if reachable else None
176
187
  if not isinstance(active_model, str):
177
188
  active_model = None
178
- except httpx.HTTPError:
189
+ if not reachable:
190
+ reason = f"/health returned HTTP {response.status_code}"
191
+ except httpx.HTTPError as exc:
179
192
  reachable = False
180
193
  active_model = None
181
- except ValueError:
194
+ reason = f"{type(exc).__name__}: {exc}"
195
+ except ValueError as exc:
182
196
  reachable = False
183
197
  active_model = None
198
+ reason = f"invalid /health response: {exc}"
199
+ _last_probe_failure_reason = reason
184
200
  _local_service_probe_cache = (now, reachable, active_model)
185
201
  return reachable, active_model
186
202
 
187
203
 
188
- def _local_service_supports_model(model: str | None) -> bool:
189
- reachable, active_model = _local_service_status()
190
- return reachable and (active_model is None or active_model == model)
204
+ def _warn_inprocess_fallback(model: str | None, reason: str | None) -> None:
205
+ """Warn (rate-limited) that a ``local/*`` model fell back to the in-process embedder.
206
+
207
+ Emitted only when daemon-mode resolution for a ``local/*`` model fails the
208
+ ``/health`` probe (or the daemon serves a different model) and routing falls
209
+ back to ``inprocess`` — NOT when ``inprocess`` was configured explicitly. The
210
+ fallback loads a second copy of the embedding model into this worker process,
211
+ so operators need to distinguish it from an intentional in-process config.
212
+
213
+ Args:
214
+ model (str | None): The ``local/*`` embedding model being resolved.
215
+ reason (str | None): Why the daemon path was rejected, if known.
216
+ """
217
+ global _last_inprocess_fallback_warn_at
218
+ now = time.monotonic()
219
+ if (
220
+ _last_inprocess_fallback_warn_at is not None
221
+ and now - _last_inprocess_fallback_warn_at
222
+ < _INPROCESS_FALLBACK_WARN_INTERVAL_SECONDS
223
+ ):
224
+ return
225
+ _last_inprocess_fallback_warn_at = now
226
+ _LOGGER.warning(
227
+ "Embedding daemon probe at %s failed for model %r; falling back to an "
228
+ "in-process embedding model copy (a second model is loaded into this "
229
+ "worker process). Probe failure: %s. Further fallback warnings are "
230
+ "suppressed for %.0fs.",
231
+ _local_service_url(),
232
+ model,
233
+ reason or "unknown",
234
+ _INPROCESS_FALLBACK_WARN_INTERVAL_SECONDS,
235
+ )
191
236
 
192
237
 
193
238
  def _ordered_embeddings_from_response(
@@ -260,7 +305,18 @@ def embedding_provider_mode(model: str | None = None) -> EmbeddingProviderMode:
260
305
  return "local_service"
261
306
 
262
307
  if _is_local_model(model):
263
- return "local_service" if _local_service_supports_model(model) else "inprocess"
308
+ reachable, active_model = _local_service_status()
309
+ if reachable and (active_model is None or active_model == model):
310
+ return "local_service"
311
+ if reachable:
312
+ reason = (
313
+ f"daemon is reachable but serves active_model={active_model!r}, "
314
+ f"not {model!r}"
315
+ )
316
+ else:
317
+ reason = _last_probe_failure_reason
318
+ _warn_inprocess_fallback(model, reason)
319
+ return "inprocess"
264
320
  return "cloud"
265
321
 
266
322
 
@@ -311,7 +367,12 @@ def get_service_embeddings(
311
367
  chunk = texts[start : start + chunk_size]
312
368
  embeddings.extend(
313
369
  _post_embedding_batch(
314
- url, model=model, texts=chunk, dimensions=dimensions, timeout=timeout
370
+ url,
371
+ model=model,
372
+ texts=chunk,
373
+ dimensions=dimensions,
374
+ timeout=timeout,
375
+ mode=mode,
315
376
  )
316
377
  )
317
378
  return embeddings
@@ -324,6 +385,7 @@ def _post_embedding_batch(
324
385
  texts: list[str],
325
386
  dimensions: int | None,
326
387
  timeout: float,
388
+ mode: EmbeddingProviderMode,
327
389
  ) -> list[list[float]]:
328
390
  """POST one bounded batch of texts to the embedding service."""
329
391
  payload: dict[str, Any] = {"model": model, "input": texts}
@@ -368,9 +430,22 @@ def _post_embedding_batch(
368
430
  last_error = exc
369
431
  break
370
432
 
371
- _LOGGER.warning("Embedding service unavailable at %s: %s", url, last_error)
433
+ # For HTTP-status failures the response body carries the actionable
434
+ # detail (e.g. the daemon's 409 "already owns model X" message) — without
435
+ # it the log reads as a connectivity problem when it is a config conflict.
436
+ error_detail = str(last_error)
437
+ if isinstance(last_error, httpx.HTTPStatusError):
438
+ body = last_error.response.text.strip()
439
+ if body:
440
+ error_detail = f"{last_error} — response body: {body[:300]}"
441
+ _LOGGER.warning(
442
+ "Embedding service unavailable at %s: %s",
443
+ url,
444
+ error_detail,
445
+ extra={"mode": mode},
446
+ )
372
447
  raise EmbeddingUnavailableError(
373
- f"Embedding service unavailable at {url}: {last_error}"
448
+ f"Embedding service unavailable at {url}: {error_detail}"
374
449
  ) from last_error
375
450
 
376
451