claude-smart 0.2.49 → 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 (177) hide show
  1. package/.claude-plugin/marketplace.json +2 -2
  2. package/README.md +10 -43
  3. package/bin/claude-smart.js +105 -0
  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/pyproject.toml +1 -1
  27. package/plugin/scripts/_lib.sh +197 -1
  28. package/plugin/scripts/backend-python-runner.py +46 -0
  29. package/plugin/scripts/backend-service.sh +757 -119
  30. package/plugin/scripts/codex-hook.js +63 -225
  31. package/plugin/scripts/dashboard-open.sh +6 -4
  32. package/plugin/scripts/dashboard-service.sh +117 -136
  33. package/plugin/scripts/hook_entry.sh +3 -0
  34. package/plugin/scripts/smart-install.sh +15 -1
  35. package/plugin/src/claude_smart/cli.py +14 -0
  36. package/plugin/src/claude_smart/context_inject.py +3 -0
  37. package/plugin/src/claude_smart/env_config.py +4 -1
  38. package/plugin/src/claude_smart/events/post_tool.py +2 -1
  39. package/plugin/src/claude_smart/events/session_end.py +2 -1
  40. package/plugin/src/claude_smart/events/stop.py +3 -0
  41. package/plugin/src/claude_smart/events/user_prompt.py +2 -1
  42. package/plugin/src/claude_smart/internal_call.py +5 -2
  43. package/plugin/src/claude_smart/optimizer_assistant.py +59 -13
  44. package/plugin/src/claude_smart/publish.py +59 -7
  45. package/plugin/src/claude_smart/reflexio_adapter.py +137 -14
  46. package/plugin/src/claude_smart/runtime.py +15 -6
  47. package/plugin/src/claude_smart/state.py +211 -52
  48. package/plugin/uv.lock +1 -1
  49. package/plugin/vendor/reflexio/.env.example +13 -0
  50. package/plugin/vendor/reflexio/reflexio/README.md +7 -3
  51. package/plugin/vendor/reflexio/reflexio/__init__.py +12 -0
  52. package/plugin/vendor/reflexio/reflexio/client/client.py +126 -3
  53. package/plugin/vendor/reflexio/reflexio/integrations/openclaw/plugin/src/openclaw_smart/state.py +10 -3
  54. package/plugin/vendor/reflexio/reflexio/integrations/openclaw/plugin/tests/test_state.py +28 -0
  55. package/plugin/vendor/reflexio/reflexio/lib/_search.py +41 -0
  56. package/plugin/vendor/reflexio/reflexio/models/api_schema/domain/entities.py +177 -25
  57. package/plugin/vendor/reflexio/reflexio/models/api_schema/eval_overview_schema.py +7 -1
  58. package/plugin/vendor/reflexio/reflexio/models/api_schema/internal_schema.py +2 -1
  59. package/plugin/vendor/reflexio/reflexio/models/api_schema/retriever_schema.py +63 -4
  60. package/plugin/vendor/reflexio/reflexio/models/api_schema/ui/converters.py +1 -0
  61. package/plugin/vendor/reflexio/reflexio/models/api_schema/ui/entities.py +8 -1
  62. package/plugin/vendor/reflexio/reflexio/models/config_schema.py +1 -1
  63. package/plugin/vendor/reflexio/reflexio/server/README.md +22 -4
  64. package/plugin/vendor/reflexio/reflexio/server/__init__.py +18 -8
  65. package/plugin/vendor/reflexio/reflexio/server/__main__.py +6 -0
  66. package/plugin/vendor/reflexio/reflexio/server/api.py +66 -3
  67. package/plugin/vendor/reflexio/reflexio/server/billing_meter.py +263 -3
  68. package/plugin/vendor/reflexio/reflexio/server/callback_executor.py +164 -0
  69. package/plugin/vendor/reflexio/reflexio/server/llm/_litellm_embedding.py +81 -81
  70. package/plugin/vendor/reflexio/reflexio/server/llm/_litellm_subprocess.py +28 -0
  71. package/plugin/vendor/reflexio/reflexio/server/llm/_litellm_text_generation.py +62 -5
  72. package/plugin/vendor/reflexio/reflexio/server/llm/embedding_service.py +19 -52
  73. package/plugin/vendor/reflexio/reflexio/server/llm/model_defaults.py +28 -0
  74. package/plugin/vendor/reflexio/reflexio/server/llm/providers/claude_code_provider.py +9 -1
  75. package/plugin/vendor/reflexio/reflexio/server/llm/providers/embedder_warmup.py +329 -0
  76. package/plugin/vendor/reflexio/reflexio/server/llm/providers/embedding_service_provider.py +85 -10
  77. package/plugin/vendor/reflexio/reflexio/server/llm/providers/local_embedding_provider.py +20 -5
  78. package/plugin/vendor/reflexio/reflexio/server/llm/providers/nomic_embedding_provider.py +77 -9
  79. package/plugin/vendor/reflexio/reflexio/server/org_fanout.py +184 -0
  80. package/plugin/vendor/reflexio/reflexio/server/prompt/prompt_bank/document_expansion/v1.0.0.prompt.md +1 -1
  81. package/plugin/vendor/reflexio/reflexio/server/prompt/prompt_bank/document_expansion/v1.1.0.prompt.md +32 -0
  82. package/plugin/vendor/reflexio/reflexio/server/prompt/prompt_bank/playbook_consolidation/v2.3.3.prompt.md +1 -1
  83. package/plugin/vendor/reflexio/reflexio/server/prompt/prompt_bank/playbook_consolidation/v2.4.0.prompt.md +63 -0
  84. package/plugin/vendor/reflexio/reflexio/server/prompt/prompt_bank/query_reformulation/v1.0.0.prompt.md +1 -1
  85. package/plugin/vendor/reflexio/reflexio/server/prompt/prompt_bank/query_reformulation/v2.0.0.prompt.md +30 -0
  86. package/plugin/vendor/reflexio/reflexio/server/prompt/prompt_bank/retrieved_learning_impact/v1.0.0.prompt.md +51 -0
  87. package/plugin/vendor/reflexio/reflexio/server/prompt/prompt_bank/retrieved_learning_relevance/v1.0.0.prompt.md +39 -0
  88. package/plugin/vendor/reflexio/reflexio/server/prompt/prompt_bank/shadow_comparison/v1.0.0.prompt.md +1 -1
  89. package/plugin/vendor/reflexio/reflexio/server/prompt/prompt_bank/shadow_comparison/v1.1.0.prompt.md +43 -0
  90. package/plugin/vendor/reflexio/reflexio/server/routes/config.py +3 -3
  91. package/plugin/vendor/reflexio/reflexio/server/routes/evaluation.py +122 -28
  92. package/plugin/vendor/reflexio/reflexio/server/routes/system.py +22 -3
  93. package/plugin/vendor/reflexio/reflexio/server/scheduling.py +64 -3
  94. package/plugin/vendor/reflexio/reflexio/server/services/README.md +6 -4
  95. package/plugin/vendor/reflexio/reflexio/server/services/agent_success_evaluation/README.md +3 -2
  96. package/plugin/vendor/reflexio/reflexio/server/services/agent_success_evaluation/_eval_health.py +41 -0
  97. package/plugin/vendor/reflexio/reflexio/server/services/agent_success_evaluation/components/retrieved_learning_evaluator.py +554 -0
  98. package/plugin/vendor/reflexio/reflexio/server/services/agent_success_evaluation/regen_jobs.py +35 -1
  99. package/plugin/vendor/reflexio/reflexio/server/services/agent_success_evaluation/runner.py +253 -101
  100. package/plugin/vendor/reflexio/reflexio/server/services/agent_success_evaluation/scheduler.py +5 -7
  101. package/plugin/vendor/reflexio/reflexio/server/services/agent_success_evaluation/service.py +27 -0
  102. package/plugin/vendor/reflexio/reflexio/server/services/base_generation/_extraction_lifecycle.py +11 -5
  103. package/plugin/vendor/reflexio/reflexio/server/services/base_generation/_should_run.py +4 -4
  104. package/plugin/vendor/reflexio/reflexio/server/services/base_generation/_usage_billing.py +6 -2
  105. package/plugin/vendor/reflexio/reflexio/server/services/base_generation_service.py +255 -74
  106. package/plugin/vendor/reflexio/reflexio/server/services/deduplication_utils.py +72 -0
  107. package/plugin/vendor/reflexio/reflexio/server/services/deferred_learning_plan.py +270 -0
  108. package/plugin/vendor/reflexio/reflexio/server/services/durable_learning/worker.py +85 -16
  109. package/plugin/vendor/reflexio/reflexio/server/services/evaluation_overview/components/hero_state.py +2 -11
  110. package/plugin/vendor/reflexio/reflexio/server/services/evaluation_overview/components/rule_attribution.py +6 -2
  111. package/plugin/vendor/reflexio/reflexio/server/services/evaluation_overview/service.py +17 -13
  112. package/plugin/vendor/reflexio/reflexio/server/services/extraction/agent_run_records.py +18 -3
  113. package/plugin/vendor/reflexio/reflexio/server/services/extraction/outcome.py +12 -1
  114. package/plugin/vendor/reflexio/reflexio/server/services/extraction/prior_answer_search.py +1 -1
  115. package/plugin/vendor/reflexio/reflexio/server/services/extraction/resumable_agent.py +2 -1
  116. package/plugin/vendor/reflexio/reflexio/server/services/extraction/resume_worker.py +66 -0
  117. package/plugin/vendor/reflexio/reflexio/server/services/generation_service.py +510 -63
  118. package/plugin/vendor/reflexio/reflexio/server/services/governance/service.py +2 -0
  119. package/plugin/vendor/reflexio/reflexio/server/services/lineage/gc_scheduler.py +179 -99
  120. package/plugin/vendor/reflexio/reflexio/server/services/lineage/vector_backfill_sweep.py +139 -0
  121. package/plugin/vendor/reflexio/reflexio/server/services/operation_state_utils.py +66 -27
  122. package/plugin/vendor/reflexio/reflexio/server/services/playbook/aggregation_trigger.py +177 -0
  123. package/plugin/vendor/reflexio/reflexio/server/services/playbook/components/aggregator.py +68 -2
  124. package/plugin/vendor/reflexio/reflexio/server/services/playbook/components/consolidator.py +360 -49
  125. package/plugin/vendor/reflexio/reflexio/server/services/playbook/components/extractor.py +27 -30
  126. package/plugin/vendor/reflexio/reflexio/server/services/playbook/playbook_edit_apply.py +8 -1
  127. package/plugin/vendor/reflexio/reflexio/server/services/playbook/service.py +137 -69
  128. package/plugin/vendor/reflexio/reflexio/server/services/playbook_optimizer/optimizer.py +20 -1
  129. package/plugin/vendor/reflexio/reflexio/server/services/playbook_optimizer/scheduler.py +13 -6
  130. package/plugin/vendor/reflexio/reflexio/server/services/pre_retrieval/_query_reformulator.py +40 -25
  131. package/plugin/vendor/reflexio/reflexio/server/services/profile/components/consolidator.py +12 -39
  132. package/plugin/vendor/reflexio/reflexio/server/services/profile/components/extractor.py +30 -35
  133. package/plugin/vendor/reflexio/reflexio/server/services/profile/service.py +122 -44
  134. package/plugin/vendor/reflexio/reflexio/server/services/reflection/service.py +457 -107
  135. package/plugin/vendor/reflexio/reflexio/server/services/retrieval/session_dedup.py +127 -0
  136. package/plugin/vendor/reflexio/reflexio/server/services/retrieval/temporal.py +104 -0
  137. package/plugin/vendor/reflexio/reflexio/server/services/shadow_comparison/dispatcher.py +139 -0
  138. package/plugin/vendor/reflexio/reflexio/server/services/shadow_comparison/judge.py +16 -6
  139. package/plugin/vendor/reflexio/reflexio/server/services/shadow_comparison/worker.py +137 -0
  140. package/plugin/vendor/reflexio/reflexio/server/services/storage/governance_validation.py +12 -0
  141. package/plugin/vendor/reflexio/reflexio/server/services/storage/lifecycle_filters.py +54 -0
  142. package/plugin/vendor/reflexio/reflexio/server/services/storage/retention.py +41 -3
  143. package/plugin/vendor/reflexio/reflexio/server/services/storage/retention_mixin.py +40 -2
  144. package/plugin/vendor/reflexio/reflexio/server/services/storage/sqlite_storage/__init__.py +1 -1
  145. package/plugin/vendor/reflexio/reflexio/server/services/storage/sqlite_storage/_base.py +50 -0
  146. package/plugin/vendor/reflexio/reflexio/server/services/storage/sqlite_storage/_extras.py +11 -4
  147. package/plugin/vendor/reflexio/reflexio/server/services/storage/sqlite_storage/_governance.py +28 -1
  148. package/plugin/vendor/reflexio/reflexio/server/services/storage/sqlite_storage/_learning_jobs.py +36 -1
  149. package/plugin/vendor/reflexio/reflexio/server/services/storage/sqlite_storage/_shadow_verdicts.py +4 -0
  150. package/plugin/vendor/reflexio/reflexio/server/services/storage/sqlite_storage/base/_deletion.py +16 -5
  151. package/plugin/vendor/reflexio/reflexio/server/services/storage/sqlite_storage/governance/_erase_execution.py +104 -42
  152. package/plugin/vendor/reflexio/reflexio/server/services/storage/sqlite_storage/governance/_subject_barrier.py +7 -1
  153. package/plugin/vendor/reflexio/reflexio/server/services/storage/sqlite_storage/playbook/_agent.py +43 -0
  154. package/plugin/vendor/reflexio/reflexio/server/services/storage/sqlite_storage/playbook/_eval_results.py +430 -5
  155. package/plugin/vendor/reflexio/reflexio/server/services/storage/sqlite_storage/playbook/_user.py +45 -7
  156. package/plugin/vendor/reflexio/reflexio/server/services/storage/sqlite_storage/profiles/_interaction_store.py +146 -6
  157. package/plugin/vendor/reflexio/reflexio/server/services/storage/sqlite_storage/profiles/_profile_store.py +42 -7
  158. package/plugin/vendor/reflexio/reflexio/server/services/storage/storage_base/__init__.py +1 -1
  159. package/plugin/vendor/reflexio/reflexio/server/services/storage/storage_base/_extras.py +9 -2
  160. package/plugin/vendor/reflexio/reflexio/server/services/storage/storage_base/_learning_jobs.py +50 -0
  161. package/plugin/vendor/reflexio/reflexio/server/services/storage/storage_base/_operations.py +7 -0
  162. package/plugin/vendor/reflexio/reflexio/server/services/storage/storage_base/_retrieval_log.py +3 -1
  163. package/plugin/vendor/reflexio/reflexio/server/services/storage/storage_base/_shadow_verdicts.py +4 -0
  164. package/plugin/vendor/reflexio/reflexio/server/services/storage/storage_base/agent_run/_models.py +12 -1
  165. package/plugin/vendor/reflexio/reflexio/server/services/storage/storage_base/evaluation_state_keys.py +78 -0
  166. package/plugin/vendor/reflexio/reflexio/server/services/storage/storage_base/playbook/_agent.py +38 -0
  167. package/plugin/vendor/reflexio/reflexio/server/services/storage/storage_base/playbook/_eval_results.py +171 -0
  168. package/plugin/vendor/reflexio/reflexio/server/services/storage/storage_base/playbook/_user.py +52 -1
  169. package/plugin/vendor/reflexio/reflexio/server/services/storage/storage_base/profiles/_interaction_store.py +59 -0
  170. package/plugin/vendor/reflexio/reflexio/server/services/storage/storage_base/profiles/_profile_store.py +52 -2
  171. package/plugin/vendor/reflexio/reflexio/server/services/storage/storage_base/retrieved_learning_state.py +226 -0
  172. package/plugin/vendor/reflexio/reflexio/server/services/tagging/tagging_scheduler.py +5 -6
  173. package/plugin/vendor/reflexio/reflexio/server/services/unified_search_service.py +209 -29
  174. package/plugin/vendor/reflexio/reflexio/server/usage_metrics.py +3 -0
  175. package/plugin/vendor/reflexio/reflexio/test_support/llm_mock.py +61 -0
  176. package/plugin/vendor/reflexio/reflexio/test_support/llm_model_registry.py +33 -0
  177. package/plugin/vendor/reflexio/reflexio/server/services/search/__init__.py +0 -0
@@ -5,6 +5,7 @@ Base class for generation services
5
5
  import logging
6
6
  import re
7
7
  import time
8
+ import unicodedata
8
9
  import uuid
9
10
  from abc import ABC, abstractmethod
10
11
  from dataclasses import dataclass
@@ -25,6 +26,10 @@ from reflexio.server.services.base_generation import (
25
26
  from reflexio.server.services.base_generation import (
26
27
  StatusChangeOperation as StatusChangeOperation, # re-export for back-compat
27
28
  )
29
+ from reflexio.server.services.deferred_learning_plan import (
30
+ ExtractorBookmarkAdvance,
31
+ GenerationComputePlan,
32
+ )
28
33
  from reflexio.server.services.extractor_config_utils import (
29
34
  get_extractor_name,
30
35
  )
@@ -38,10 +43,11 @@ class ExtractorExecutionError(RuntimeError):
38
43
  logger = logging.getLogger(__name__)
39
44
 
40
45
 
41
- # Cheap-signal thresholds for the pre-LLM should_run filter. Tuned for
46
+ # Cheap-signal threshold for the pre-LLM should_run filter. Tuned for
42
47
  # coding-assistant traffic where most turns are either slash commands
43
48
  # or tool scaffolding, and where the LLM should_run gate costs 5–7s
44
- # even when it ultimately votes False.
49
+ # even when it ultimately votes False. Non-Latin letters and numbers
50
+ # count twice because many scripts carry more meaning per character.
45
51
  _MIN_USER_CONTENT_LEN = 30
46
52
  # Heuristic match for reflexio's own extractor system prompts that
47
53
  # sometimes leak into the corpus via the claude-code LLM provider's
@@ -76,17 +82,47 @@ def _is_pure_slash_command(content: str) -> bool:
76
82
  def _iter_user_contents(
77
83
  session_data_models: list[RequestInteractionDataModel],
78
84
  ) -> list[str]:
79
- """Collect the ``content`` of every User-role interaction, order-preserving."""
85
+ """Collect non-empty user-authored content, preserving interaction order.
86
+
87
+ Role matching is case-insensitive because API and CLI publishers use
88
+ different casing. These contents feed user-text-only heuristics; they do
89
+ not determine stride or window eligibility.
90
+ """
80
91
  out: list[str] = []
81
92
  for model in session_data_models:
82
93
  out.extend(
83
94
  interaction.content
84
95
  for interaction in model.interactions
85
- if interaction.role == "User" and interaction.content
96
+ if interaction.role.casefold() == "user" and interaction.content
86
97
  )
87
98
  return out
88
99
 
89
100
 
101
+ def _weighted_content_length(content: str) -> int:
102
+ """Count original non-Latin letters and numbers twice.
103
+
104
+ Normalize each code point only for script classification so compatibility
105
+ forms such as full-width Latin stay unweighted without changing the
106
+ original content length.
107
+ """
108
+ stripped = content.strip()
109
+
110
+ def is_non_latin_letter_or_number(char: str) -> bool:
111
+ normalized_chars = [
112
+ normalized_char
113
+ for normalized_char in unicodedata.normalize("NFKC", char)
114
+ if unicodedata.category(normalized_char)[0] in {"L", "N"}
115
+ ]
116
+ return bool(normalized_chars) and all(
117
+ not normalized_char.isascii()
118
+ and "LATIN" not in unicodedata.name(normalized_char, "")
119
+ for normalized_char in normalized_chars
120
+ )
121
+
122
+ non_latin_bonus = sum(1 for char in stripped if is_non_latin_letter_or_number(char))
123
+ return len(stripped) + non_latin_bonus
124
+
125
+
90
126
  def _cheap_should_run_reject(
91
127
  session_data_models: list[RequestInteractionDataModel],
92
128
  ) -> str | None:
@@ -97,9 +133,14 @@ def _cheap_should_run_reject(
97
133
  LLM call. Returns None when we cannot decide cheaply and the LLM
98
134
  should run.
99
135
 
100
- Rejection rules:
101
- - No user message at least ``_MIN_USER_CONTENT_LEN`` chars long
102
- (purely short commands / confirmations).
136
+ When the scoped batch has no non-empty user-authored content, these
137
+ user-text-only heuristics are inapplicable and the batch falls through to
138
+ the LLM gate. Stride and window eligibility are evaluated independently.
139
+
140
+ Rejection rules when user-authored content is present:
141
+ - No user message reaches ``_MIN_USER_CONTENT_LEN`` weighted
142
+ characters (purely short commands / confirmations). Non-Latin
143
+ letters and numbers count twice.
103
144
  - Every user message is a bare slash-command dispatch with no
104
145
  substantive trailing text (e.g. ``/commit``, ``/review``,
105
146
  ``/claude-smart:tag``). Slash commands that carry user text
@@ -116,14 +157,18 @@ def _cheap_should_run_reject(
116
157
  """
117
158
  user_contents = _iter_user_contents(session_data_models)
118
159
  if not user_contents:
119
- return "no_user_turns"
160
+ return None
120
161
 
121
162
  for content in user_contents:
122
163
  lowered = content.lstrip().lower()
123
164
  if any(lowered.startswith(p) for p in _EXTRACTOR_PROMPT_PREFIXES):
124
165
  return "extractor_prompt_echo"
125
166
 
126
- if not any(len(c.strip()) >= _MIN_USER_CONTENT_LEN for c in user_contents):
167
+ if not any(
168
+ len(content.strip()) >= _MIN_USER_CONTENT_LEN
169
+ or _weighted_content_length(content) >= _MIN_USER_CONTENT_LEN
170
+ for content in user_contents
171
+ ):
127
172
  return "all_user_turns_too_short"
128
173
 
129
174
  if all(_is_pure_slash_command(c) for c in user_contents):
@@ -159,8 +204,9 @@ class BaseGenerationService(
159
204
  ABC,
160
205
  Generic[TExtractorConfig, TExtractor, TGenerationServiceConfig, TRequest], # noqa: UP046
161
206
  ):
162
- # Only profile/playbook GENERATION services bill the Learning line;
163
- # reflection/consolidation/aggregation/evaluation are bundled, not metered.
207
+ # Only profile/playbook GENERATION services emit extraction-run billing here.
208
+ # Non-extraction learning mutation paths emit their value facet at their own
209
+ # durable-success point.
164
210
  # Default is False so any future subclass is safe by default (opt-IN).
165
211
  EMITS_LEARNING_BILLING: bool = False
166
212
  """
@@ -208,6 +254,9 @@ class BaseGenerationService(
208
254
  }
209
255
  self._last_extraction_run_ids: list[str] = []
210
256
  self._last_token_totals: RunTokenTotals | None = None
257
+ # Stride-bookmark advance deferred off the extractor (F1); captured in
258
+ # ``_execute_extractor`` and applied in the persist half of the run.
259
+ self._last_bookmark_advance: ExtractorBookmarkAdvance | None = None
211
260
  # Window fetched by the should-run gate (_collect_scoped_interactions_for_precheck),
212
261
  # stashed so the billing path (_extraction_input_text) can reuse it instead of
213
262
  # re-querying storage. None when the gate did not run (bypass paths).
@@ -428,7 +477,7 @@ class BaseGenerationService(
428
477
 
429
478
  # Get scope ID and request ID for in-progress tracking
430
479
  scope_id = self._get_lock_scope_id(request)
431
- my_request_id = getattr(request, "request_id", None) or str(uuid.uuid4())
480
+ lock_request_id = getattr(request, "request_id", None) or str(uuid.uuid4())
432
481
 
433
482
  state_manager = self._create_state_manager()
434
483
 
@@ -439,7 +488,7 @@ class BaseGenerationService(
439
488
  # never get extracted.
440
489
  my_payload = self._serialize_request_for_queue(request)
441
490
  if not state_manager.acquire_lock(
442
- my_request_id, scope_id=scope_id, payload=my_payload
491
+ lock_request_id, scope_id=scope_id, payload=my_payload
443
492
  ):
444
493
  return # Another operation is running, we've enqueued ourselves
445
494
 
@@ -464,21 +513,21 @@ class BaseGenerationService(
464
513
  # request's ID + payload so the rerun runs against THAT
465
514
  # publish's data, not the original holder's.
466
515
  next_entry = state_manager.release_lock_pop_queue(
467
- my_request_id, scope_id=scope_id
516
+ lock_request_id, scope_id=scope_id
468
517
  )
469
518
 
470
519
  if next_entry is None:
471
520
  break # Queue empty — we're done
472
521
 
473
- next_request_id = next_entry["request_id"]
522
+ next_lock_request_id = next_entry["request_id"]
474
523
  next_payload = next_entry.get("payload")
475
524
 
476
525
  logger.info(
477
- "Draining queued %s request: prev_request_id=%s, next_request_id=%s, "
526
+ "Draining queued %s request: prev_lock_request_id=%s, next_lock_request_id=%s, "
478
527
  "payload_present=%s",
479
528
  self._get_service_name(),
480
- my_request_id,
481
- next_request_id,
529
+ lock_request_id,
530
+ next_lock_request_id,
482
531
  next_payload is not None,
483
532
  )
484
533
 
@@ -492,7 +541,7 @@ class BaseGenerationService(
492
541
  else:
493
542
  current_request = request
494
543
 
495
- my_request_id = next_request_id
544
+ lock_request_id = next_lock_request_id
496
545
 
497
546
  except Exception:
498
547
  # Clear lock on error to prevent deadlock
@@ -500,69 +549,29 @@ class BaseGenerationService(
500
549
  raise
501
550
 
502
551
  def _run_generation(self, request: TRequest) -> None:
503
- """
504
- Run the actual generation logic.
552
+ """Run one generation synchronously: compute -> persist -> emit.
553
+
554
+ Thin wrapper preserving the pre-split behavior for the synchronous
555
+ ``.run()`` / manual / rerun callers: compute (extractor + dedup +
556
+ embeddings), persist (row writes + the extractor stride-bookmark
557
+ advance) and post-commit side-effects (telemetry + billing) run together
558
+ with **no** external ``commit_scope``. Only the durable worker (Task 8/9)
559
+ drives ``compute_generation`` / ``persist_generation`` /
560
+ ``emit_generation_side_effects`` separately across its fenced scope.
505
561
 
506
- Orchestrates validation, config loading, extractor execution, and result
507
- processing by delegating to _prepare_generation_run and _execute_extractor.
562
+ The outer ``except`` records ``generation_failed`` and re-raises
563
+ ``ExtractorExecutionError`` exactly as before.
508
564
 
509
565
  Args:
510
566
  request: The request object containing parameters
511
567
  """
512
- if not request:
513
- logger.error("Received None request for %s", self._get_service_name())
514
- return
515
-
516
568
  generation_start = time.perf_counter()
517
569
  try:
518
- prepared = self._prepare_generation_run(request)
519
- if prepared is None:
570
+ plan = self.compute_generation(request)
571
+ if plan is None:
520
572
  return
521
-
522
- self._record_generation_event(
523
- event_name="generation_started",
524
- outcome="started",
525
- count_value=1,
526
- metadata={
527
- "identifier": prepared.identifier,
528
- "extractor_name": prepared.extractor_name,
529
- },
530
- )
531
- self._last_extraction_run_ids = []
532
- self._last_token_totals = None
533
- result = self._execute_extractor(
534
- prepared.extractor_config, prepared.identifier
535
- )
536
- generated_count = self._count_generated_results(result)
537
-
538
- try:
539
- if result:
540
- self._process_results([result])
541
- self._finalize_extraction_runs()
542
- except Exception as exc:
543
- self._mark_extraction_runs_finalization_failed(exc)
544
- raise
545
-
546
- self._record_generation_event(
547
- event_name="generation_succeeded",
548
- outcome="success",
549
- count_value=generated_count,
550
- duration_ms=int((time.perf_counter() - generation_start) * 1000),
551
- metadata={
552
- "identifier": prepared.identifier,
553
- "extractor_name": prepared.extractor_name,
554
- "extractor_failed": bool(
555
- self._last_extractor_run_stats.get("failed")
556
- ),
557
- "extractor_timed_out": bool(
558
- self._last_extractor_run_stats.get("timed_out")
559
- ),
560
- },
561
- )
562
- self._record_billing_learning_events(
563
- prepared=prepared, generated_count=generated_count
564
- )
565
-
573
+ self.persist_generation(plan)
574
+ self.emit_generation_side_effects(plan)
566
575
  except Exception as e:
567
576
  self._record_generation_event(
568
577
  event_name="generation_failed",
@@ -579,6 +588,178 @@ class BaseGenerationService(
579
588
  if isinstance(e, ExtractorExecutionError):
580
589
  raise
581
590
 
591
+ def compute_generation(self, request: TRequest) -> GenerationComputePlan | None:
592
+ """Compute half of one generation run — NO learning DB write, NO fence.
593
+
594
+ Runs the prepare gate, the extractor (thread-pool LLM tool-loop), dedup
595
+ + embedding resolution (``_resolve_write_plan``), and drives the
596
+ ``agent_run`` rows to their terminal state (``_finalize_extraction_runs``
597
+ — agent_run only, §4.3). Snapshots the billing inputs onto the returned
598
+ plan so ``emit_generation_side_effects`` never depends on the reused
599
+ instance's mutable ``_last_*``.
600
+
601
+ Returns a resolved ``GenerationComputePlan`` for persist/emit, or
602
+ ``None`` when the prepare gate closes (nothing to run). Raises
603
+ ``ExtractorExecutionError`` on extractor failure — the caller
604
+ (``_run_generation`` / the durable worker) records ``generation_failed``.
605
+ """
606
+ if not request:
607
+ logger.error("Received None request for %s", self._get_service_name())
608
+ return None
609
+
610
+ generation_start = time.perf_counter()
611
+ prepared = self._prepare_generation_run(request)
612
+ if prepared is None:
613
+ return None
614
+
615
+ self._record_generation_event(
616
+ event_name="generation_started",
617
+ outcome="started",
618
+ count_value=1,
619
+ metadata={
620
+ "identifier": prepared.identifier,
621
+ "extractor_name": prepared.extractor_name,
622
+ },
623
+ )
624
+ self._last_extraction_run_ids = []
625
+ self._last_token_totals = None
626
+ self._last_bookmark_advance = None
627
+ result = self._execute_extractor(prepared.extractor_config, prepared.identifier)
628
+ generated_count = self._count_generated_results(result)
629
+
630
+ try:
631
+ write_plan = self._resolve_write_plan([result]) if result else None
632
+ self._finalize_extraction_runs()
633
+ except Exception as exc:
634
+ self._mark_extraction_runs_finalization_failed(exc)
635
+ raise
636
+
637
+ return GenerationComputePlan(
638
+ prepared=prepared,
639
+ generated_count=generated_count,
640
+ write_plan=write_plan,
641
+ bookmark_advance=self._last_bookmark_advance,
642
+ generation_start=generation_start,
643
+ extraction_run_ids=list(self._last_extraction_run_ids),
644
+ token_totals=self._last_token_totals,
645
+ )
646
+
647
+ def persist_generation(self, plan: GenerationComputePlan) -> None:
648
+ """Persist half — apply the write-plan + the extractor bookmark advance.
649
+
650
+ This is the ONLY part that runs inside the durable worker's fenced
651
+ ``commit_scope`` (and inline on the synchronous ``.run()`` path). It
652
+ issues only the resolved write-plan's row writes (embeddings already
653
+ precomputed in compute for Tasks 6-7) and the extractor stride-bookmark
654
+ advance — **no** events, **no** billing, **no** LLM / embedding compute.
655
+
656
+ The bookmark advance is applied HERE so that BOTH the synchronous
657
+ ``.run()`` path and the durable persist fence advance the bookmark
658
+ atomically with the row writes it corresponds to (F1) — and it is
659
+ applied in exactly one place, so it is never double-applied.
660
+ """
661
+ if plan.write_plan is not None:
662
+ self._persist_write_plan(plan.write_plan)
663
+ self._apply_bookmark_advance(plan.bookmark_advance)
664
+
665
+ def emit_generation_side_effects(self, plan: GenerationComputePlan) -> None:
666
+ """Post-commit side-effects — telemetry + ② Learning billing.
667
+
668
+ Runs only for a fence-winning job (the durable worker calls it after the
669
+ scope commits; ``.run()`` calls it inline). Reads the plan's compute-time
670
+ snapshot (``generated_count`` / ``prepared`` / ``generation_start``) so a
671
+ fence-lost job never emits.
672
+
673
+ Billing purity note (round-2 finding): ``_record_billing_learning_events``
674
+ also reads ``self._last_token_totals`` / ``self._last_precheck_sessions``,
675
+ and the ``generation_succeeded`` metadata reads ``self._last_extractor_run_stats``.
676
+ These are NOT re-plumbed to the plan — the money helper lives in a
677
+ separate mixin behind a documented patch seam (``_usage_billing.py`` SINK-3)
678
+ and re-plumbing it would touch the money path for zero behavior change.
679
+ It is safe under the **single-use-instance invariant**: every generation
680
+ service instance runs exactly one compute→persist→emit for one job with
681
+ no interleaving (``.run()`` is synchronous; the durable orchestration in
682
+ Task 8 holds one ``(instance, plan)`` pair per profile/playbook service
683
+ and never re-runs compute on that instance before emit), so ``self._last_*``
684
+ is exactly as compute left it when emit runs. The plan still snapshots the
685
+ billing inputs so a future durable reuse can re-plumb locally.
686
+ """
687
+ self._record_generation_event(
688
+ event_name="generation_succeeded",
689
+ outcome="success",
690
+ count_value=plan.generated_count,
691
+ duration_ms=int((time.perf_counter() - plan.generation_start) * 1000),
692
+ metadata={
693
+ "identifier": plan.prepared.identifier,
694
+ "extractor_name": plan.prepared.extractor_name,
695
+ "extractor_failed": bool(self._last_extractor_run_stats.get("failed")),
696
+ "extractor_timed_out": bool(
697
+ self._last_extractor_run_stats.get("timed_out")
698
+ ),
699
+ },
700
+ )
701
+ self._record_billing_learning_events(
702
+ prepared=plan.prepared, generated_count=plan.generated_count
703
+ )
704
+
705
+ @abstractmethod
706
+ def _resolve_write_plan(self, results: list) -> Any | None:
707
+ """Compute-half of item finalization — resolve a write-plan, NO DB write.
708
+
709
+ Runs the dedup + source/status assignment + embedding precompute and
710
+ returns a resolved write-plan (or ``None`` when there is nothing to
711
+ write). Issues NO learning DB write — the write is the persist half's
712
+ job (``_persist_write_plan``), so this stays inside the compute purity
713
+ contract (the compute-write-tripwire contract test).
714
+
715
+ The durable-split ``ProfileGenerationService`` /
716
+ ``PlaybookGenerationService`` return a real ``ProfileWritePlan`` /
717
+ ``PlaybookWritePlan`` (Tasks 6-7). ``AgentSuccessEvaluationService`` was
718
+ never split into a durable persist path — it keeps a concrete override
719
+ that writes in compute via its permanent ``_finalize_extracted_items``
720
+ wrapper (its results never flow through the durable worker fence).
721
+ """
722
+
723
+ @abstractmethod
724
+ def _persist_write_plan(self, plan: Any) -> None:
725
+ """Persist-half of item finalization — apply the resolved write-plan.
726
+
727
+ Issues only the fence-critical row writes for a plan produced by
728
+ ``_resolve_write_plan`` (embeddings already precomputed in compute), with
729
+ NO LLM / embedding / dedup. This is the only item-finalization work that
730
+ runs inside the durable worker's fenced ``commit_scope``.
731
+
732
+ ``AgentSuccessEvaluationService`` (never split) implements this as a
733
+ no-op — its ``_resolve_write_plan`` performs the write in compute and
734
+ returns ``None`` so ``persist_generation`` never invokes this.
735
+ """
736
+
737
+ def _apply_bookmark_advance(self, advance: ExtractorBookmarkAdvance | None) -> None:
738
+ """Apply the deferred extractor stride-bookmark advance (F1).
739
+
740
+ The extractor emits its bookmark advance on the ``ExtractionOutcome``
741
+ rather than self-advancing; ``_execute_extractor`` captures it and
742
+ ``compute_generation`` snapshots it onto the ``GenerationComputePlan``.
743
+ This applies it via ``update_extractor_bookmark`` using the same
744
+ OperationStateManager service name the extractor used, so the bookmark
745
+ key is byte-identical. No-op when the extractor produced no output
746
+ (``advance`` is ``None``) or the service has no stride bookmark.
747
+
748
+ Args:
749
+ advance: The bookmark advance from ``plan.bookmark_advance``.
750
+ """
751
+ if advance is None or self.storage is None:
752
+ return
753
+ service_name = self._get_extractor_state_service_name()
754
+ if service_name is None:
755
+ return
756
+ manager = OperationStateManager(self.storage, self.org_id, service_name)
757
+ manager.update_extractor_bookmark(
758
+ extractor_name=advance.extractor_name,
759
+ processed_interactions=advance.processed_interactions,
760
+ user_id=advance.user_id,
761
+ )
762
+
582
763
  def _prepare_generation_run(
583
764
  self, request: TRequest
584
765
  ) -> PreparedGenerationRun[TExtractorConfig] | None:
@@ -8,14 +8,86 @@ ProfileConsolidator and PlaybookConsolidator.
8
8
  import logging
9
9
  from abc import ABC
10
10
  from datetime import UTC, datetime
11
+ from typing import Any, cast
11
12
 
12
13
  from reflexio.server.api_endpoints.request_context import RequestContext
13
14
  from reflexio.server.llm.litellm_client import LiteLLMClient
14
15
  from reflexio.server.llm.model_defaults import ModelRole, resolve_model_name
16
+ from reflexio.server.services.embedding_text import embedding_input
15
17
  from reflexio.server.site_var.site_var_manager import SiteVarManager
16
18
 
17
19
  logger = logging.getLogger(__name__)
18
20
 
21
+
22
+ def resolve_dedup_query_embeddings(
23
+ storage: Any,
24
+ client: LiteLLMClient,
25
+ query_texts: list[str],
26
+ *,
27
+ entity_label: str,
28
+ ) -> list[list[float] | None]:
29
+ """Embed dedup-search queries with the model that indexed the store.
30
+
31
+ Prefers the storage's own embedding path (correct model + "query" prefix);
32
+ falls back to ``client`` pinned to the storage's model/dimensions. Letting
33
+ the client resolve its default embedding model would be wrong here: it
34
+ picks the OSS default, which the enterprise embedding daemon rejects
35
+ (409 model conflict) and which would not match the indexed vectors anyway.
36
+
37
+ Args:
38
+ storage: Storage backend (duck-typed: ``_get_embedding`` preferred,
39
+ else ``embedding_model_name`` / ``embedding_dimensions``).
40
+ client: Shared LLM client, used only in the fallback path.
41
+ query_texts: Query strings to embed.
42
+ entity_label: Log prefix identifying the caller, e.g. "Profile".
43
+
44
+ Returns:
45
+ One embedding (or None) per query text, in input order. On any
46
+ failure, all entries are None so the caller degrades to text-only
47
+ search. A backend that signals embedding-service unavailability with
48
+ an empty vector (e.g. the Supabase query path) is normalized to None
49
+ so search falls back to its own embedding/FTS path instead of sending
50
+ an empty vector to the database.
51
+ """
52
+ try:
53
+ get_storage_embedding = getattr(storage, "_get_embedding", None)
54
+ if callable(get_storage_embedding):
55
+ logger.info(
56
+ "%s dedup query embeddings: source=storage model=%s",
57
+ entity_label,
58
+ getattr(storage, "embedding_model_name", "unknown"),
59
+ )
60
+ embeddings = [
61
+ cast(
62
+ "list[float] | None",
63
+ get_storage_embedding(query_text, purpose="query"),
64
+ )
65
+ for query_text in query_texts
66
+ ]
67
+ else:
68
+ embedding_model_name = storage.embedding_model_name
69
+ embedding_dimensions = storage.embedding_dimensions
70
+ logger.info(
71
+ "%s dedup query embeddings: source=llm_client model=%s",
72
+ entity_label,
73
+ embedding_model_name,
74
+ )
75
+ embeddings = list(
76
+ client.get_embeddings(
77
+ [
78
+ embedding_input(query_text, purpose="query")
79
+ for query_text in query_texts
80
+ ],
81
+ model=embedding_model_name,
82
+ dimensions=embedding_dimensions,
83
+ )
84
+ )
85
+ except Exception as e:
86
+ logger.warning("Failed to generate embeddings for dedup search: %s", e)
87
+ return [None] * len(query_texts)
88
+ return [emb or None for emb in embeddings]
89
+
90
+
19
91
  # Format used for "Last Modified" timestamps shown to deduplication LLMs.
20
92
  # Includes hours and minutes so same-day contradictions (morning vs evening)
21
93
  # can be distinguished.