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
@@ -22,6 +22,7 @@ from typing import TYPE_CHECKING, Any, cast
22
22
 
23
23
  from reflexio.models.api_schema.retriever_schema import (
24
24
  ConversationTurn,
25
+ ReformulationResult,
25
26
  SearchAgentPlaybookRequest,
26
27
  SearchUserPlaybookRequest,
27
28
  SearchUserProfileRequest,
@@ -50,6 +51,15 @@ from reflexio.server.services.retrieval.recency import (
50
51
  multiplicative_factor,
51
52
  )
52
53
  from reflexio.server.services.retrieval.relevance_floor import apply_relevance_floors
54
+ from reflexio.server.services.retrieval.session_dedup import (
55
+ EntityKey,
56
+ session_seen_cache,
57
+ )
58
+ from reflexio.server.services.retrieval.temporal import (
59
+ freshness_collapse,
60
+ sort_by_recency,
61
+ window_bounds,
62
+ )
53
63
  from reflexio.server.services.storage.storage_base import BaseStorage
54
64
  from reflexio.server.tracing import profile_step, set_span_data
55
65
 
@@ -76,6 +86,20 @@ _SEARCH_FANOUT_EXECUTOR = ThreadPoolExecutor(
76
86
  thread_name_prefix="reflexio-search",
77
87
  )
78
88
  _ENV_SINGLE_RPC = "REFLEXIO_UNIFIED_SEARCH_SINGLE_RPC"
89
+ # Working-pool floor for temporal reordering (freshness collapse / recency
90
+ # sort): matches the RecencyConfig default pool so the fresh version of a
91
+ # fact can be promoted even when it ranks below top_k on text relevance.
92
+ _TEMPORAL_POOL_SIZE = 20
93
+ # Session dedup widens the fetch pool so filtered-out (already served) items
94
+ # can be backfilled, but never by more than this many extra candidates.
95
+ _SESSION_DEDUP_FETCH_BUMP_CAP = 50
96
+ # Singular dedup kinds (session_dedup EntityKey vocabulary) — deliberately
97
+ # distinct from the plural request-level ``entity_types`` values.
98
+ _ENTITY_ID_ATTRS = {
99
+ "profile": "profile_id",
100
+ "user_playbook": "user_playbook_id",
101
+ "agent_playbook": "agent_playbook_id",
102
+ }
79
103
  _EMBEDDING_CACHE_TTL_SECONDS = max(
80
104
  0, int(os.getenv("REFLEXIO_QUERY_EMBEDDING_CACHE_TTL_SECONDS", "300") or "300")
81
105
  )
@@ -139,14 +163,9 @@ def run_unified_search(
139
163
  floor_cfg = retrieval_floor or RetrievalFloorConfig()
140
164
  floor_on = floor_cfg.enabled
141
165
  recency_on = bool(recency and recency.enabled)
142
- fetch_k = max(
143
- top_k,
144
- floor_cfg.pool_size if floor_on else 0,
145
- recency.pool_size if recency_on and recency is not None else 0,
146
- )
147
166
 
148
- # --- Phase A: query reformulation + embedding generation ---
149
- reformulated_query, embedding = _run_phase_a(
167
+ # --- Phase A: query reformulation (+ temporal signals) + embedding ---
168
+ reformulation, embedding, embedding_failed = _run_phase_a(
150
169
  query=request.query,
151
170
  storage=storage,
152
171
  llm_client=llm_client,
@@ -157,6 +176,50 @@ def run_unified_search(
157
176
  pre_retrieval_model_name=pre_retrieval_model_name,
158
177
  search_mode=request.search_mode,
159
178
  )
179
+ reformulated_query = reformulation.standalone_query
180
+
181
+ # Degrade-to-FTS: when embedding GENERATION failed (distinct from the
182
+ # benign None cases), force the effective mode to FTS for the entire
183
+ # Phase B fan-out. Storage then takes the zero-placeholder branch and
184
+ # never re-embeds — closing the historical second-embed leak where a
185
+ # None embedding + a vector/hybrid mode caused the storage layer to retry
186
+ # the embed and raise unguarded. Benign None (FTS-only, unsupported)
187
+ # leaves the requested mode untouched.
188
+ effective_search_mode = request.search_mode
189
+ if embedding_failed:
190
+ effective_search_mode = SearchMode.FTS
191
+ logger.warning(
192
+ "event=search_degraded_to_fts reason=embedding_generation_failed "
193
+ "backend=%s requested_mode=%s",
194
+ _storage_backend_name(storage),
195
+ request.search_mode.value,
196
+ )
197
+ window_start, window_end = window_bounds(
198
+ reformulation.start_days_ago, reformulation.end_days_ago
199
+ )
200
+
201
+ # Temporal reordering (freshness collapse / recency sort) happens after
202
+ # relevance ranking, so it needs a wider working pool: the fresh version
203
+ # of a fact may rank below top_k on text relevance alone. When signals
204
+ # are present, fetch and rank a larger pool and cut to top_k only after
205
+ # the temporal pass.
206
+ temporal_reorder = reformulation.wants_current or reformulation.recency_dominant
207
+ fetch_k = max(
208
+ top_k,
209
+ floor_cfg.pool_size if floor_on else 0,
210
+ recency.pool_size if recency_on and recency is not None else 0,
211
+ _TEMPORAL_POOL_SIZE if temporal_reorder else 0,
212
+ )
213
+ result_cap = max(top_k, _TEMPORAL_POOL_SIZE) if temporal_reorder else top_k
214
+
215
+ # Session dedup: a request that carries a session_id skips items already
216
+ # served to that (org, session) and backfills from a widened pool.
217
+ session_id = (request.session_id or "").strip()
218
+ seen_keys = (
219
+ session_seen_cache.seen(org_id, session_id) if session_id else frozenset()
220
+ )
221
+ if seen_keys:
222
+ fetch_k += min(len(seen_keys), _SESSION_DEDUP_FETCH_BUMP_CAP)
160
223
 
161
224
  # --- Phase B: parallel searches across all entity types ---
162
225
  profiles, agent_playbooks, user_playbooks = _run_phase_b(
@@ -168,41 +231,65 @@ def run_unified_search(
168
231
  top_k=fetch_k,
169
232
  threshold=threshold,
170
233
  recency_on=recency_on,
234
+ start_time=window_start,
235
+ end_time=window_end,
236
+ search_mode=effective_search_mode,
171
237
  )
172
238
 
173
239
  if profiles is None:
174
240
  return UnifiedSearchResponse(success=False, msg="Search failed")
175
241
 
242
+ if seen_keys:
243
+ # Drop before the floors so seen items spend no cross-encoder budget.
244
+ profiles = _drop_seen_items(profiles or [], "profile", seen_keys)
245
+ agent_playbooks = _drop_seen_items(
246
+ agent_playbooks or [], "agent_playbook", seen_keys
247
+ )
248
+ user_playbooks = _drop_seen_items(
249
+ user_playbooks or [], "user_playbook", seen_keys
250
+ )
251
+
176
252
  if floor_on:
177
253
  profiles, agent_playbooks, user_playbooks = _apply_floors(
178
254
  query=reformulated_query,
179
255
  profiles=profiles,
180
256
  agent_playbooks=agent_playbooks, # type: ignore[arg-type]
181
257
  user_playbooks=user_playbooks, # type: ignore[arg-type]
182
- top_k=top_k,
258
+ top_k=result_cap,
183
259
  cfg=floor_cfg,
184
260
  recency=recency if recency_on else None,
185
261
  )
186
262
  elif recency_on and recency is not None:
187
263
  profiles = _apply_combined_score_recency(
188
- profiles or [], entity_type="profiles", top_k=top_k, cfg=recency
264
+ profiles or [], entity_type="profiles", top_k=result_cap, cfg=recency
189
265
  )
190
266
  agent_playbooks = _apply_combined_score_recency(
191
267
  agent_playbooks or [],
192
268
  entity_type="agent_playbooks",
193
- top_k=top_k,
269
+ top_k=result_cap,
194
270
  cfg=recency,
195
271
  )
196
272
  user_playbooks = _apply_combined_score_recency(
197
273
  user_playbooks or [],
198
274
  entity_type="user_playbooks",
199
- top_k=top_k,
275
+ top_k=result_cap,
200
276
  cfg=recency,
201
277
  )
202
278
  else:
203
- profiles = _unwrap_items(profiles or [])[:top_k]
204
- agent_playbooks = _unwrap_items(agent_playbooks or [])[:top_k]
205
- user_playbooks = _unwrap_items(user_playbooks or [])[:top_k]
279
+ profiles = _unwrap_items(profiles or [])[:result_cap]
280
+ agent_playbooks = _unwrap_items(agent_playbooks or [])[:result_cap]
281
+ user_playbooks = _unwrap_items(user_playbooks or [])[:result_cap]
282
+
283
+ # --- Temporal post-processing from query-derived signals ---
284
+ if temporal_reorder:
285
+ arms = [profiles or [], agent_playbooks or [], user_playbooks or []]
286
+ if reformulation.wants_current:
287
+ arms = [freshness_collapse(arm) for arm in arms]
288
+ if reformulation.recency_dominant:
289
+ arms = [sort_by_recency(arm) for arm in arms]
290
+ # Final cut to top_k only after the temporal pass, so items promoted
291
+ # from the wider pool can take the top slots.
292
+ profiles, agent_playbooks, user_playbooks = (arm[:top_k] for arm in arms)
206
293
 
207
294
  user_playbooks = _suppress_source_user_playbooks(
208
295
  storage=storage,
@@ -218,7 +305,11 @@ def run_unified_search(
218
305
  reformulated_query=reformulated_query
219
306
  if reformulated_query != request.query
220
307
  else None,
308
+ degraded=embedding_failed,
309
+ search_mode_effective=effective_search_mode.value if embedding_failed else None,
221
310
  )
311
+ if session_id:
312
+ session_seen_cache.record(org_id, session_id, _served_entity_keys(response))
222
313
  _maybe_capture_final_response(
223
314
  request=request,
224
315
  response=response,
@@ -257,7 +348,7 @@ def _run_phase_a(
257
348
  enable_reformulation: bool = False,
258
349
  pre_retrieval_model_name: str | None = None,
259
350
  search_mode: SearchMode = SearchMode.HYBRID,
260
- ) -> tuple[str, list[float] | None]:
351
+ ) -> tuple[ReformulationResult, list[float] | None, bool]:
261
352
  """Run query reformulation and embedding generation sequentially.
262
353
 
263
354
  Args:
@@ -273,7 +364,16 @@ def _run_phase_a(
273
364
  search_mode (SearchMode): Search mode; FTS-only mode skips embedding generation entirely
274
365
 
275
366
  Returns:
276
- tuple[str, Optional[list[float]]]: (standalone_query, embedding_vector) — embedding is None when unsupported or on failure
367
+ tuple[ReformulationResult, Optional[list[float]], bool]: The
368
+ reformulation result (standalone query + temporal signals;
369
+ signal-free pass-through of the original query when reformulation
370
+ is disabled), the query embedding (None when unsupported, when the
371
+ mode is FTS-only, or when generation failed), and
372
+ ``embedding_failed`` — True *only* when an embedding attempt was
373
+ made and raised. The benign None cases (FTS-only mode,
374
+ ``supports_embedding`` False) leave ``embedding_failed`` False so
375
+ callers can distinguish a real failure from a mode that never
376
+ needed an embedding.
277
377
  """
278
378
  reformulator = QueryReformulator(
279
379
  llm_client=llm_client,
@@ -288,14 +388,14 @@ def _run_phase_a(
288
388
  has_conversation_history=bool(conversation_history),
289
389
  ):
290
390
  if enable_reformulation:
291
- result = reformulator.rewrite(query, conversation_history)
292
- standalone_query = result.standalone_query
391
+ reformulation = reformulator.rewrite(query, conversation_history)
293
392
  else:
294
- standalone_query = query
393
+ reformulation = ReformulationResult(standalone_query=query)
295
394
 
296
395
  # Embedding generation (uses reformulated query for semantic accuracy).
297
396
  # FTS-only search has no use for an embedding, so skip the call entirely.
298
397
  embedding = None
398
+ embedding_failed = False
299
399
  if supports_embedding and search_mode != SearchMode.FTS:
300
400
  with profile_step(
301
401
  "search.embedding",
@@ -303,13 +403,24 @@ def _run_phase_a(
303
403
  purpose="query",
304
404
  ) as span:
305
405
  try:
306
- embedding = _get_cached_query_embedding(storage, standalone_query)
406
+ embedding = _get_cached_query_embedding(
407
+ storage, reformulation.standalone_query
408
+ )
409
+ # A falsy result (None or []) means the embedder produced no
410
+ # usable query vector — e.g. a storage backend that swallows
411
+ # EmbeddingUnavailableError and returns [] on a provider outage.
412
+ # Treat it the same as a raised failure: degrade to FTS rather
413
+ # than run a vector search with an empty embedding.
414
+ if not embedding:
415
+ embedding = None
416
+ embedding_failed = True
307
417
  span.set_data("embedding_generated", embedding is not None)
308
418
  except Exception as e:
309
419
  span.set_data("embedding_generated", False)
310
- logger.error("Embedding generation failed: %s", e)
420
+ embedding_failed = True
421
+ logger.exception("Embedding generation failed: %s", e)
311
422
 
312
- return standalone_query, embedding
423
+ return reformulation, embedding, embedding_failed
313
424
 
314
425
 
315
426
  def _run_phase_b(
@@ -321,6 +432,9 @@ def _run_phase_b(
321
432
  top_k: int,
322
433
  threshold: float,
323
434
  recency_on: bool = False,
435
+ start_time: datetime | None = None,
436
+ end_time: datetime | None = None,
437
+ search_mode: SearchMode = SearchMode.HYBRID,
324
438
  ) -> tuple[
325
439
  list[Any] | None,
326
440
  list[Any] | None,
@@ -336,11 +450,19 @@ def _run_phase_b(
336
450
  query (str): Query string (possibly rewritten) for FTS
337
451
  top_k (int): Maximum results per entity type
338
452
  threshold (float): Minimum match threshold
453
+ start_time (Optional[datetime]): Query-derived time-window lower bound
454
+ (from reformulation temporal signals); forces the per-arm fan-out
455
+ path since the combined single RPC has no time parameters.
456
+ end_time (Optional[datetime]): Query-derived time-window upper bound.
457
+ search_mode (SearchMode): The EFFECTIVE search mode for this fan-out —
458
+ equal to ``request.search_mode`` normally, but forced to FTS by the
459
+ caller when embedding generation failed (degrade-to-FTS). Threaded
460
+ into every arm so storage never re-embeds a failed query.
339
461
 
340
462
  Returns:
341
463
  tuple: (profiles, agent_playbooks, user_playbooks) — all None on timeout/failure
342
464
  """
343
- options = SearchOptions(query_embedding=embedding, search_mode=request.search_mode)
465
+ options = SearchOptions(query_embedding=embedding, search_mode=search_mode)
344
466
 
345
467
  entity_types = set(request.entity_types or _DEFAULT_ENTITY_TYPES)
346
468
  allowed_agent_statuses = request.agent_playbook_status_filter
@@ -360,9 +482,16 @@ def _run_phase_b(
360
482
  wants_scored_single_rpc = recency_on and callable(
361
483
  getattr(storage, "unified_hybrid_search_scored", None)
362
484
  )
363
- if _unified_single_rpc_enabled() and (
364
- getattr(storage, "supports_unified_hybrid_search", False)
365
- or wants_scored_single_rpc
485
+ # A query-derived time window forces the per-arm fan-out: the
486
+ # combined single RPC has no time parameters.
487
+ has_time_window = start_time is not None or end_time is not None
488
+ if (
489
+ not has_time_window
490
+ and _unified_single_rpc_enabled()
491
+ and (
492
+ getattr(storage, "supports_unified_hybrid_search", False)
493
+ or wants_scored_single_rpc
494
+ )
366
495
  ):
367
496
  combined = _run_phase_b_single_rpc(
368
497
  request=request,
@@ -374,6 +503,7 @@ def _run_phase_b(
374
503
  entity_types=entity_types,
375
504
  allowed_agent_statuses=allowed_agent_statuses,
376
505
  recency_on=recency_on,
506
+ search_mode=search_mode,
377
507
  )
378
508
  if combined is not None:
379
509
  profiles, agent_playbooks, user_playbooks = combined
@@ -398,7 +528,9 @@ def _run_phase_b(
398
528
  threshold,
399
529
  request.user_id,
400
530
  embedding,
401
- request.search_mode,
531
+ search_mode,
532
+ start_time,
533
+ end_time,
402
534
  )
403
535
  if "profiles" in entity_types
404
536
  else None
@@ -415,6 +547,8 @@ def _run_phase_b(
415
547
  request.playbook_name,
416
548
  allowed_agent_statuses,
417
549
  options,
550
+ start_time,
551
+ end_time,
418
552
  )
419
553
  if "agent_playbooks" in entity_types
420
554
  else None
@@ -428,7 +562,9 @@ def _run_phase_b(
428
562
  status_filter=None,
429
563
  threshold=threshold,
430
564
  top_k=top_k,
431
- search_mode=request.search_mode,
565
+ search_mode=search_mode,
566
+ start_time=start_time,
567
+ end_time=end_time,
432
568
  )
433
569
  user_playbooks_future = _submit_with_current_context(
434
570
  _SEARCH_FANOUT_EXECUTOR,
@@ -485,6 +621,7 @@ def _run_phase_b_single_rpc(
485
621
  entity_types: set[str],
486
622
  allowed_agent_statuses: list[PlaybookStatus] | None,
487
623
  recency_on: bool = False,
624
+ search_mode: SearchMode = SearchMode.HYBRID,
488
625
  ) -> tuple[list[Any], list[Any], list[Any]] | None:
489
626
  """Run all Phase B arms through one combined storage round trip.
490
627
 
@@ -529,7 +666,7 @@ def _run_phase_b_single_rpc(
529
666
  agent_version=request.agent_version,
530
667
  playbook_name=request.playbook_name,
531
668
  agent_playbook_statuses=statuses,
532
- search_mode=request.search_mode,
669
+ search_mode=search_mode,
533
670
  include_profiles="profiles" in entity_types and bool(request.user_id),
534
671
  include_agent_playbooks="agent_playbooks" in entity_types,
535
672
  include_user_playbooks="user_playbooks" in entity_types,
@@ -664,6 +801,34 @@ def _unwrap_items(items: list[Any]) -> list[Any]:
664
801
  return [_unwrap_item(item) for item in items]
665
802
 
666
803
 
804
+ def _entity_key(kind: str, item: Any) -> EntityKey | None:
805
+ """Return the session-dedup key for a (possibly score-wrapped) item."""
806
+ raw_id = getattr(_unwrap_item(item), _ENTITY_ID_ATTRS[kind], None)
807
+ if raw_id in (None, "", 0):
808
+ return None
809
+ return (kind, str(raw_id))
810
+
811
+
812
+ def _drop_seen_items(
813
+ items: list[Any], kind: str, seen: frozenset[EntityKey]
814
+ ) -> list[Any]:
815
+ return [item for item in items if _entity_key(kind, item) not in seen]
816
+
817
+
818
+ def _served_entity_keys(response: UnifiedSearchResponse) -> list[EntityKey]:
819
+ arms: tuple[tuple[str, list[Any]], ...] = (
820
+ ("profile", response.profiles or []),
821
+ ("user_playbook", response.user_playbooks or []),
822
+ ("agent_playbook", response.agent_playbooks or []),
823
+ )
824
+ return [
825
+ key
826
+ for kind, items in arms
827
+ for item in items
828
+ if (key := _entity_key(kind, item)) is not None
829
+ ]
830
+
831
+
667
832
  def _suppress_source_user_playbooks(
668
833
  *,
669
834
  storage: BaseStorage,
@@ -764,12 +929,17 @@ def _search_agent_playbooks_via_storage(
764
929
  playbook_name: str | None,
765
930
  allowed_statuses: list[PlaybookStatus] | None,
766
931
  options: SearchOptions,
932
+ start_time: datetime | None = None,
933
+ end_time: datetime | None = None,
767
934
  ) -> list[AgentPlaybook]:
768
935
  """Search agent playbooks, restricted to one or more approval statuses.
769
936
 
770
937
  When ``allowed_statuses`` is None or empty, falls back to
771
938
  ``_DEFAULT_AGENT_PLAYBOOK_STATUSES`` (APPROVED + PENDING). Callers that
772
939
  genuinely want REJECTED playbooks must opt in by passing the full list.
940
+ ``start_time``/``end_time`` bound ``created_at`` (query-derived time
941
+ windows from reformulation temporal signals; unset when the query names
942
+ no window).
773
943
  """
774
944
  with profile_step(
775
945
  "search.branch.agent_playbooks",
@@ -790,6 +960,8 @@ def _search_agent_playbooks_via_storage(
790
960
  threshold=threshold,
791
961
  top_k=top_k,
792
962
  search_mode=options.search_mode,
963
+ start_time=start_time,
964
+ end_time=end_time,
793
965
  )
794
966
  results: list[AgentPlaybook] = []
795
967
  seen_ids: set[str] = set()
@@ -812,6 +984,8 @@ def _search_profiles_via_storage(
812
984
  user_id: str | None,
813
985
  embedding: list[float] | None,
814
986
  search_mode: SearchMode,
987
+ start_time: datetime | None = None,
988
+ end_time: datetime | None = None,
815
989
  ) -> list[UserProfile]:
816
990
  """Search profiles via storage.search_user_profile, returning [] on error or missing user_id.
817
991
 
@@ -823,6 +997,10 @@ def _search_profiles_via_storage(
823
997
  user_id (Optional[str]): User ID filter (required for profile search)
824
998
  embedding (Optional[list[float]]): Pre-computed query embedding, or None for text-only search
825
999
  search_mode (SearchMode): Search mode (hybrid/vector/fts)
1000
+ start_time (Optional[datetime]): Lower bound on last_modified_timestamp
1001
+ (query-derived time window from reformulation temporal signals;
1002
+ unset when the query names no window)
1003
+ end_time (Optional[datetime]): Upper bound on last_modified_timestamp
826
1004
 
827
1005
  Returns:
828
1006
  list[UserProfile]: Matching profiles, or [] on error/missing user_id
@@ -843,6 +1021,8 @@ def _search_profiles_via_storage(
843
1021
  top_k=top_k,
844
1022
  threshold=threshold,
845
1023
  search_mode=search_mode,
1024
+ start_time=start_time,
1025
+ end_time=end_time,
846
1026
  ),
847
1027
  status_filter=[None],
848
1028
  query_embedding=embedding,
@@ -27,6 +27,7 @@ class UsageEvent:
27
27
  pipeline: str | None = None
28
28
  entity_type: str | None = None
29
29
  entity_id: str | None = None
30
+ event_key: str | None = None
30
31
  extractor_name: str | None = None
31
32
  playbook_name: str | None = None
32
33
  source: str | None = None
@@ -72,6 +73,7 @@ def record_usage_event(
72
73
  pipeline: str | None = None,
73
74
  entity_type: str | None = None,
74
75
  entity_id: str | None = None,
76
+ event_key: str | None = None,
75
77
  extractor_name: str | None = None,
76
78
  playbook_name: str | None = None,
77
79
  source: str | None = None,
@@ -109,6 +111,7 @@ def record_usage_event(
109
111
  pipeline=pipeline,
110
112
  entity_type=entity_type,
111
113
  entity_id=entity_id,
114
+ event_key=event_key,
112
115
  extractor_name=extractor_name,
113
116
  playbook_name=playbook_name,
114
117
  source=source,
@@ -34,6 +34,7 @@ from __future__ import annotations
34
34
 
35
35
  import json
36
36
  import os
37
+ import re
37
38
  from collections.abc import Iterator
38
39
  from contextlib import contextmanager
39
40
  from typing import Any
@@ -42,6 +43,28 @@ from unittest.mock import MagicMock, patch
42
43
  from reflexio.models.structured_output import find_schema_keyword as _find_schema_key
43
44
  from reflexio.test_support.llm_model_registry import get_model_registry
44
45
 
46
+ _LEARNING_REF_PATTERN = re.compile(r'"learning_ref":\s*"([^"]+)"')
47
+
48
+
49
+ def _extract_learning_refs(prompt_content: str) -> list[str]:
50
+ """Extract unique learning_refs from a judge prompt, preserving order.
51
+
52
+ The retrieved-learning judges validate exact per-ref coverage, so the
53
+ mock must echo the refs actually listed in the prompt rather than a
54
+ static payload. Only the ``[Retrieved Learnings]`` payload section is
55
+ scanned — the prompt's ``[Output]`` example also contains a
56
+ ``"learning_ref"`` placeholder line that must not be echoed.
57
+ """
58
+ start = prompt_content.find("[Retrieved Learnings]")
59
+ if start != -1:
60
+ end = prompt_content.find("[Output]", start)
61
+ prompt_content = prompt_content[start : end if end != -1 else None]
62
+ seen: list[str] = []
63
+ for ref in _LEARNING_REF_PATTERN.findall(prompt_content):
64
+ if ref not in seen:
65
+ seen.append(ref)
66
+ return seen
67
+
45
68
 
46
69
  def _create_mock_completion(
47
70
  prompt_content: str, parse_structured_output: bool = False
@@ -55,6 +78,40 @@ def _create_mock_completion(
55
78
 
56
79
  if "Output just a boolean value" in prompt_content:
57
80
  content = str(registry["boolean_evaluation"].minimal_valid)
81
+ elif "[Retrieved Learning Relevance Evaluation]" in prompt_content:
82
+ # Echo the refs listed in the prompt — the judge validates exact
83
+ # coverage, so a static payload would always fail validation.
84
+ refs = _extract_learning_refs(prompt_content)
85
+ content = json.dumps(
86
+ {
87
+ "verdicts": [
88
+ {
89
+ "learning_ref": ref,
90
+ "is_relevant": True,
91
+ "relevance_reason": "Applies to the session task.",
92
+ }
93
+ for ref in refs
94
+ ]
95
+ }
96
+ if refs
97
+ else registry["retrieved_learning_relevance"].minimal_valid
98
+ )
99
+ elif "[Retrieved Learning Impact Evaluation]" in prompt_content:
100
+ refs = _extract_learning_refs(prompt_content)
101
+ content = json.dumps(
102
+ {
103
+ "verdicts": [
104
+ {
105
+ "learning_ref": ref,
106
+ "impact": "positive",
107
+ "impact_reason": "The response followed this learning.",
108
+ }
109
+ for ref in refs
110
+ ]
111
+ }
112
+ if refs
113
+ else registry["retrieved_learning_impact"].minimal_valid
114
+ )
58
115
  elif "policy consolidation" in prompt_content:
59
116
  content = json.dumps(registry["playbook_aggregation"].minimal_valid)
60
117
  elif (
@@ -72,6 +129,10 @@ def _create_mock_completion(
72
129
  content = json.dumps(registry["playbook_extraction"].minimal_valid)
73
130
  elif '"tags"' in prompt_content:
74
131
  content = json.dumps(registry["tagging"].minimal_valid)
132
+ elif "standalone_query" in prompt_content:
133
+ # Query-reformulation prompt (v2+, structured) — anchored on its
134
+ # primary output field name, which the prompt must describe.
135
+ content = json.dumps(registry["query_reformulation"].minimal_valid)
75
136
  elif parse_structured_output:
76
137
  content = json.dumps(registry["profile_extraction"].minimal_valid)
77
138
  else:
@@ -30,9 +30,14 @@ class ModelRegistryEntry:
30
30
  def _build_registry() -> dict[str, ModelRegistryEntry]:
31
31
  """Build the model registry with lazy imports to avoid circular dependencies."""
32
32
  from reflexio.models.api_schema.eval_overview_schema import ShadowComparisonOutput
33
+ from reflexio.models.api_schema.retriever_schema import ReformulationResult
33
34
  from reflexio.server.services.agent_success_evaluation.agent_success_evaluation_constants import (
34
35
  AgentSuccessEvaluationOutput,
35
36
  )
37
+ from reflexio.server.services.agent_success_evaluation.components.retrieved_learning_evaluator import (
38
+ RetrievedLearningImpactOutput,
39
+ RetrievedLearningRelevanceOutput,
40
+ )
36
41
  from reflexio.server.services.playbook.components.consolidator import (
37
42
  PlaybookConsolidationOutput,
38
43
  )
@@ -114,6 +119,30 @@ def _build_registry() -> dict[str, ModelRegistryEntry]:
114
119
  "is_escalated": False,
115
120
  },
116
121
  ),
122
+ "retrieved_learning_relevance": ModelRegistryEntry(
123
+ model_class=RetrievedLearningRelevanceOutput,
124
+ minimal_valid={
125
+ "verdicts": [
126
+ {
127
+ "learning_ref": "profile:mock-profile",
128
+ "is_relevant": True,
129
+ "relevance_reason": "Applies to the session task.",
130
+ },
131
+ ],
132
+ },
133
+ ),
134
+ "retrieved_learning_impact": ModelRegistryEntry(
135
+ model_class=RetrievedLearningImpactOutput,
136
+ minimal_valid={
137
+ "verdicts": [
138
+ {
139
+ "learning_ref": "profile:mock-profile",
140
+ "impact": "positive",
141
+ "impact_reason": "The response followed this learning.",
142
+ },
143
+ ],
144
+ },
145
+ ),
117
146
  "tagging": ModelRegistryEntry(
118
147
  model_class=TagsOutput,
119
148
  minimal_valid={"tags": ["example_tag"]},
@@ -152,6 +181,10 @@ def _build_registry() -> dict[str, ModelRegistryEntry]:
152
181
  model_class=None,
153
182
  minimal_valid="true",
154
183
  ),
184
+ "query_reformulation": ModelRegistryEntry(
185
+ model_class=ReformulationResult,
186
+ minimal_valid={"standalone_query": "example reformulated query"},
187
+ ),
155
188
  }
156
189
 
157
190