claude-smart 0.2.48 → 0.2.50

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (193) hide show
  1. package/.claude-plugin/marketplace.json +2 -2
  2. package/README.md +11 -40
  3. package/bin/claude-smart.js +393 -55
  4. package/package.json +3 -2
  5. package/plugin/.claude-plugin/plugin.json +1 -1
  6. package/plugin/.codex-plugin/plugin.json +1 -1
  7. package/plugin/.coverage +0 -0
  8. package/plugin/README.md +4 -3
  9. package/plugin/dashboard/app/api/reflexio/[...path]/route.ts +4 -2
  10. package/plugin/dashboard/app/dashboard/page.tsx +6 -1
  11. package/plugin/dashboard/app/preferences/[id]/page.tsx +18 -6
  12. package/plugin/dashboard/app/preferences/page.tsx +32 -35
  13. package/plugin/dashboard/app/sessions/[sessionId]/page.tsx +16 -1
  14. package/plugin/dashboard/app/sessions/page.tsx +2 -0
  15. package/plugin/dashboard/app/skills/page.tsx +65 -50
  16. package/plugin/dashboard/app/skills/project/[id]/page.tsx +17 -8
  17. package/plugin/dashboard/app/skills/shared/[id]/page.tsx +1 -6
  18. package/plugin/dashboard/components/common/host-badge.tsx +118 -0
  19. package/plugin/dashboard/components/common/learning-application-badge.tsx +34 -0
  20. package/plugin/dashboard/components/common/learnings-badge.tsx +1 -1
  21. package/plugin/dashboard/components/common/page-header.tsx +3 -3
  22. package/plugin/dashboard/lib/config-file.ts +5 -1
  23. package/plugin/dashboard/lib/host-attribution.ts +62 -0
  24. package/plugin/dashboard/lib/session-reader.ts +40 -2
  25. package/plugin/dashboard/lib/types.ts +7 -1
  26. package/plugin/opencode/dist/server.mjs +60 -2
  27. package/plugin/opencode/server.mts +64 -2
  28. package/plugin/pyproject.toml +2 -2
  29. package/plugin/scripts/_lib.sh +255 -7
  30. package/plugin/scripts/backend-python-runner.py +46 -0
  31. package/plugin/scripts/backend-service.sh +766 -123
  32. package/plugin/scripts/codex-hook.js +79 -229
  33. package/plugin/scripts/dashboard-open.sh +6 -4
  34. package/plugin/scripts/dashboard-service.sh +118 -137
  35. package/plugin/scripts/ensure-plugin-root.sh +106 -8
  36. package/plugin/scripts/hook_entry.sh +3 -0
  37. package/plugin/scripts/opencode-claude-compat.js +8 -1
  38. package/plugin/scripts/smart-install.sh +116 -21
  39. package/plugin/src/README.md +1 -1
  40. package/plugin/src/claude_smart/cli.py +93 -29
  41. package/plugin/src/claude_smart/context_inject.py +3 -0
  42. package/plugin/src/claude_smart/env_config.py +56 -11
  43. package/plugin/src/claude_smart/events/post_tool.py +2 -1
  44. package/plugin/src/claude_smart/events/session_end.py +2 -1
  45. package/plugin/src/claude_smart/events/stop.py +3 -0
  46. package/plugin/src/claude_smart/events/user_prompt.py +2 -1
  47. package/plugin/src/claude_smart/internal_call.py +5 -2
  48. package/plugin/src/claude_smart/optimizer_assistant.py +59 -13
  49. package/plugin/src/claude_smart/publish.py +59 -7
  50. package/plugin/src/claude_smart/reflexio_adapter.py +137 -14
  51. package/plugin/src/claude_smart/runtime.py +15 -6
  52. package/plugin/src/claude_smart/state.py +211 -52
  53. package/plugin/uv.lock +5 -5
  54. package/plugin/vendor/reflexio/.env.example +13 -0
  55. package/plugin/vendor/reflexio/reflexio/README.md +7 -3
  56. package/plugin/vendor/reflexio/reflexio/__init__.py +12 -0
  57. package/plugin/vendor/reflexio/reflexio/cli/README.md +3 -0
  58. package/plugin/vendor/reflexio/reflexio/client/client.py +166 -9
  59. package/plugin/vendor/reflexio/reflexio/integrations/openclaw/plugin/src/openclaw_smart/state.py +10 -3
  60. package/plugin/vendor/reflexio/reflexio/integrations/openclaw/plugin/tests/test_state.py +28 -0
  61. package/plugin/vendor/reflexio/reflexio/lib/_search.py +41 -0
  62. package/plugin/vendor/reflexio/reflexio/models/api_schema/domain/entities.py +195 -25
  63. package/plugin/vendor/reflexio/reflexio/models/api_schema/eval_overview_schema.py +7 -1
  64. package/plugin/vendor/reflexio/reflexio/models/api_schema/internal_schema.py +2 -1
  65. package/plugin/vendor/reflexio/reflexio/models/api_schema/retriever_schema.py +63 -4
  66. package/plugin/vendor/reflexio/reflexio/models/api_schema/ui/converters.py +1 -0
  67. package/plugin/vendor/reflexio/reflexio/models/api_schema/ui/entities.py +8 -1
  68. package/plugin/vendor/reflexio/reflexio/models/config_schema.py +1 -1
  69. package/plugin/vendor/reflexio/reflexio/server/README.md +22 -4
  70. package/plugin/vendor/reflexio/reflexio/server/__init__.py +18 -8
  71. package/plugin/vendor/reflexio/reflexio/server/__main__.py +6 -0
  72. package/plugin/vendor/reflexio/reflexio/server/api.py +79 -5
  73. package/plugin/vendor/reflexio/reflexio/server/billing_meter.py +263 -3
  74. package/plugin/vendor/reflexio/reflexio/server/callback_executor.py +164 -0
  75. package/plugin/vendor/reflexio/reflexio/server/llm/_litellm_embedding.py +81 -81
  76. package/plugin/vendor/reflexio/reflexio/server/llm/_litellm_subprocess.py +28 -0
  77. package/plugin/vendor/reflexio/reflexio/server/llm/_litellm_text_generation.py +63 -6
  78. package/plugin/vendor/reflexio/reflexio/server/llm/embedding_service.py +19 -52
  79. package/plugin/vendor/reflexio/reflexio/server/llm/litellm_client.py +1 -0
  80. package/plugin/vendor/reflexio/reflexio/server/llm/model_defaults.py +28 -0
  81. package/plugin/vendor/reflexio/reflexio/server/llm/providers/claude_code_provider.py +9 -1
  82. package/plugin/vendor/reflexio/reflexio/server/llm/providers/embedder_warmup.py +329 -0
  83. package/plugin/vendor/reflexio/reflexio/server/llm/providers/embedding_service_provider.py +85 -10
  84. package/plugin/vendor/reflexio/reflexio/server/llm/providers/local_embedding_provider.py +199 -2
  85. package/plugin/vendor/reflexio/reflexio/server/llm/providers/nomic_embedding_provider.py +77 -9
  86. package/plugin/vendor/reflexio/reflexio/server/org_fanout.py +184 -0
  87. package/plugin/vendor/reflexio/reflexio/server/prompt/prompt_bank/document_expansion/v1.0.0.prompt.md +1 -1
  88. package/plugin/vendor/reflexio/reflexio/server/prompt/prompt_bank/document_expansion/v1.1.0.prompt.md +32 -0
  89. package/plugin/vendor/reflexio/reflexio/server/prompt/prompt_bank/playbook_consolidation/v2.3.3.prompt.md +1 -1
  90. package/plugin/vendor/reflexio/reflexio/server/prompt/prompt_bank/playbook_consolidation/v2.4.0.prompt.md +63 -0
  91. package/plugin/vendor/reflexio/reflexio/server/prompt/prompt_bank/query_reformulation/v1.0.0.prompt.md +1 -1
  92. package/plugin/vendor/reflexio/reflexio/server/prompt/prompt_bank/query_reformulation/v2.0.0.prompt.md +30 -0
  93. package/plugin/vendor/reflexio/reflexio/server/prompt/prompt_bank/retrieved_learning_impact/v1.0.0.prompt.md +51 -0
  94. package/plugin/vendor/reflexio/reflexio/server/prompt/prompt_bank/retrieved_learning_relevance/v1.0.0.prompt.md +39 -0
  95. package/plugin/vendor/reflexio/reflexio/server/prompt/prompt_bank/shadow_comparison/v1.0.0.prompt.md +1 -1
  96. package/plugin/vendor/reflexio/reflexio/server/prompt/prompt_bank/shadow_comparison/v1.1.0.prompt.md +43 -0
  97. package/plugin/vendor/reflexio/reflexio/server/routes/config.py +3 -3
  98. package/plugin/vendor/reflexio/reflexio/server/routes/evaluation.py +122 -28
  99. package/plugin/vendor/reflexio/reflexio/server/routes/interactions.py +63 -2
  100. package/plugin/vendor/reflexio/reflexio/server/routes/system.py +22 -3
  101. package/plugin/vendor/reflexio/reflexio/server/scheduling.py +64 -3
  102. package/plugin/vendor/reflexio/reflexio/server/services/README.md +6 -4
  103. package/plugin/vendor/reflexio/reflexio/server/services/agent_success_evaluation/README.md +3 -2
  104. package/plugin/vendor/reflexio/reflexio/server/services/agent_success_evaluation/_eval_health.py +41 -0
  105. package/plugin/vendor/reflexio/reflexio/server/services/agent_success_evaluation/components/retrieved_learning_evaluator.py +554 -0
  106. package/plugin/vendor/reflexio/reflexio/server/services/agent_success_evaluation/regen_jobs.py +35 -1
  107. package/plugin/vendor/reflexio/reflexio/server/services/agent_success_evaluation/runner.py +253 -101
  108. package/plugin/vendor/reflexio/reflexio/server/services/agent_success_evaluation/scheduler.py +5 -7
  109. package/plugin/vendor/reflexio/reflexio/server/services/agent_success_evaluation/service.py +27 -0
  110. package/plugin/vendor/reflexio/reflexio/server/services/base_generation/_extraction_lifecycle.py +11 -5
  111. package/plugin/vendor/reflexio/reflexio/server/services/base_generation/_should_run.py +4 -4
  112. package/plugin/vendor/reflexio/reflexio/server/services/base_generation/_usage_billing.py +6 -2
  113. package/plugin/vendor/reflexio/reflexio/server/services/base_generation_service.py +255 -74
  114. package/plugin/vendor/reflexio/reflexio/server/services/deduplication_utils.py +72 -0
  115. package/plugin/vendor/reflexio/reflexio/server/services/deferred_learning_plan.py +270 -0
  116. package/plugin/vendor/reflexio/reflexio/server/services/durable_learning/__init__.py +13 -0
  117. package/plugin/vendor/reflexio/reflexio/server/services/durable_learning/scheduler.py +142 -0
  118. package/plugin/vendor/reflexio/reflexio/server/services/durable_learning/worker.py +262 -0
  119. package/plugin/vendor/reflexio/reflexio/server/services/evaluation_overview/components/hero_state.py +2 -11
  120. package/plugin/vendor/reflexio/reflexio/server/services/evaluation_overview/components/rule_attribution.py +6 -2
  121. package/plugin/vendor/reflexio/reflexio/server/services/evaluation_overview/service.py +17 -13
  122. package/plugin/vendor/reflexio/reflexio/server/services/extraction/agent_run_records.py +18 -3
  123. package/plugin/vendor/reflexio/reflexio/server/services/extraction/outcome.py +12 -1
  124. package/plugin/vendor/reflexio/reflexio/server/services/extraction/prior_answer_search.py +1 -1
  125. package/plugin/vendor/reflexio/reflexio/server/services/extraction/resumable_agent.py +2 -1
  126. package/plugin/vendor/reflexio/reflexio/server/services/extraction/resume_scheduler.py +1 -3
  127. package/plugin/vendor/reflexio/reflexio/server/services/extraction/resume_worker.py +66 -0
  128. package/plugin/vendor/reflexio/reflexio/server/services/generation_service.py +640 -80
  129. package/plugin/vendor/reflexio/reflexio/server/services/governance/service.py +2 -0
  130. package/plugin/vendor/reflexio/reflexio/server/services/lineage/gc_scheduler.py +179 -99
  131. package/plugin/vendor/reflexio/reflexio/server/services/lineage/vector_backfill_sweep.py +139 -0
  132. package/plugin/vendor/reflexio/reflexio/server/services/operation_state_utils.py +66 -27
  133. package/plugin/vendor/reflexio/reflexio/server/services/playbook/aggregation_trigger.py +177 -0
  134. package/plugin/vendor/reflexio/reflexio/server/services/playbook/components/aggregator.py +68 -2
  135. package/plugin/vendor/reflexio/reflexio/server/services/playbook/components/consolidator.py +360 -49
  136. package/plugin/vendor/reflexio/reflexio/server/services/playbook/components/extractor.py +27 -30
  137. package/plugin/vendor/reflexio/reflexio/server/services/playbook/playbook_edit_apply.py +8 -1
  138. package/plugin/vendor/reflexio/reflexio/server/services/playbook/service.py +137 -69
  139. package/plugin/vendor/reflexio/reflexio/server/services/playbook_optimizer/optimizer.py +20 -1
  140. package/plugin/vendor/reflexio/reflexio/server/services/playbook_optimizer/scheduler.py +13 -6
  141. package/plugin/vendor/reflexio/reflexio/server/services/pre_retrieval/_query_reformulator.py +40 -25
  142. package/plugin/vendor/reflexio/reflexio/server/services/profile/components/consolidator.py +12 -39
  143. package/plugin/vendor/reflexio/reflexio/server/services/profile/components/extractor.py +30 -35
  144. package/plugin/vendor/reflexio/reflexio/server/services/profile/service.py +166 -66
  145. package/plugin/vendor/reflexio/reflexio/server/services/reflection/service.py +457 -107
  146. package/plugin/vendor/reflexio/reflexio/server/services/retrieval/session_dedup.py +127 -0
  147. package/plugin/vendor/reflexio/reflexio/server/services/retrieval/temporal.py +104 -0
  148. package/plugin/vendor/reflexio/reflexio/server/services/shadow_comparison/dispatcher.py +139 -0
  149. package/plugin/vendor/reflexio/reflexio/server/services/shadow_comparison/judge.py +16 -6
  150. package/plugin/vendor/reflexio/reflexio/server/services/shadow_comparison/worker.py +137 -0
  151. package/plugin/vendor/reflexio/reflexio/server/services/storage/governance_validation.py +12 -0
  152. package/plugin/vendor/reflexio/reflexio/server/services/storage/lifecycle_filters.py +54 -0
  153. package/plugin/vendor/reflexio/reflexio/server/services/storage/retention.py +41 -3
  154. package/plugin/vendor/reflexio/reflexio/server/services/storage/retention_mixin.py +40 -2
  155. package/plugin/vendor/reflexio/reflexio/server/services/storage/sqlite_storage/__init__.py +2 -0
  156. package/plugin/vendor/reflexio/reflexio/server/services/storage/sqlite_storage/_base.py +190 -2
  157. package/plugin/vendor/reflexio/reflexio/server/services/storage/sqlite_storage/_extras.py +11 -4
  158. package/plugin/vendor/reflexio/reflexio/server/services/storage/sqlite_storage/_governance.py +28 -1
  159. package/plugin/vendor/reflexio/reflexio/server/services/storage/sqlite_storage/_learning_jobs.py +414 -0
  160. package/plugin/vendor/reflexio/reflexio/server/services/storage/sqlite_storage/_lineage.py +11 -5
  161. package/plugin/vendor/reflexio/reflexio/server/services/storage/sqlite_storage/_requests.py +8 -3
  162. package/plugin/vendor/reflexio/reflexio/server/services/storage/sqlite_storage/_shadow_verdicts.py +4 -0
  163. package/plugin/vendor/reflexio/reflexio/server/services/storage/sqlite_storage/base/_deletion.py +16 -5
  164. package/plugin/vendor/reflexio/reflexio/server/services/storage/sqlite_storage/base/_fts_vec.py +86 -2
  165. package/plugin/vendor/reflexio/reflexio/server/services/storage/sqlite_storage/governance/_erase_execution.py +104 -42
  166. package/plugin/vendor/reflexio/reflexio/server/services/storage/sqlite_storage/governance/_subject_barrier.py +7 -1
  167. package/plugin/vendor/reflexio/reflexio/server/services/storage/sqlite_storage/playbook/_agent.py +59 -7
  168. package/plugin/vendor/reflexio/reflexio/server/services/storage/sqlite_storage/playbook/_eval_results.py +430 -5
  169. package/plugin/vendor/reflexio/reflexio/server/services/storage/sqlite_storage/playbook/_source_linkage.py +8 -3
  170. package/plugin/vendor/reflexio/reflexio/server/services/storage/sqlite_storage/playbook/_user.py +57 -12
  171. package/plugin/vendor/reflexio/reflexio/server/services/storage/sqlite_storage/profiles/_interaction_store.py +197 -12
  172. package/plugin/vendor/reflexio/reflexio/server/services/storage/sqlite_storage/profiles/_profile_store.py +70 -11
  173. package/plugin/vendor/reflexio/reflexio/server/services/storage/storage_base/__init__.py +17 -0
  174. package/plugin/vendor/reflexio/reflexio/server/services/storage/storage_base/_commit_scope.py +9 -0
  175. package/plugin/vendor/reflexio/reflexio/server/services/storage/storage_base/_extras.py +9 -2
  176. package/plugin/vendor/reflexio/reflexio/server/services/storage/storage_base/_learning_jobs.py +269 -0
  177. package/plugin/vendor/reflexio/reflexio/server/services/storage/storage_base/_operations.py +7 -0
  178. package/plugin/vendor/reflexio/reflexio/server/services/storage/storage_base/_retrieval_log.py +3 -1
  179. package/plugin/vendor/reflexio/reflexio/server/services/storage/storage_base/_shadow_verdicts.py +4 -0
  180. package/plugin/vendor/reflexio/reflexio/server/services/storage/storage_base/agent_run/_models.py +12 -1
  181. package/plugin/vendor/reflexio/reflexio/server/services/storage/storage_base/evaluation_state_keys.py +78 -0
  182. package/plugin/vendor/reflexio/reflexio/server/services/storage/storage_base/playbook/_agent.py +38 -0
  183. package/plugin/vendor/reflexio/reflexio/server/services/storage/storage_base/playbook/_eval_results.py +171 -0
  184. package/plugin/vendor/reflexio/reflexio/server/services/storage/storage_base/playbook/_user.py +52 -1
  185. package/plugin/vendor/reflexio/reflexio/server/services/storage/storage_base/profiles/_interaction_store.py +82 -1
  186. package/plugin/vendor/reflexio/reflexio/server/services/storage/storage_base/profiles/_profile_store.py +74 -2
  187. package/plugin/vendor/reflexio/reflexio/server/services/storage/storage_base/retrieved_learning_state.py +226 -0
  188. package/plugin/vendor/reflexio/reflexio/server/services/tagging/tagging_scheduler.py +5 -6
  189. package/plugin/vendor/reflexio/reflexio/server/services/unified_search_service.py +209 -29
  190. package/plugin/vendor/reflexio/reflexio/server/usage_metrics.py +3 -0
  191. package/plugin/vendor/reflexio/reflexio/test_support/llm_mock.py +61 -0
  192. package/plugin/vendor/reflexio/reflexio/test_support/llm_model_registry.py +33 -0
  193. package/plugin/vendor/reflexio/reflexio/server/services/search/__init__.py +0 -0
@@ -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
 
@@ -45,6 +55,7 @@ class InteractionStoreMixin:
45
55
  embedding_dimensions: int
46
56
  _subject_ref_for_user_id: Any
47
57
  _assert_subject_writable_locked: Any
58
+ _own_transaction: Any
48
59
 
49
60
  # ------------------------------------------------------------------
50
61
  # CRUD — Interactions
@@ -71,6 +82,12 @@ class InteractionStoreMixin:
71
82
 
72
83
  @SQLiteStorageBase.handle_exceptions
73
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.
74
91
  embedding = self._get_embedding(
75
92
  f"{interaction.content}\n{interaction.user_action_description}"
76
93
  )
@@ -81,8 +98,10 @@ class InteractionStoreMixin:
81
98
  created_at_iso = _epoch_to_iso(interaction.created_at)
82
99
  subject_ref = self._subject_ref_for_user_id(interaction.user_id)
83
100
  with self._lock:
101
+ own_txn = self._own_transaction()
84
102
  try:
85
- self.conn.execute("BEGIN IMMEDIATE")
103
+ if own_txn:
104
+ self.conn.execute("BEGIN IMMEDIATE")
86
105
  self._assert_subject_writable_locked(subject_ref)
87
106
  if interaction.interaction_id:
88
107
  self.conn.execute(
@@ -90,8 +109,9 @@ class InteractionStoreMixin:
90
109
  (interaction_id, user_id, content, request_id, created_at,
91
110
  role, user_action, user_action_description,
92
111
  interacted_image_url, image_encoding, shadow_content,
93
- expert_content, tools_used, citations, embedding, governance_subject_ref)
94
- VALUES (?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?)""",
112
+ expert_content, tools_used, citations, retrieved_learnings,
113
+ embedding, governance_subject_ref)
114
+ VALUES (?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?)""",
95
115
  (
96
116
  interaction.interaction_id,
97
117
  interaction.user_id,
@@ -111,6 +131,12 @@ class InteractionStoreMixin:
111
131
  _json_dumps(
112
132
  [c.model_dump() for c in interaction.citations]
113
133
  ),
134
+ _json_dumps(
135
+ [
136
+ c.model_dump()
137
+ for c in interaction.retrieved_learnings
138
+ ]
139
+ ),
114
140
  _json_dumps(interaction.embedding),
115
141
  subject_ref,
116
142
  ),
@@ -122,8 +148,9 @@ class InteractionStoreMixin:
122
148
  (user_id, content, request_id, created_at,
123
149
  role, user_action, user_action_description,
124
150
  interacted_image_url, image_encoding, shadow_content,
125
- expert_content, tools_used, citations, embedding, governance_subject_ref)
126
- VALUES (?,?,?,?,?,?,?,?,?,?,?,?,?,?,?)""",
151
+ expert_content, tools_used, citations, retrieved_learnings,
152
+ embedding, governance_subject_ref)
153
+ VALUES (?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?)""",
127
154
  (
128
155
  interaction.user_id,
129
156
  interaction.content,
@@ -142,15 +169,23 @@ class InteractionStoreMixin:
142
169
  _json_dumps(
143
170
  [c.model_dump() for c in interaction.citations]
144
171
  ),
172
+ _json_dumps(
173
+ [
174
+ c.model_dump()
175
+ for c in interaction.retrieved_learnings
176
+ ]
177
+ ),
145
178
  _json_dumps(interaction.embedding),
146
179
  subject_ref,
147
180
  ),
148
181
  )
149
182
  iid = cur.lastrowid or 0
150
183
  interaction.interaction_id = iid
151
- self.conn.commit()
184
+ if own_txn:
185
+ self.conn.commit()
152
186
  except Exception:
153
- self.conn.rollback()
187
+ if own_txn:
188
+ self.conn.rollback()
154
189
  raise
155
190
  # Update FTS and vec
156
191
  self._fts_upsert(
@@ -168,12 +203,53 @@ class InteractionStoreMixin:
168
203
  self,
169
204
  user_id: str, # noqa: ARG002
170
205
  interactions: list[Interaction],
206
+ *,
207
+ embeddings_prepared: bool = False,
171
208
  ) -> None:
172
209
  if not interactions:
173
210
  return
211
+ if not embeddings_prepared:
212
+ # Only generate embeddings for interactions that do not already have them.
213
+ # This allows callers to pre-populate embeddings (e.g. via
214
+ # prepare_interaction_embeddings) before opening a commit_scope so that no
215
+ # network I/O occurs inside the transaction.
216
+ to_embed = [i for i in interactions if not i.embedding]
217
+ if to_embed:
218
+ texts = [
219
+ _embed_text_for_interaction(i.content, i.user_action_description)
220
+ for i in to_embed
221
+ ]
222
+ try:
223
+ embeddings = self.llm_client.get_embeddings(
224
+ texts, self.embedding_model_name, self.embedding_dimensions
225
+ )
226
+ except EmbeddingUnavailableError as exc:
227
+ logger.warning(
228
+ "Embedding unavailable for interaction bulk insert; "
229
+ "continuing without vectors: %s",
230
+ exc,
231
+ )
232
+ embeddings = [[] for _ in texts]
233
+ for interaction, embedding in zip(to_embed, embeddings, strict=False):
234
+ interaction.embedding = embedding
235
+ for interaction in interactions:
236
+ self._insert_interaction(interaction)
237
+
238
+ @SQLiteStorageBase.handle_exceptions
239
+ def prepare_interaction_embeddings(self, interactions: list[Interaction]) -> None:
240
+ """Pre-populate interaction.embedding for each interaction (no DB write).
241
+
242
+ Generates embeddings in one batch call so the write path inside a
243
+ commit_scope can skip the network round-trip.
244
+ """
245
+ if not interactions:
246
+ return
247
+ to_embed = [i for i in interactions if not i.embedding]
248
+ if not to_embed:
249
+ return
174
250
  texts = [
175
- "\n".join([i.content or "", i.user_action_description or ""])
176
- for i in interactions
251
+ _embed_text_for_interaction(i.content, i.user_action_description)
252
+ for i in to_embed
177
253
  ]
178
254
  try:
179
255
  embeddings = self.llm_client.get_embeddings(
@@ -181,14 +257,123 @@ class InteractionStoreMixin:
181
257
  )
182
258
  except EmbeddingUnavailableError as exc:
183
259
  logger.warning(
184
- "Embedding unavailable for interaction bulk insert; "
260
+ "Embedding unavailable during prepare_interaction_embeddings; "
185
261
  "continuing without vectors: %s",
186
262
  exc,
187
263
  )
188
264
  embeddings = [[] for _ in texts]
189
- for interaction, embedding in zip(interactions, embeddings, strict=False):
265
+ for interaction, embedding in zip(to_embed, embeddings, strict=False):
190
266
  interaction.embedding = embedding
191
- self._insert_interaction(interaction)
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)
192
377
 
193
378
  @SQLiteStorageBase.handle_exceptions
194
379
  def delete_user_interaction(self, request: DeleteUserInteractionRequest) -> None:
@@ -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,
@@ -77,6 +80,7 @@ class ProfileStoreMixin:
77
80
  _has_sqlite_vec: bool
78
81
  _subject_ref_for_user_id: Any
79
82
  _assert_subject_writable_locked: Any
83
+ _own_transaction: Any
80
84
 
81
85
  def _subject_ref_from_profile_row(self, row: sqlite3.Row) -> str:
82
86
  subject_ref = row["governance_subject_ref"]
@@ -207,12 +211,14 @@ class ProfileStoreMixin:
207
211
  sql = f"SELECT * FROM profiles WHERE {' AND '.join(conditions)}"
208
212
  return [_row_to_profile(r) for r in self._fetchall(sql, all_params)]
209
213
 
210
- @SQLiteStorageBase.handle_exceptions
211
- def add_user_profile(self, user_id: str, user_profiles: list[UserProfile]) -> None: # noqa: ARG002
212
- for profile in user_profiles:
213
- subject_ref = self._subject_ref_for_user_id(profile.user_id)
214
- with self._lock:
215
- 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:
216
222
  embedding_text = "\n".join([profile.content, str(profile.custom_features)])
217
223
  if self._should_expand_documents():
218
224
  with ThreadPoolExecutor(max_workers=2) as executor:
@@ -222,10 +228,31 @@ class ProfileStoreMixin:
222
228
  profile.expanded_terms = exp_future.result(timeout=15)
223
229
  else:
224
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])
225
250
  embedding = profile.embedding
226
251
  with self._lock:
252
+ own_txn = self._own_transaction()
227
253
  try:
228
- self.conn.execute("BEGIN IMMEDIATE")
254
+ if own_txn:
255
+ self.conn.execute("BEGIN IMMEDIATE")
229
256
  self._assert_subject_writable_locked(subject_ref)
230
257
  self.conn.execute(
231
258
  """INSERT OR REPLACE INTO profiles
@@ -261,9 +288,11 @@ class ProfileStoreMixin:
261
288
  subject_ref,
262
289
  ),
263
290
  )
264
- self.conn.commit()
291
+ if own_txn:
292
+ self.conn.commit()
265
293
  except Exception:
266
- self.conn.rollback()
294
+ if own_txn:
295
+ self.conn.rollback()
267
296
  raise
268
297
  fts_parts = [profile.content or ""]
269
298
  if profile.custom_features:
@@ -473,6 +502,24 @@ class ProfileStoreMixin:
473
502
  row = self._fetchone("SELECT COUNT(*) as cnt FROM profiles")
474
503
  return row["cnt"] if row else 0
475
504
 
505
+ @SQLiteStorageBase.handle_exceptions
506
+ def count_user_profiles_by_status(
507
+ self, user_ids: list[str], status: Status | None
508
+ ) -> int:
509
+ if not user_ids:
510
+ return 0
511
+
512
+ current_ts = _epoch_now()
513
+ status_frag, status_params = _build_status_sql([status])
514
+ ph = ",".join("?" for _ in user_ids)
515
+ sql = (
516
+ f"SELECT COUNT(*) as cnt FROM profiles "
517
+ f"WHERE user_id IN ({ph}) "
518
+ f"AND expiration_timestamp >= ? AND {status_frag}"
519
+ )
520
+ row = self._fetchone(sql, [*user_ids, current_ts, *status_params])
521
+ return row["cnt"] if row else 0
522
+
476
523
  @SQLiteStorageBase.handle_exceptions
477
524
  def update_all_profiles_status(
478
525
  self,
@@ -616,14 +663,25 @@ class ProfileStoreMixin:
616
663
  user_id: str,
617
664
  profile_ids: list[str],
618
665
  status_filter: list[Status | None] | None = None,
666
+ *,
667
+ include_inactive: bool = False,
619
668
  ) -> list[UserProfile]:
669
+ validate_include_inactive(
670
+ include_inactive=include_inactive, status_filter=status_filter
671
+ )
620
672
  if not profile_ids:
621
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]
622
681
  if status_filter is None:
623
682
  status_filter = [None]
624
683
  current_ts = _epoch_now()
625
684
  frag, sparams = _build_status_sql(status_filter)
626
- ph = ",".join("?" for _ in profile_ids)
627
685
  sql = (
628
686
  f"SELECT * FROM profiles "
629
687
  f"WHERE user_id = ? AND profile_id IN ({ph}) "
@@ -809,7 +867,8 @@ class ProfileStoreMixin:
809
867
  status_namespace="lifecycle_status",
810
868
  )
811
869
  committed_ids.append(pid)
812
- self.conn.commit()
870
+ if self._own_transaction():
871
+ self.conn.commit()
813
872
  return committed_ids
814
873
 
815
874
  @SQLiteStorageBase.handle_exceptions
@@ -22,7 +22,9 @@ from ._agent_run import (
22
22
  not_applicable_tool_result,
23
23
  )
24
24
  from ._base import BaseStorageCore, matches_status_filter
25
+ from ._commit_scope import CommitScopeMixin
25
26
  from ._extras import ExtrasMixin
27
+ from ._learning_jobs import LearningJob, LearningJobStatus, LearningJobStoreABC
26
28
  from ._lineage import EntityType, LineageEventMixin
27
29
  from ._operations import OperationMixin
28
30
  from ._requests import RequestMixin
@@ -48,6 +50,8 @@ from .profiles import InteractionStoreMixin, ProfileSearchMixin, ProfileStoreMix
48
50
 
49
51
 
50
52
  class BaseStorage(
53
+ LearningJobStoreABC,
54
+ CommitScopeMixin,
51
55
  AgentRunMixin,
52
56
  ProfileStoreMixin,
53
57
  InteractionStoreMixin,
@@ -260,8 +264,21 @@ class BaseStorage(
260
264
  "purged_user_playbooks": len(purge_upb_ids),
261
265
  }
262
266
 
267
+ def learning_jobs_columns(self) -> list[str]:
268
+ """Return the column names of the learning_jobs table.
269
+
270
+ Each backend overrides this to query its own schema introspection
271
+ mechanism (SQLite: PRAGMA table_info; Postgres/Supabase:
272
+ information_schema.columns).
273
+ """
274
+ raise NotImplementedError
275
+
263
276
 
264
277
  __all__ = [
278
+ "LearningJob",
279
+ "LearningJobStatus",
280
+ "LearningJobStoreABC",
281
+ "CommitScopeMixin",
265
282
  "AgentBinding",
266
283
  "AgentRunMixin",
267
284
  "EntityType",
@@ -0,0 +1,9 @@
1
+ from abc import ABC, abstractmethod
2
+ from contextlib import AbstractContextManager
3
+
4
+
5
+ class CommitScopeMixin(ABC):
6
+ @abstractmethod
7
+ def commit_scope(self) -> AbstractContextManager[None]:
8
+ """Atomic multi-write transaction; see Workstream A plan Task 1."""
9
+ raise NotImplementedError
@@ -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
  )