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
@@ -29,7 +29,7 @@ import logging
29
29
  import uuid
30
30
  from dataclasses import dataclass
31
31
  from datetime import UTC, datetime
32
- from typing import TYPE_CHECKING
32
+ from typing import TYPE_CHECKING, Literal
33
33
 
34
34
  from reflexio.models.api_schema.domain.entities import (
35
35
  Citation,
@@ -39,7 +39,11 @@ from reflexio.models.api_schema.domain.entities import (
39
39
  UserProfile,
40
40
  )
41
41
  from reflexio.server.llm.litellm_client import LiteLLMClient
42
+ from reflexio.server.services.deferred_learning_plan import ReflectionWritePlan
42
43
  from reflexio.server.services.operation_state_utils import OperationStateManager
44
+ from reflexio.server.services.playbook.aggregation_trigger import (
45
+ maybe_trigger_user_playbook_aggregation,
46
+ )
43
47
  from reflexio.server.services.playbook.playbook_edit_apply import apply_playbook_edit
44
48
  from reflexio.server.services.reflection.components.extractor import (
45
49
  ReflectionExtractor,
@@ -81,23 +85,63 @@ class ReflectionService:
81
85
  ):
82
86
  self.request_context = request_context
83
87
  self.client = llm_client
88
+ # Per-persist state threaded from the write-plan into the (verbatim)
89
+ # apply loop's ``_replace_*`` helpers without changing their signatures.
90
+ # Empty on the direct-call / ``.run()``-without-compute paths, which then
91
+ # build+embed the replacement row inline exactly as before.
92
+ self._pending_replacement_profiles: dict[str, UserProfile] = {}
93
+ self._pending_replacement_playbooks: dict[int, UserPlaybook] = {}
94
+ self._pending_aggregation_successor_ids: list[int] = []
95
+ # Partial result threaded out of compute's four no-write paths to run().
96
+ self._pending_partial_result: ReflectionResult | None = None
84
97
 
85
98
  def run(self, request: ReflectionServiceRequest) -> ReflectionResult:
86
- """Execute one reflection pass.
87
-
88
- Always returns a ``ReflectionResult`` describing what happened.
89
- Routine failure modes (gate closed, no citations, missing rows,
90
- LLM error) do not raise. Storage exceptions propagate.
99
+ """Execute one reflection pass (compute → persist → side-effects).
100
+
101
+ Behavior-identical wrapper over the split: ``compute`` does all the
102
+ read + LLM work (no learning write), ``persist`` applies the resolved
103
+ write-plan (rows + bookmark) and ``emit_side_effects`` fires the
104
+ post-write billing + aggregation triggers. Always returns a
105
+ ``ReflectionResult`` describing what happened. Routine failure modes
106
+ (gate closed, no citations, missing rows, LLM error) do not raise.
107
+ Storage exceptions propagate.
108
+ """
109
+ self._pending_partial_result = None
110
+ plan = self.compute(request)
111
+ if plan is None:
112
+ # The four no-write paths (config disabled, storage None, gate
113
+ # closed, extractor failure) thread their partial result out here.
114
+ return self._pending_partial_result or ReflectionResult()
115
+ self.persist(plan)
116
+ self.emit_side_effects(plan)
117
+ return plan.result
118
+
119
+ def compute(self, request: ReflectionServiceRequest) -> ReflectionWritePlan | None:
120
+ """Resolve one reflection pass into a write-plan, issuing NO write.
121
+
122
+ Runs the gate, window pull, citation filter, cited-row resolve and the
123
+ reflection LLM call, then precomputes the replacement rows' embeddings
124
+ (V2) so persist embeds nothing inside the fence.
125
+
126
+ Returns ``None`` (nothing to persist) on the four no-write paths —
127
+ config disabled, storage ``None``, gate closed, extractor failure —
128
+ stashing the partial ``ReflectionResult`` on ``self`` for ``run()``.
129
+ Returns a ``ReflectionWritePlan`` on all five bookmark-advance paths
130
+ (the four early-advance returns carry ``decisions=[]`` +
131
+ ``advance_bookmark=True``; the normal path carries the resolved
132
+ decisions/rows + ``record_learnings=True``).
91
133
  """
92
134
  result = ReflectionResult()
93
135
  config = self.request_context.configurator.get_config()
94
136
  reflection_config = config.reflection_config if config else None
95
137
  if reflection_config is not None and not reflection_config.enabled:
96
- return result
138
+ self._pending_partial_result = result
139
+ return None
97
140
 
98
141
  storage = self.request_context.storage
99
142
  if storage is None:
100
- return result
143
+ self._pending_partial_result = result
144
+ return None
101
145
 
102
146
  window_size = config.window_size if config else 10
103
147
  stride_size = config.stride_size if config else 5
@@ -117,7 +161,8 @@ class ReflectionService:
117
161
  )
118
162
  new_count = sum(len(m.interactions) for m in new_models)
119
163
  if new_count < stride_size:
120
- return result
164
+ self._pending_partial_result = result
165
+ return None
121
166
  result.gate_open = True
122
167
 
123
168
  # Pull window of last window_size interactions for the user.
@@ -128,23 +173,16 @@ class ReflectionService:
128
173
  )
129
174
  window_interactions = _flatten(window_models)
130
175
  if not window_interactions:
131
- mgr.update_extractor_bookmark(
132
- REFLECTION_OPERATION_NAME,
133
- processed_interactions=[],
134
- user_id=request.user_id,
135
- )
136
- return result
176
+ # Empty window: advance the bookmark over nothing (persist).
177
+ return self._advance_only_plan(request, result, bookmark_interactions=[])
137
178
 
138
179
  citations = _collect_citations(window_interactions)
139
180
  result.cited_count = len(citations)
140
181
  if not citations:
141
182
  # Advance the bookmark — we did examine this window.
142
- mgr.update_extractor_bookmark(
143
- REFLECTION_OPERATION_NAME,
144
- processed_interactions=window_interactions,
145
- user_id=request.user_id,
183
+ return self._advance_only_plan(
184
+ request, result, bookmark_interactions=window_interactions
146
185
  )
147
- return result
148
186
 
149
187
  # Post-horizon filter: only send citations with enough follow-up context.
150
188
  post_horizon_size = (
@@ -159,16 +197,15 @@ class ReflectionService:
159
197
  deferred_count = len(citations) - len(eligible)
160
198
  result.skipped_count += deferred_count
161
199
  if not eligible:
162
- mgr.update_extractor_bookmark(
163
- REFLECTION_OPERATION_NAME,
164
- processed_interactions=window_interactions,
165
- user_id=request.user_id,
200
+ return self._advance_only_plan(
201
+ request, result, bookmark_interactions=window_interactions
166
202
  )
167
- return result
168
203
 
169
204
  eligible_citations = [e.citation for e in eligible]
170
- horizon_by_key = {
171
- (e.citation.kind, e.citation.real_id): e.has_full_horizon for e in eligible
205
+ horizon_by_key: dict[tuple[str, str], bool] = {
206
+ key: e.has_full_horizon
207
+ for e in eligible
208
+ if (key := _reflection_citation_key(e.citation)) is not None
172
209
  }
173
210
  cited_profiles, cited_playbooks, missing = self._resolve_cited_rows(
174
211
  user_id=request.user_id,
@@ -177,12 +214,9 @@ class ReflectionService:
177
214
  result.skipped_count += missing
178
215
  result.considered_count = len(cited_profiles) + len(cited_playbooks)
179
216
  if result.considered_count == 0:
180
- mgr.update_extractor_bookmark(
181
- REFLECTION_OPERATION_NAME,
182
- processed_interactions=window_interactions,
183
- user_id=request.user_id,
217
+ return self._advance_only_plan(
218
+ request, result, bookmark_interactions=window_interactions
184
219
  )
185
- return result
186
220
 
187
221
  agent_context = (config.agent_context_prompt or "") if config else ""
188
222
  model_override = reflection_config.model if reflection_config else None
@@ -213,7 +247,8 @@ class ReflectionService:
213
247
  request.user_id,
214
248
  )
215
249
  # Don't advance bookmark — let the next publish retry.
216
- return result
250
+ self._pending_partial_result = result
251
+ return None
217
252
 
218
253
  result.ran = True
219
254
  profiles_by_id = {p.profile_id: p for p in cited_profiles}
@@ -225,22 +260,76 @@ class ReflectionService:
225
260
  else _DEFAULT_MAX_REVISIONS_PER_PASS
226
261
  )
227
262
 
228
- for decision in output.decisions:
263
+ # Collapse ≥2 decisions on the SAME target to the first (the LLM emits one
264
+ # decision per cited row, but nothing constrains it to one per target).
265
+ # Two same-target revisions produce torn state in persist: D1 supersedes
266
+ # the incumbent, D2 reuses the SAME successor uuid (keyed by cited id),
267
+ # finds the incumbent already superseded, its CAS misses, and its cleanup
268
+ # deletes the live successor — a dangling ``superseded_by`` + double-count.
269
+ # Deduplicating at the source keeps _precompute_replacement_rows and the
270
+ # persist apply loop in lockstep and makes that state unreachable.
271
+ decisions = _dedup_decisions_by_target(output.decisions)
272
+
273
+ # V2: precompute the replacement rows' embeddings here (outside any
274
+ # writer transaction) so persist inserts them with skip_embedding=True.
275
+ replacement_profiles, replacement_playbooks = self._precompute_replacement_rows(
276
+ request=request,
277
+ decisions=decisions,
278
+ profiles_by_id=profiles_by_id,
279
+ playbooks_by_id=playbooks_by_id,
280
+ max_revisions_per_pass=max_revisions_per_pass,
281
+ )
282
+
283
+ return ReflectionWritePlan(
284
+ request=request,
285
+ result=result,
286
+ decisions=decisions,
287
+ profiles_by_id=profiles_by_id,
288
+ playbooks_by_id=playbooks_by_id,
289
+ max_revisions_per_pass=max_revisions_per_pass,
290
+ bookmark_interactions=window_interactions,
291
+ advance_bookmark=True,
292
+ record_learnings=True,
293
+ replacement_profiles=replacement_profiles,
294
+ replacement_playbooks=replacement_playbooks,
295
+ )
296
+
297
+ def persist(self, plan: ReflectionWritePlan) -> None:
298
+ """Apply the resolved write-plan inside the caller's fence.
299
+
300
+ Runs the cap/validate/CAS apply loop verbatim (mutating ``plan.result``
301
+ in place), inserting replacement rows with ``skip_embedding=True`` (their
302
+ embeddings were precomputed in ``compute``), then advances the reflection
303
+ stride-bookmark iff ``plan.advance_bookmark``. Playbook aggregation
304
+ triggers are only *collected* here (dispatched post-commit in
305
+ ``emit_side_effects``) so they never fire on a rolled-back job.
306
+ """
307
+ request = plan.request
308
+ result = plan.result
309
+ # Thread the precomputed replacement rows + aggregation-trigger sink into
310
+ # the verbatim apply loop's _replace_* helpers.
311
+ self._pending_replacement_profiles = plan.replacement_profiles
312
+ self._pending_replacement_playbooks = plan.replacement_playbooks
313
+ self._pending_aggregation_successor_ids = plan.aggregation_successor_ids
314
+
315
+ for decision in plan.decisions:
229
316
  if not _is_revision(decision):
230
317
  result.no_change_count += 1
231
318
  continue
232
319
  # Per-pass cap: once we've applied max_revisions_per_pass
233
320
  # revisions, skip any further revision-intent decisions.
234
- if result.revised_count >= max_revisions_per_pass:
321
+ if result.revised_count >= plan.max_revisions_per_pass:
235
322
  result.capped_count += 1
236
323
  continue
237
324
  try:
238
- self._validate_decision(decision, profiles_by_id, playbooks_by_id)
325
+ self._validate_decision(
326
+ decision, plan.profiles_by_id, plan.playbooks_by_id
327
+ )
239
328
  applied = self._apply_revision(
240
329
  request=request,
241
330
  decision=decision,
242
- profiles_by_id=profiles_by_id,
243
- playbooks_by_id=playbooks_by_id,
331
+ profiles_by_id=plan.profiles_by_id,
332
+ playbooks_by_id=plan.playbooks_by_id,
244
333
  )
245
334
  except Exception as exc: # noqa: BLE001 — per-decision isolation
246
335
  result.failed_count += 1
@@ -271,37 +360,244 @@ class ReflectionService:
271
360
  else:
272
361
  result.skipped_count += 1
273
362
 
274
- mgr.update_extractor_bookmark(
275
- REFLECTION_OPERATION_NAME,
276
- processed_interactions=window_interactions,
277
- user_id=request.user_id,
278
- )
363
+ if plan.advance_bookmark:
364
+ mgr = OperationStateManager(
365
+ self.request_context.storage, # type: ignore[arg-type]
366
+ self.request_context.org_id,
367
+ REFLECTION_OPERATION_NAME,
368
+ )
369
+ mgr.update_extractor_bookmark(
370
+ REFLECTION_OPERATION_NAME,
371
+ processed_interactions=plan.bookmark_interactions,
372
+ user_id=request.user_id,
373
+ )
279
374
 
280
- logger.info(
281
- "event=reflection_done user_id=%s gate_open=%s ran=%s "
282
- "cited=%d considered=%d no_change=%d revised=%d "
283
- "trigger_revised=%d content_revised=%d ttl_changed=%d capped=%d "
284
- "skipped=%d failed=%d",
285
- request.user_id,
286
- result.gate_open,
287
- result.ran,
288
- result.cited_count,
289
- result.considered_count,
290
- result.no_change_count,
291
- result.revised_count,
292
- result.trigger_revised_count,
293
- result.content_revised_count,
294
- result.ttl_changed_count,
295
- result.capped_count,
296
- result.skipped_count,
297
- result.failed_count,
375
+ def emit_side_effects(self, plan: ReflectionWritePlan) -> None:
376
+ """Fire post-commit billing + aggregation triggers for a winning job.
377
+
378
+ Only invoked after the persist fence commits, so nothing here runs for
379
+ a superseded / rolled-back job (phantom-billing gate).
380
+ """
381
+ request = plan.request
382
+ result = plan.result
383
+ if plan.record_learnings:
384
+ self._record_learnings_generated(request, result)
385
+ logger.info(
386
+ "event=reflection_done user_id=%s gate_open=%s ran=%s "
387
+ "cited=%d considered=%d no_change=%d revised=%d "
388
+ "trigger_revised=%d content_revised=%d ttl_changed=%d capped=%d "
389
+ "skipped=%d failed=%d",
390
+ request.user_id,
391
+ result.gate_open,
392
+ result.ran,
393
+ result.cited_count,
394
+ result.considered_count,
395
+ result.no_change_count,
396
+ result.revised_count,
397
+ result.trigger_revised_count,
398
+ result.content_revised_count,
399
+ result.ttl_changed_count,
400
+ result.capped_count,
401
+ result.skipped_count,
402
+ result.failed_count,
403
+ )
404
+
405
+ self._dispatch_aggregation_triggers(plan.aggregation_successor_ids)
406
+
407
+ def _advance_only_plan(
408
+ self,
409
+ request: ReflectionServiceRequest,
410
+ result: ReflectionResult,
411
+ *,
412
+ bookmark_interactions: list[Interaction],
413
+ ) -> ReflectionWritePlan:
414
+ """Build a no-decision plan that only advances the bookmark in persist."""
415
+ return ReflectionWritePlan(
416
+ request=request,
417
+ result=result,
418
+ decisions=[],
419
+ profiles_by_id={},
420
+ playbooks_by_id={},
421
+ max_revisions_per_pass=0,
422
+ bookmark_interactions=bookmark_interactions,
423
+ advance_bookmark=True,
424
+ record_learnings=False,
298
425
  )
299
- return result
426
+
427
+ def _dispatch_aggregation_triggers(self, successor_ids: list[int]) -> None:
428
+ """Best-effort user-playbook aggregation trigger for each successor.
429
+
430
+ Runs post-commit (outside the persist fence). Reloads each successor to
431
+ read its ``agent_version``; a reload/trigger failure is logged but never
432
+ propagates, exactly as the pre-split inline trigger behaved.
433
+ """
434
+ storage = self.request_context.storage
435
+ if storage is None:
436
+ return
437
+ for successor_id in successor_ids:
438
+ try:
439
+ successor = storage.get_user_playbook_by_id(successor_id)
440
+ if successor is not None and successor.agent_version:
441
+ maybe_trigger_user_playbook_aggregation(
442
+ request_context=self.request_context,
443
+ llm_client=self.client,
444
+ agent_version=successor.agent_version,
445
+ reason="reflection",
446
+ )
447
+ except Exception: # noqa: BLE001
448
+ logger.exception(
449
+ "event=reflection_aggregation_trigger_failed kind=playbook "
450
+ "successor_id=%s",
451
+ successor_id,
452
+ )
300
453
 
301
454
  # ------------------------------------------------------------------
302
455
  # Helpers
303
456
  # ------------------------------------------------------------------
304
457
 
458
+ def _precompute_replacement_rows(
459
+ self,
460
+ *,
461
+ request: ReflectionServiceRequest,
462
+ decisions: list[ReflectionDecision],
463
+ profiles_by_id: dict[str, UserProfile],
464
+ playbooks_by_id: dict[int, UserPlaybook],
465
+ max_revisions_per_pass: int,
466
+ ) -> tuple[dict[str, UserProfile], dict[int, UserPlaybook]]:
467
+ """Build + embed the replacement rows persist will insert (V2).
468
+
469
+ Mirrors the persist apply loop's cap / validate / resolve so it builds
470
+ exactly the rows persist applies (no more, no fewer), embedding them via
471
+ ``precompute_*_embeddings`` outside any writer transaction. Persist then
472
+ inserts each with ``skip_embedding=True``. Keyed by the **cited** row id
473
+ (``profile_id`` / ``user_playbook_id``) so ``_replace_*`` can look up the
474
+ successor for the incumbent it is superseding.
475
+ """
476
+ storage = self.request_context.storage
477
+ replacement_profiles: dict[str, UserProfile] = {}
478
+ replacement_playbooks: dict[int, UserPlaybook] = {}
479
+ if storage is None:
480
+ return replacement_profiles, replacement_playbooks
481
+
482
+ applied = 0
483
+ for decision in decisions:
484
+ if not _is_revision(decision):
485
+ continue
486
+ if applied >= max_revisions_per_pass:
487
+ # Cap reached — every remaining revision is capped in persist too.
488
+ break
489
+ try:
490
+ self._validate_decision(decision, profiles_by_id, playbooks_by_id)
491
+ except Exception: # noqa: BLE001, S112 — mirrors persist per-decision isolation
492
+ # A validation failure never applies a revision (persist counts
493
+ # it as failed, not revised) so it consumes no cap and builds no
494
+ # row — skip it here too.
495
+ continue
496
+ if decision.target_kind == "profile":
497
+ cited_p = profiles_by_id.get(decision.target_id)
498
+ if cited_p is None:
499
+ continue
500
+ new_profile = self._build_replacement_profile(
501
+ request, decision, cited_p
502
+ )
503
+ storage.precompute_profile_embeddings([new_profile])
504
+ replacement_profiles[cited_p.profile_id] = new_profile
505
+ applied += 1
506
+ else:
507
+ try:
508
+ target_id = int(decision.target_id)
509
+ except (TypeError, ValueError):
510
+ continue
511
+ cited_pb = playbooks_by_id.get(target_id)
512
+ if cited_pb is None:
513
+ continue
514
+ new_playbook = self._build_replacement_playbook(
515
+ request, decision, cited_pb
516
+ )
517
+ storage.precompute_user_playbook_embeddings([new_playbook])
518
+ replacement_playbooks[cited_pb.user_playbook_id] = new_playbook
519
+ applied += 1
520
+ return replacement_profiles, replacement_playbooks
521
+
522
+ def _build_replacement_profile(
523
+ self,
524
+ request: ReflectionServiceRequest, # noqa: ARG002 — parity with _build_replacement_playbook
525
+ decision: ReflectionDecision,
526
+ cited: UserProfile,
527
+ ) -> UserProfile:
528
+ """Construct the replacement profile row for a revision decision."""
529
+ now_ts = int(datetime.now(UTC).timestamp())
530
+ return UserProfile(
531
+ profile_id=str(uuid.uuid4()),
532
+ user_id=cited.user_id,
533
+ content=decision.new_content or cited.content,
534
+ last_modified_timestamp=now_ts,
535
+ generated_from_request_id=cited.generated_from_request_id,
536
+ profile_time_to_live=(
537
+ decision.new_profile_time_to_live or cited.profile_time_to_live
538
+ ),
539
+ custom_features=cited.custom_features,
540
+ source=cited.source,
541
+ status=None,
542
+ extractor_names=cited.extractor_names,
543
+ )
544
+
545
+ def _build_replacement_playbook(
546
+ self,
547
+ request: ReflectionServiceRequest,
548
+ decision: ReflectionDecision,
549
+ cited: UserPlaybook,
550
+ ) -> UserPlaybook:
551
+ """Construct the replacement playbook row for a revision decision."""
552
+ owning_user_id = cited.user_id or request.user_id
553
+ return UserPlaybook(
554
+ user_playbook_id=0, # auto-assigned by storage
555
+ user_id=owning_user_id,
556
+ agent_version=cited.agent_version or request.agent_version,
557
+ request_id=cited.request_id,
558
+ playbook_name=cited.playbook_name,
559
+ content=decision.new_content or cited.content,
560
+ trigger=(
561
+ decision.new_trigger
562
+ if decision.new_trigger is not None
563
+ else cited.trigger
564
+ ),
565
+ rationale=(
566
+ decision.new_rationale
567
+ if decision.new_rationale is not None
568
+ else cited.rationale
569
+ ),
570
+ status=None,
571
+ source=cited.source,
572
+ source_interaction_ids=list(cited.source_interaction_ids),
573
+ )
574
+
575
+ def _record_learnings_generated(
576
+ self, request: ReflectionServiceRequest, result: ReflectionResult
577
+ ) -> None:
578
+ from reflexio.server.billing_meter import emit_learnings_generated
579
+
580
+ emit_learnings_generated(
581
+ org_id=self.request_context.org_id,
582
+ configurator=self.request_context.configurator,
583
+ count=result.revised_count,
584
+ source="reflection",
585
+ pipeline="reflection",
586
+ user_id=request.user_id,
587
+ request_id=request.request_id,
588
+ agent_version=request.agent_version,
589
+ metadata={
590
+ "cited_count": result.cited_count,
591
+ "considered_count": result.considered_count,
592
+ "trigger_revised_count": result.trigger_revised_count,
593
+ "content_revised_count": result.content_revised_count,
594
+ "ttl_changed_count": result.ttl_changed_count,
595
+ "capped_count": result.capped_count,
596
+ "skipped_count": result.skipped_count,
597
+ "failed_count": result.failed_count,
598
+ },
599
+ )
600
+
305
601
  def _resolve_cited_rows(
306
602
  self,
307
603
  *,
@@ -324,9 +620,10 @@ class ReflectionService:
324
620
  wanted_profile_ids: set[str] = set()
325
621
  wanted_playbook_ids: set[int] = set()
326
622
  for c in citations:
327
- if c.kind == "profile":
623
+ target_kind = _reflection_target_kind(c)
624
+ if target_kind == "profile":
328
625
  wanted_profile_ids.add(c.real_id)
329
- elif c.kind == "playbook":
626
+ elif target_kind == "playbook":
330
627
  try:
331
628
  wanted_playbook_ids.add(int(c.real_id))
332
629
  except (TypeError, ValueError):
@@ -465,20 +762,15 @@ class ReflectionService:
465
762
  storage = self.request_context.storage
466
763
  if storage is None:
467
764
  return False
468
- now_ts = int(datetime.now(UTC).timestamp())
469
- new_profile = UserProfile(
470
- profile_id=str(uuid.uuid4()),
471
- user_id=cited.user_id,
472
- content=decision.new_content or cited.content,
473
- last_modified_timestamp=now_ts,
474
- generated_from_request_id=cited.generated_from_request_id,
475
- profile_time_to_live=(
476
- decision.new_profile_time_to_live or cited.profile_time_to_live
477
- ),
478
- custom_features=cited.custom_features,
479
- source=cited.source,
480
- status=None,
481
- extractor_names=cited.extractor_names,
765
+ # Use the replacement row compute already built + embedded (durable /
766
+ # .run() path → skip_embedding=True), else build + embed inline (direct
767
+ # _replace_profile call with no compute-prepared plan).
768
+ precomputed = self._pending_replacement_profiles.get(cited.profile_id)
769
+ skip_embedding = precomputed is not None
770
+ new_profile = (
771
+ precomputed
772
+ if precomputed is not None
773
+ else self._build_replacement_profile(request, decision, cited)
482
774
  )
483
775
  _log_edit_magnitude(
484
776
  kind="profile",
@@ -486,7 +778,9 @@ class ReflectionService:
486
778
  old_content=cited.content,
487
779
  new_content=new_profile.content,
488
780
  )
489
- storage.add_user_profile(cited.user_id, [new_profile])
781
+ storage.add_user_profile(
782
+ cited.user_id, [new_profile], skip_embedding=skip_embedding
783
+ )
490
784
  ctx = LineageContext(
491
785
  op_kind="revise",
492
786
  actor="reflection",
@@ -557,26 +851,15 @@ class ReflectionService:
557
851
  if storage is None:
558
852
  return False
559
853
  owning_user_id = cited.user_id or request.user_id
560
- new_playbook = UserPlaybook(
561
- user_playbook_id=0, # auto-assigned by storage
562
- user_id=owning_user_id,
563
- agent_version=cited.agent_version or request.agent_version,
564
- request_id=cited.request_id,
565
- playbook_name=cited.playbook_name,
566
- content=decision.new_content or cited.content,
567
- trigger=(
568
- decision.new_trigger
569
- if decision.new_trigger is not None
570
- else cited.trigger
571
- ),
572
- rationale=(
573
- decision.new_rationale
574
- if decision.new_rationale is not None
575
- else cited.rationale
576
- ),
577
- status=None,
578
- source=cited.source,
579
- source_interaction_ids=list(cited.source_interaction_ids),
854
+ # Use the replacement row compute already built + embedded (durable /
855
+ # .run() path → skip_embedding=True), else build + embed inline (direct
856
+ # _replace_playbook call with no compute-prepared plan).
857
+ precomputed = self._pending_replacement_playbooks.get(cited.user_playbook_id)
858
+ skip_embedding = precomputed is not None
859
+ new_playbook = (
860
+ precomputed
861
+ if precomputed is not None
862
+ else self._build_replacement_playbook(request, decision, cited)
580
863
  )
581
864
  _log_edit_magnitude(
582
865
  kind="playbook",
@@ -605,6 +888,7 @@ class ReflectionService:
605
888
  new_playbook=new_playbook,
606
889
  source=new_playbook.source or "reflection",
607
890
  request_id=request.request_id,
891
+ skip_embedding=skip_embedding,
608
892
  )
609
893
  except Exception as exc: # noqa: BLE001
610
894
  with sentry_tags(
@@ -624,6 +908,11 @@ class ReflectionService:
624
908
  new_playbook.user_playbook_id,
625
909
  )
626
910
  return True
911
+ if archived > 0:
912
+ # Defer the user-playbook aggregation trigger to emit_side_effects
913
+ # (post-commit): collect the successor id so the trigger never fires
914
+ # on a rolled-back / superseded durable job.
915
+ self._pending_aggregation_successor_ids.append(archived)
627
916
  if archived < 0:
628
917
  with sentry_tags(
629
918
  subsystem="reflection",
@@ -692,6 +981,37 @@ def _log_edit_magnitude(
692
981
  )
693
982
 
694
983
 
984
+ def _dedup_decisions_by_target(
985
+ decisions: list[ReflectionDecision],
986
+ ) -> list[ReflectionDecision]:
987
+ """Keep only the first decision for each ``(target_kind, target_id)``.
988
+
989
+ The reflection LLM is prompted for one decision per cited row, but nothing
990
+ (schema or code) forbids it from emitting two decisions on the same target.
991
+ A second same-target revision is not just redundant — it corrupts state: the
992
+ replacement row is keyed by the cited id, so the second decision reuses the
993
+ first's successor uuid, re-inserts it, then supersede CAS-misses (the
994
+ incumbent is already superseded) and deletes the live successor, leaving a
995
+ dangling ``superseded_by`` pointer. Collapsing duplicates here — before both
996
+ ``_precompute_replacement_rows`` and the persist apply loop consume the
997
+ decisions — makes that torn state unreachable and keeps the two loops (which
998
+ must build/apply exactly the same rows) in lockstep.
999
+
1000
+ Ids are compared as the canonical strings the prompt is fed
1001
+ (``profile_id`` / ``str(user_playbook_id)``), which is what the LLM echoes
1002
+ back as ``target_id``.
1003
+ """
1004
+ seen: set[tuple[str, str]] = set()
1005
+ out: list[ReflectionDecision] = []
1006
+ for decision in decisions:
1007
+ key = (decision.target_kind, decision.target_id)
1008
+ if key in seen:
1009
+ continue
1010
+ seen.add(key)
1011
+ out.append(decision)
1012
+ return out
1013
+
1014
+
695
1015
  def _is_revision(decision: ReflectionDecision) -> bool:
696
1016
  """Return True iff a revision field relevant to the target kind is set.
697
1017
 
@@ -725,14 +1045,16 @@ def _flatten(
725
1045
 
726
1046
 
727
1047
  def _collect_citations(interactions: list[Interaction]) -> list[Citation]:
728
- """Pull distinct citations off Assistant interactions."""
729
- seen: set[tuple[str, str]] = set()
1048
+ """Pull distinct reflection-eligible citations off Assistant interactions."""
1049
+ seen: set[tuple[Literal["playbook", "profile"], str]] = set()
730
1050
  out: list[Citation] = []
731
1051
  for i in interactions:
732
1052
  if i.role != "Assistant":
733
1053
  continue
734
1054
  for c in i.citations:
735
- key = (c.kind, c.real_id)
1055
+ key = _reflection_citation_key(c)
1056
+ if key is None:
1057
+ continue
736
1058
  if key in seen:
737
1059
  continue
738
1060
  seen.add(key)
@@ -740,6 +1062,27 @@ def _collect_citations(interactions: list[Interaction]) -> list[Citation]:
740
1062
  return out
741
1063
 
742
1064
 
1065
+ def _reflection_target_kind(
1066
+ citation: Citation,
1067
+ ) -> Literal["playbook", "profile"] | None:
1068
+ if citation.kind == "user_playbook":
1069
+ return "playbook"
1070
+ if citation.kind == "playbook":
1071
+ return "playbook"
1072
+ if citation.kind == "profile":
1073
+ return "profile"
1074
+ return None
1075
+
1076
+
1077
+ def _reflection_citation_key(
1078
+ citation: Citation,
1079
+ ) -> tuple[Literal["playbook", "profile"], str] | None:
1080
+ target_kind = _reflection_target_kind(citation)
1081
+ if target_kind is None:
1082
+ return None
1083
+ return (target_kind, citation.real_id)
1084
+
1085
+
743
1086
  @dataclass(frozen=True)
744
1087
  class _EligibleCitation:
745
1088
  """A citation that passed the post-horizon filter.
@@ -768,8 +1111,8 @@ def _filter_citations_by_horizon(
768
1111
  ) -> list[_EligibleCitation]:
769
1112
  """Filter citations to those that have enough post-citation context.
770
1113
 
771
- For each unique ``(kind, real_id)``, finds the **earliest** occurrence
772
- in the window (maximizes follow-up turns) and decides:
1114
+ For each unique reflection citation key, finds the **earliest**
1115
+ occurrence in the window (maximizes follow-up turns) and decides:
773
1116
 
774
1117
  - ``after_count >= post_horizon_size`` → eligible, ``has_full_horizon=True``.
775
1118
  - ``position < stride_size`` → eligible (last-chance, about to fall
@@ -792,15 +1135,22 @@ def _filter_citations_by_horizon(
792
1135
  list[_EligibleCitation]: Citations to send to the LLM, each
793
1136
  paired with its window position and horizon flag.
794
1137
  """
795
- seen: dict[tuple[str, str], int] = {}
1138
+ seen: dict[tuple[Literal["playbook", "profile"], str], int] = {}
796
1139
  for idx, interaction in enumerate(window):
797
1140
  if interaction.role != "Assistant":
798
1141
  continue
799
1142
  for c in interaction.citations:
800
- key = (c.kind, c.real_id)
1143
+ key = _reflection_citation_key(c)
1144
+ if key is None:
1145
+ continue
801
1146
  seen.setdefault(key, idx) # earliest occurrence only
802
1147
 
803
- citation_by_key = {(c.kind, c.real_id): c for c in citations}
1148
+ citation_by_key: dict[tuple[Literal["playbook", "profile"], str], Citation] = {}
1149
+ for citation in citations:
1150
+ key = _reflection_citation_key(citation)
1151
+ if key is None:
1152
+ continue
1153
+ citation_by_key.setdefault(key, citation)
804
1154
 
805
1155
  out: list[_EligibleCitation] = []
806
1156
  for key, position in seen.items():