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
@@ -27,6 +27,16 @@ from .._base import (
27
27
  logger = logging.getLogger(__name__)
28
28
 
29
29
 
30
+ def _embed_text_for_interaction(content: str | None, action_desc: str | None) -> str:
31
+ """Derive the text embedded for an interaction.
32
+
33
+ Kept byte-for-byte identical to the ingest path
34
+ (``add_user_interactions_bulk`` / ``prepare_interaction_embeddings``) so a
35
+ backfilled vector matches a freshly-ingested one.
36
+ """
37
+ return "\n".join([content or "", action_desc or ""])
38
+
39
+
30
40
  class InteractionStoreMixin:
31
41
  """Mixin providing interaction-store CRUD for SQLite storage."""
32
42
 
@@ -72,6 +82,12 @@ class InteractionStoreMixin:
72
82
 
73
83
  @SQLiteStorageBase.handle_exceptions
74
84
  def add_user_interaction(self, user_id: str, interaction: Interaction) -> None: # noqa: ARG002
85
+ # NOTE: distinct from the bulk/backfill derivation
86
+ # (_embed_text_for_interaction). This legacy single-insert path embeds via
87
+ # the purpose-prefixed _get_embedding ("search_document: ...") and its own
88
+ # f-string, so it is intentionally NOT routed through the shared helper —
89
+ # aligning it would silently change stored embedding values (e.g. null ->
90
+ # literal "None"). The real bulk ingest + backfill share the helper.
75
91
  embedding = self._get_embedding(
76
92
  f"{interaction.content}\n{interaction.user_action_description}"
77
93
  )
@@ -93,8 +109,9 @@ class InteractionStoreMixin:
93
109
  (interaction_id, user_id, content, request_id, created_at,
94
110
  role, user_action, user_action_description,
95
111
  interacted_image_url, image_encoding, shadow_content,
96
- expert_content, tools_used, citations, embedding, governance_subject_ref)
97
- VALUES (?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?)""",
112
+ expert_content, tools_used, citations, retrieved_learnings,
113
+ embedding, governance_subject_ref)
114
+ VALUES (?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?)""",
98
115
  (
99
116
  interaction.interaction_id,
100
117
  interaction.user_id,
@@ -114,6 +131,12 @@ class InteractionStoreMixin:
114
131
  _json_dumps(
115
132
  [c.model_dump() for c in interaction.citations]
116
133
  ),
134
+ _json_dumps(
135
+ [
136
+ c.model_dump()
137
+ for c in interaction.retrieved_learnings
138
+ ]
139
+ ),
117
140
  _json_dumps(interaction.embedding),
118
141
  subject_ref,
119
142
  ),
@@ -125,8 +148,9 @@ class InteractionStoreMixin:
125
148
  (user_id, content, request_id, created_at,
126
149
  role, user_action, user_action_description,
127
150
  interacted_image_url, image_encoding, shadow_content,
128
- expert_content, tools_used, citations, embedding, governance_subject_ref)
129
- VALUES (?,?,?,?,?,?,?,?,?,?,?,?,?,?,?)""",
151
+ expert_content, tools_used, citations, retrieved_learnings,
152
+ embedding, governance_subject_ref)
153
+ VALUES (?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?)""",
130
154
  (
131
155
  interaction.user_id,
132
156
  interaction.content,
@@ -145,6 +169,12 @@ class InteractionStoreMixin:
145
169
  _json_dumps(
146
170
  [c.model_dump() for c in interaction.citations]
147
171
  ),
172
+ _json_dumps(
173
+ [
174
+ c.model_dump()
175
+ for c in interaction.retrieved_learnings
176
+ ]
177
+ ),
148
178
  _json_dumps(interaction.embedding),
149
179
  subject_ref,
150
180
  ),
@@ -186,7 +216,7 @@ class InteractionStoreMixin:
186
216
  to_embed = [i for i in interactions if not i.embedding]
187
217
  if to_embed:
188
218
  texts = [
189
- "\n".join([i.content or "", i.user_action_description or ""])
219
+ _embed_text_for_interaction(i.content, i.user_action_description)
190
220
  for i in to_embed
191
221
  ]
192
222
  try:
@@ -218,7 +248,7 @@ class InteractionStoreMixin:
218
248
  if not to_embed:
219
249
  return
220
250
  texts = [
221
- "\n".join([i.content or "", i.user_action_description or ""])
251
+ _embed_text_for_interaction(i.content, i.user_action_description)
222
252
  for i in to_embed
223
253
  ]
224
254
  try:
@@ -235,6 +265,116 @@ class InteractionStoreMixin:
235
265
  for interaction, embedding in zip(to_embed, embeddings, strict=False):
236
266
  interaction.embedding = embedding
237
267
 
268
+ # ------------------------------------------------------------------
269
+ # Missing-vector backfill (durability sweep)
270
+ # ------------------------------------------------------------------
271
+
272
+ @SQLiteStorageBase.handle_exceptions
273
+ def iter_interactions_missing_vectors(self, limit: int) -> list[tuple[int, str]]:
274
+ """Enumerate interactions whose embedding was never persisted.
275
+
276
+ A degraded/failed embedding leaves the ``embedding`` column empty
277
+ (``'[]'`` or NULL) and writes no ``interactions_vec`` row — equivalent
278
+ conditions on the write path. Detecting on the ``embedding`` column is
279
+ both the root-cause signal and portable across backends (the column
280
+ exists everywhere; ``interactions_vec`` is a SQLite-vec detail).
281
+
282
+ Args:
283
+ limit: Maximum number of interactions to return.
284
+
285
+ Returns:
286
+ list[tuple[int, str]]: ``(interaction_id, embed_text)`` pairs, at
287
+ most ``limit`` long, ordered by ``interaction_id`` for stable paging.
288
+ """
289
+ if limit <= 0:
290
+ return []
291
+ rows = self._fetchall(
292
+ """SELECT interaction_id, content, user_action_description
293
+ FROM interactions
294
+ WHERE embedding IS NULL OR embedding = '[]'
295
+ ORDER BY interaction_id
296
+ LIMIT ?""",
297
+ (limit,),
298
+ )
299
+ return [
300
+ (
301
+ r["interaction_id"],
302
+ _embed_text_for_interaction(r["content"], r["user_action_description"]),
303
+ )
304
+ for r in rows
305
+ ]
306
+
307
+ @SQLiteStorageBase.handle_exceptions
308
+ def backfill_missing_interaction_vectors(self, limit: int) -> int:
309
+ """Re-embed and persist vectors for interactions missing them.
310
+
311
+ Bounded by ``limit`` and idempotent: once the ``embedding`` column and
312
+ ``interactions_vec`` row are written, the row no longer matches
313
+ detection and is skipped next time. Embeds via the same batch call and
314
+ text derivation the ingest path uses so backfilled vectors match
315
+ freshly-ingested ones.
316
+
317
+ Fail-safe: if the embedder is unavailable the batch call raises
318
+ ``EmbeddingUnavailableError``; we log a single bounded WARN and return 0,
319
+ leaving the rows for the next tick rather than crashing the caller or
320
+ hot-looping a down embedder.
321
+
322
+ Args:
323
+ limit: Maximum number of interactions to re-embed this call.
324
+
325
+ Returns:
326
+ int: Number of interactions whose vector was backfilled.
327
+ """
328
+ if limit <= 0:
329
+ return 0
330
+ pairs = self.iter_interactions_missing_vectors(limit)
331
+ if not pairs:
332
+ return 0
333
+ texts = [text for _, text in pairs]
334
+ try:
335
+ embeddings = self.llm_client.get_embeddings(
336
+ texts, self.embedding_model_name, self.embedding_dimensions
337
+ )
338
+ except EmbeddingUnavailableError as exc:
339
+ logger.warning(
340
+ "Embedding unavailable during missing-vector backfill; leaving "
341
+ "%d interaction(s) for the next tick: %s",
342
+ len(pairs),
343
+ exc,
344
+ )
345
+ return 0
346
+ backfilled = 0
347
+ for (iid, _text), embedding in zip(pairs, embeddings, strict=False):
348
+ if not embedding:
349
+ # Embedder returned empty for this row — leave it for next tick.
350
+ continue
351
+ self._persist_backfilled_vector(iid, embedding)
352
+ backfilled += 1
353
+ return backfilled
354
+
355
+ def _persist_backfilled_vector(
356
+ self, interaction_id: int, embedding: list[float]
357
+ ) -> None:
358
+ """Write a re-embedded vector for one interaction (column + vec row).
359
+
360
+ Updates the ``embedding`` TEXT column (read by the Python vector-rank
361
+ search path) and upserts the ``interactions_vec`` row via the same
362
+ ``_vec_upsert`` helper the insert path uses.
363
+
364
+ Only commits when it owns the transaction (mirroring ``_insert_interaction``)
365
+ so it can never flush a partial outer transaction if ever called inside an
366
+ open ``commit_scope``; ``_vec_upsert`` likewise defers under an open scope.
367
+ """
368
+ with self._lock:
369
+ own_txn = self._own_transaction()
370
+ self.conn.execute(
371
+ "UPDATE interactions SET embedding = ? WHERE interaction_id = ?",
372
+ (_json_dumps(embedding), interaction_id),
373
+ )
374
+ if own_txn:
375
+ self.conn.commit()
376
+ self._vec_upsert("interactions_vec", interaction_id, embedding)
377
+
238
378
  @SQLiteStorageBase.handle_exceptions
239
379
  def delete_user_interaction(self, request: DeleteUserInteractionRequest) -> None:
240
380
  with self._lock:
@@ -16,6 +16,9 @@ from reflexio.models.api_schema.service_schemas import (
16
16
  Status,
17
17
  UserProfile,
18
18
  )
19
+ from reflexio.server.services.storage.lifecycle_filters import (
20
+ validate_include_inactive,
21
+ )
19
22
 
20
23
  from .._base import (
21
24
  _PROFILE_TOMBSTONE_STATUS_VALUES,
@@ -208,12 +211,14 @@ class ProfileStoreMixin:
208
211
  sql = f"SELECT * FROM profiles WHERE {' AND '.join(conditions)}"
209
212
  return [_row_to_profile(r) for r in self._fetchall(sql, all_params)]
210
213
 
211
- @SQLiteStorageBase.handle_exceptions
212
- def add_user_profile(self, user_id: str, user_profiles: list[UserProfile]) -> None: # noqa: ARG002
213
- for profile in user_profiles:
214
- subject_ref = self._subject_ref_for_user_id(profile.user_id)
215
- with self._lock:
216
- self._assert_subject_writable_locked(subject_ref)
214
+ def precompute_profile_embeddings(self, profiles: list[UserProfile]) -> None:
215
+ """Populate ``.embedding`` / ``.expanded_terms`` in place; no DB write.
216
+
217
+ Extracted verbatim from the former ``add_user_profile`` prelude so the
218
+ durable compute/persist split can embed outside the writer transaction
219
+ and then persist with ``skip_embedding=True``.
220
+ """
221
+ for profile in profiles:
217
222
  embedding_text = "\n".join([profile.content, str(profile.custom_features)])
218
223
  if self._should_expand_documents():
219
224
  with ThreadPoolExecutor(max_workers=2) as executor:
@@ -223,6 +228,25 @@ class ProfileStoreMixin:
223
228
  profile.expanded_terms = exp_future.result(timeout=15)
224
229
  else:
225
230
  profile.embedding = self._get_embedding(embedding_text)
231
+
232
+ @SQLiteStorageBase.handle_exceptions
233
+ def add_user_profile(
234
+ self,
235
+ user_id: str, # noqa: ARG002
236
+ user_profiles: list[UserProfile],
237
+ *,
238
+ skip_embedding: bool = False,
239
+ ) -> None:
240
+ for profile in user_profiles:
241
+ subject_ref = self._subject_ref_for_user_id(profile.user_id)
242
+ with self._lock:
243
+ self._assert_subject_writable_locked(subject_ref)
244
+ # Default (skip_embedding=False) recomputes unconditionally, exactly
245
+ # as before — model_copy callers that change content while keeping
246
+ # the old embedding depend on this. The durable persist path opts
247
+ # out (embedding already set by precompute_profile_embeddings).
248
+ if not skip_embedding:
249
+ self.precompute_profile_embeddings([profile])
226
250
  embedding = profile.embedding
227
251
  with self._lock:
228
252
  own_txn = self._own_transaction()
@@ -639,14 +663,25 @@ class ProfileStoreMixin:
639
663
  user_id: str,
640
664
  profile_ids: list[str],
641
665
  status_filter: list[Status | None] | None = None,
666
+ *,
667
+ include_inactive: bool = False,
642
668
  ) -> list[UserProfile]:
669
+ validate_include_inactive(
670
+ include_inactive=include_inactive, status_filter=status_filter
671
+ )
643
672
  if not profile_ids:
644
673
  return []
674
+ ph = ",".join("?" for _ in profile_ids)
675
+ if include_inactive:
676
+ rows = self._fetchall(
677
+ f"SELECT * FROM profiles WHERE user_id = ? AND profile_id IN ({ph})",
678
+ (user_id, *profile_ids),
679
+ )
680
+ return [_row_to_profile(r) for r in rows]
645
681
  if status_filter is None:
646
682
  status_filter = [None]
647
683
  current_ts = _epoch_now()
648
684
  frag, sparams = _build_status_sql(status_filter)
649
- ph = ",".join("?" for _ in profile_ids)
650
685
  sql = (
651
686
  f"SELECT * FROM profiles "
652
687
  f"WHERE user_id = ? AND profile_id IN ({ph}) "
@@ -1,6 +1,5 @@
1
1
  from reflexio.models.api_schema.domain.enums import Status
2
2
 
3
- from ._learning_jobs import LearningJob, LearningJobStatus, LearningJobStoreABC
4
3
  from ._agent_run import (
5
4
  NOT_APPLICABLE_ANSWER,
6
5
  AgentBinding,
@@ -25,6 +24,7 @@ from ._agent_run import (
25
24
  from ._base import BaseStorageCore, matches_status_filter
26
25
  from ._commit_scope import CommitScopeMixin
27
26
  from ._extras import ExtrasMixin
27
+ from ._learning_jobs import LearningJob, LearningJobStatus, LearningJobStoreABC
28
28
  from ._lineage import EntityType, LineageEventMixin
29
29
  from ._operations import OperationMixin
30
30
  from ._requests import RequestMixin
@@ -1,13 +1,20 @@
1
1
  from abc import abstractmethod
2
+ from typing import cast
2
3
 
3
4
  from reflexio.models.api_schema.braintrust_schema import (
4
5
  BraintrustConnection,
5
6
  ImportedScore,
6
7
  )
7
8
  from reflexio.models.api_schema.domain import (
9
+ CitationKind,
8
10
  Interaction,
9
11
  )
10
12
  from reflexio.models.api_schema.internal_schema import SessionCitation
13
+
14
+ # Stored citation dicts are untyped JSON; only these kinds are valid.
15
+ CITATION_KINDS: frozenset[str] = frozenset(
16
+ {"playbook", "profile", "user_playbook", "agent_playbook"}
17
+ )
11
18
  from reflexio.models.api_schema.retriever_schema import PlaybookApplicationStat
12
19
 
13
20
 
@@ -142,12 +149,12 @@ class ExtrasMixin:
142
149
  kind = getattr(cite, "kind", None)
143
150
  real_id = getattr(cite, "real_id", None)
144
151
  title = getattr(cite, "title", "") or ""
145
- if kind and real_id:
152
+ if str(kind) in CITATION_KINDS and real_id:
146
153
  out.append(
147
154
  SessionCitation(
148
155
  user_id="",
149
156
  session_id=session_id,
150
- kind=str(kind),
157
+ kind=cast("CitationKind", str(kind)),
151
158
  real_id=str(real_id),
152
159
  title=str(title),
153
160
  )
@@ -145,6 +145,7 @@ class LearningJobStoreABC(ABC):
145
145
  job_id: str,
146
146
  claim_token: str,
147
147
  dead: bool,
148
+ refund_attempt: bool = False,
148
149
  ) -> None:
149
150
  """Fenced fail/dead transition — sets status, does NOT increment attempts.
150
151
 
@@ -157,6 +158,25 @@ class LearningJobStoreABC(ABC):
157
158
  worker's dead-gate (``job.attempts >= max_attempts``) depends on that
158
159
  single-increment-per-claim invariant. Incrementing here would
159
160
  double-count and misfire the dead transition.
161
+
162
+ ``refund_attempt`` (default ``False``): when ``True``, decrement
163
+ ``attempts`` by one (floored at 0) as part of this transition. This is
164
+ the same-user-contention requeue path (F4): the worker claimed the job
165
+ (``claim_learning_jobs`` did ``attempts += 1``) but could not run because
166
+ another same-user job holds the per-user lock, so it releases the job
167
+ WITHOUT having done any real work. Refunding the claim's increment makes
168
+ a contention cycle (claim +1, contention-release -1) net to zero, so a
169
+ job that loses the same-user race repeatedly (every ~2s poll while the
170
+ holder runs its ~60s compute) does NOT inflate ``attempts`` past
171
+ ``max_attempts`` and dead-letter with zero real retries. It is a no-op
172
+ unless ``refund_attempt=True``, so the ``dead`` retry path is unchanged.
173
+
174
+ # TASK 10: the enterprise Supabase (``reflexio_ext/server/services/
175
+ # storage/supabase_storage/_learning_jobs.py``) and native-Postgres
176
+ # ``fail_learning_job`` impls (and their ``claim_learning_jobs`` SQL
177
+ # functions) need this same ``refund_attempt`` decrement. The param is
178
+ # optional with a default so this base signature stays back-compatible
179
+ # until they are updated.
160
180
  """
161
181
  raise NotImplementedError
162
182
 
@@ -179,6 +199,36 @@ class LearningJobStoreABC(ABC):
179
199
  """
180
200
  raise NotImplementedError
181
201
 
202
+ @abstractmethod
203
+ def get_oldest_pending_learning_job_age_seconds(self) -> float | None:
204
+ """Age in seconds of the oldest ``status='pending'`` job on this storage ref.
205
+
206
+ Returns:
207
+ The age in seconds (``now - created_at``) of the oldest pending job,
208
+ or ``None`` if there are no pending jobs.
209
+
210
+ Resolves the table via the same placement resolver used by
211
+ ``list_org_ids_with_pending_learning_jobs`` — never hardcodes
212
+ ``public.`` or ``reflexio_ops.``.
213
+ """
214
+ raise NotImplementedError
215
+
216
+ @abstractmethod
217
+ def count_learning_jobs_by_status(self, status: str) -> int:
218
+ """Count of learning jobs with the given ``status`` on this storage ref.
219
+
220
+ Args:
221
+ status: One of ``'pending'``, ``'claimed'``, ``'done'``,
222
+ ``'failed'``, or ``'dead'``.
223
+
224
+ Returns:
225
+ The integer count of matching rows (0 if none).
226
+
227
+ Resolves the table via the same placement resolver used by the other
228
+ query methods — never hardcodes schema prefix.
229
+ """
230
+ raise NotImplementedError
231
+
182
232
  @abstractmethod
183
233
  def get_learning_status_for_request(
184
234
  self,
@@ -146,6 +146,10 @@ class OperationMixin:
146
146
  the current holder) are dropped to keep the queue idempotent under
147
147
  publish retries.
148
148
 
149
+ ``request_id`` is the intentional storage-boundary name here. Higher
150
+ layers may call the same value ``lock_request_id``, but persisted lock
151
+ rows and queued entries still use the legacy ``request_id`` key.
152
+
149
153
  The queue is a FIFO drained one entry at a time when the holder
150
154
  releases the lock. It replaces the older single-slot
151
155
  ``pending_request_id`` field, which silently dropped earlier blocked
@@ -178,6 +182,9 @@ class OperationMixin:
178
182
  ) -> bool:
179
183
  """Atomically clear an in-progress lock only if ``request_id`` still owns it.
180
184
 
185
+ The storage boundary keeps the legacy ``request_id`` naming for the
186
+ persisted lock holder even when callers use clearer local names.
187
+
181
188
  Args:
182
189
  state_key: Operation-state row key for the lock.
183
190
  request_id: Request attempting to release the lock.
@@ -7,7 +7,9 @@ class RetrievalLogMixin:
7
7
  These methods are optional retrieval-capture storage hooks. They are
8
8
  intentionally concrete (not ``@abstractmethod``) so concrete storage classes
9
9
  remain instantiable; each raises ``NotImplementedError`` until a backend
10
- provides a real implementation.
10
+ provides a real implementation. Stored item rows may use either the legacy
11
+ ``agent_playbook_id`` field or the explicit ``target_kind`` / ``target_id``
12
+ pair; readers should preserve backward compatibility for both.
11
13
  """
12
14
 
13
15
  def save_playbook_retrieval_log(self, log: PlaybookRetrievalLog) -> int:
@@ -34,6 +34,10 @@ class ShadowVerdictsMixin:
34
34
  clear error message naming the backend rather than ``AttributeError``.
35
35
  """
36
36
 
37
+ def supports_shadow_comparison_verdicts(self) -> bool:
38
+ """Return whether this backend can persist shadow comparison verdicts."""
39
+ return False
40
+
37
41
  def save_shadow_comparison_verdict(
38
42
  self, verdict: ShadowComparisonVerdict
39
43
  ) -> ShadowComparisonVerdict:
@@ -44,7 +44,12 @@ class RunToolDependencyKind(StrEnum):
44
44
 
45
45
  @dataclass(frozen=True)
46
46
  class AgentBinding:
47
- """Logical run binding flattened into `_agent_runs` storage columns."""
47
+ """Logical run binding flattened into `_agent_runs` storage columns.
48
+
49
+ ``request_id`` remains the persisted provenance field name at this storage
50
+ boundary even when higher-level helpers refer to the same value as a
51
+ generation request id.
52
+ """
48
53
 
49
54
  org_id: str
50
55
  extractor_kind: str
@@ -60,6 +65,12 @@ class AgentBinding:
60
65
 
61
66
  @dataclass(frozen=True)
62
67
  class AgentRunRecord:
68
+ """Durable agent run row plus snapshots used for resume/finalization.
69
+
70
+ ``generation_request_snapshot`` intentionally keeps a legacy ``request_id``
71
+ key for stored provenance.
72
+ """
73
+
63
74
  id: str
64
75
  binding: AgentBinding
65
76
  status: AgentRunStatus
@@ -0,0 +1,78 @@
1
+ """Single source of truth for evaluation ``_operation_state`` key formats.
2
+
3
+ Three evaluation namespaces store per-session state in ``_operation_state``:
4
+
5
+ 1. ``agent_success_group_eval`` — the runner's "evaluated" marker.
6
+ 2. ``grade_on_demand`` — the 24h on-demand grading cache.
7
+ 3. ``retrieved_learning_eval`` — retrieved-learning generation/completion
8
+ state (see ``retrieved_learning_state.build_retrieved_learning_state_key``).
9
+
10
+ Governance erasure must scrub all three for an erased user's sessions, so the
11
+ key builders live here where both the producers (runner / evaluation route)
12
+ and the erasers (sqlite + supabase governance) can import them without
13
+ layering cycles.
14
+ """
15
+
16
+ from __future__ import annotations
17
+
18
+ AGENT_SUCCESS_MARKER_PREFIX = "agent_success_group_eval"
19
+ GRADE_ON_DEMAND_CACHE_PREFIX = "grade_on_demand"
20
+
21
+
22
+ def build_agent_success_marker_key(org_id: str, user_id: str, session_id: str) -> str:
23
+ """Build the agent-success "evaluated" marker key for a session.
24
+
25
+ Historical plain ``::`` join — kept byte-identical so existing markers
26
+ stay addressable.
27
+
28
+ Args:
29
+ org_id (str): Organization ID.
30
+ user_id (str): Session owner.
31
+ session_id (str): Evaluated session.
32
+
33
+ Returns:
34
+ str: The marker key.
35
+ """
36
+ return f"{AGENT_SUCCESS_MARKER_PREFIX}::{org_id}::{user_id}::{session_id}"
37
+
38
+
39
+ def build_grade_on_demand_cache_key(
40
+ org_id: str, session_id: str, agent_version: str, evaluation_name: str
41
+ ) -> str:
42
+ """Build the grade-on-demand cache key (length-prefixed components).
43
+
44
+ Length-prefixing each free-form component keeps the join injective even
45
+ when a component contains the ``::`` delimiter.
46
+
47
+ Args:
48
+ org_id (str): Organization ID.
49
+ session_id (str): Target session.
50
+ agent_version (str): Agent version filter.
51
+ evaluation_name (str): Evaluator/result namespace.
52
+
53
+ Returns:
54
+ str: The cache key.
55
+ """
56
+ parts = "::".join(
57
+ f"{len(s)}:{s}" for s in (org_id, session_id, agent_version, evaluation_name)
58
+ )
59
+ return f"{GRADE_ON_DEMAND_CACHE_PREFIX}::{parts}"
60
+
61
+
62
+ def build_grade_on_demand_session_prefix(org_id: str, session_id: str) -> str:
63
+ """Prefix matching every grade-on-demand cache key for one session.
64
+
65
+ Used by governance erasure to find a session's cache rows regardless of
66
+ agent_version / evaluation_name.
67
+
68
+ Args:
69
+ org_id (str): Organization ID.
70
+ session_id (str): Target session.
71
+
72
+ Returns:
73
+ str: The per-session key prefix (ends with ``::``).
74
+ """
75
+ return (
76
+ f"{GRADE_ON_DEMAND_CACHE_PREFIX}"
77
+ f"::{len(org_id)}:{org_id}::{len(session_id)}:{session_id}::"
78
+ )
@@ -161,6 +161,44 @@ class AgentPlaybookStoreMixin:
161
161
  """
162
162
  raise NotImplementedError
163
163
 
164
+ @abstractmethod
165
+ def get_agent_playbooks_by_ids(
166
+ self,
167
+ agent_playbook_ids: list[int],
168
+ *,
169
+ status_filter: list[Status | None] | None = None,
170
+ playbook_status_filter: list[PlaybookStatus] | None = None,
171
+ include_inactive: bool = False,
172
+ ) -> list[AgentPlaybook]:
173
+ """Fetch agent playbooks in bulk with lifecycle filters.
174
+
175
+ Args:
176
+ agent_playbook_ids (list[int]): Playbook ids to fetch. Empty list
177
+ returns ``[]`` without hitting storage.
178
+ status_filter (list[Status | None] | None): Lifecycle statuses to
179
+ include. ``None`` (default) means CURRENT only.
180
+ playbook_status_filter (list[PlaybookStatus] | None): Approval
181
+ statuses to include. ``None`` (default) means no approval filter.
182
+ include_inactive (bool): Return every matching row regardless of
183
+ lifecycle status *or* approval status. This is the *historical
184
+ resolution* mode (see ``RetrievedLearningEvaluator``): it answers
185
+ "what did this id point at", not "what is retrievable now", so an
186
+ archived or never-approved playbook that was actually served is
187
+ still returned. It is a strict superset of ``include_tombstones``
188
+ on ``get_agent_playbook_by_id``, which only unhides
189
+ MERGED/SUPERSEDED for lineage walks. The default preserves
190
+ retrieval behavior.
191
+
192
+ Returns:
193
+ list[AgentPlaybook]: Matching playbooks. Order is unspecified.
194
+
195
+ Raises:
196
+ StorageError: If ``include_inactive`` is combined with an explicit
197
+ ``status_filter`` or ``playbook_status_filter`` — the two are
198
+ contradictory.
199
+ """
200
+ raise NotImplementedError
201
+
164
202
  @abstractmethod
165
203
  def delete_all_agent_playbooks(self) -> None:
166
204
  """Delete all agent playbooks from storage."""