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
@@ -12,7 +12,44 @@ class UserPlaybookStoreMixin:
12
12
  """Abstract user playbook CRUD + search methods."""
13
13
 
14
14
  @abstractmethod
15
- def save_user_playbooks(self, user_playbooks: list[UserPlaybook]) -> None:
15
+ def save_user_playbooks(
16
+ self,
17
+ user_playbooks: list[UserPlaybook],
18
+ *,
19
+ skip_embedding: bool = False,
20
+ ) -> None:
21
+ """Insert user playbooks, assigning survivor ids.
22
+
23
+ Args:
24
+ user_playbooks: Playbooks to insert.
25
+ skip_embedding: When ``False`` (default — what every current caller
26
+ gets), the embedding (and, when document expansion is enabled,
27
+ ``expanded_terms``) is recomputed unconditionally at write time,
28
+ exactly as before. Callers that ``model_copy`` a DB-loaded row
29
+ with changed content but the old embedding preserved rely on
30
+ this recompute, so it must stay the default. When ``True``, the
31
+ embedding step is skipped because ``.embedding`` was already
32
+ populated up front by
33
+ :meth:`precompute_user_playbook_embeddings` (the durable
34
+ compute/persist split — the only caller that opts in). A bare
35
+ ``if not up.embedding`` guard is deliberately NOT used: it would
36
+ persist a stale vector for the ``model_copy`` callers' changed
37
+ content (silent search corruption).
38
+ """
39
+ raise NotImplementedError
40
+
41
+ @abstractmethod
42
+ def precompute_user_playbook_embeddings(
43
+ self, playbooks: list[UserPlaybook]
44
+ ) -> None:
45
+ """Populate ``.embedding`` (and ``.expanded_terms`` when document
46
+ expansion is enabled) on each playbook in place, issuing NO DB write.
47
+
48
+ Lets the durable learning worker do the (slow, LLM/embedding) compute
49
+ outside the writer transaction, then pass ``skip_embedding=True`` to
50
+ :meth:`save_user_playbooks` so the fenced persist writes the
51
+ pre-computed vector without re-embedding.
52
+ """
16
53
  raise NotImplementedError
17
54
 
18
55
  @abstractmethod
@@ -183,6 +220,8 @@ class UserPlaybookStoreMixin:
183
220
  user_id: str,
184
221
  user_playbook_ids: list[int],
185
222
  status_filter: list[Status | None] | None = None,
223
+ *,
224
+ include_inactive: bool = False,
186
225
  ) -> list[UserPlaybook]:
187
226
  """Fetch the subset of a user's playbooks whose ids are in the list.
188
227
 
@@ -197,11 +236,23 @@ class UserPlaybookStoreMixin:
197
236
  status_filter (list[Status | None] | None): Statuses to
198
237
  include. ``None`` (default) means CURRENT only — same
199
238
  default as ``get_user_playbooks`` for consistency.
239
+ include_inactive (bool): Return matching owned rows regardless of
240
+ lifecycle status. This is the *historical resolution* mode (see
241
+ ``RetrievedLearningEvaluator``): it answers "what did this id
242
+ point at", not "what is retrievable now". It is a strict superset
243
+ of ``include_tombstones`` on ``get_user_playbook_by_id``, which
244
+ only unhides MERGED/SUPERSEDED for lineage walks —
245
+ ``include_inactive`` also returns ARCHIVED rows. ``user_id``
246
+ scoping still applies. The default preserves retrieval behavior.
200
247
 
201
248
  Returns:
202
249
  list[UserPlaybook]: Matching playbooks. Order is unspecified.
203
250
  Ids that do not exist (or do not match the user / status
204
251
  filter) are silently omitted.
252
+
253
+ Raises:
254
+ StorageError: If ``include_inactive`` is combined with an explicit
255
+ ``status_filter`` — the two are contradictory.
205
256
  """
206
257
  raise NotImplementedError
207
258
 
@@ -28,16 +28,97 @@ class InteractionStoreMixin:
28
28
 
29
29
  @abstractmethod
30
30
  def add_user_interactions_bulk(
31
- self, user_id: str, interactions: list[Interaction]
31
+ self,
32
+ user_id: str,
33
+ interactions: list[Interaction],
34
+ *,
35
+ embeddings_prepared: bool = False,
32
36
  ) -> None:
33
37
  """Add multiple user interactions with batched embedding generation.
34
38
 
35
39
  Args:
36
40
  user_id: The user ID
37
41
  interactions: List of interactions to add
42
+ embeddings_prepared: When True, skip all embedding generation and write
43
+ whatever embedding is already on each interaction (real or ``[]``).
44
+ Use on the durable path after ``prepare_interaction_embeddings`` to
45
+ ensure no network I/O occurs inside an open ``commit_scope``.
38
46
  """
39
47
  raise NotImplementedError
40
48
 
49
+ def prepare_interaction_embeddings(self, interactions: list[Interaction]) -> None: # noqa: ARG002
50
+ """Pre-populate interaction.embedding for each interaction without writing to storage.
51
+
52
+ Call this before opening a commit_scope to avoid a network round-trip inside
53
+ the transaction. Subclasses that generate embeddings client-side MUST override
54
+ this to call get_embeddings and populate interaction.embedding before the scope
55
+ is opened; backends where the embedding is generated server-side can leave this
56
+ as a no-op.
57
+
58
+ Args:
59
+ interactions: Interactions whose embedding fields will be populated in-place.
60
+ """
61
+ return
62
+
63
+ def iter_interactions_missing_vectors(
64
+ self,
65
+ limit: int, # noqa: ARG002
66
+ ) -> list[tuple[int, str]]:
67
+ """Enumerate interactions whose embedding vector was never persisted.
68
+
69
+ When an embedding call fails or degrades, the ingest path stores an empty
70
+ embedding (and writes no vector row), returns success, and nothing ever
71
+ re-embeds the row — so that interaction is invisible to vector/hybrid
72
+ search forever. This method surfaces those rows so a background sweep can
73
+ re-embed them (see ``backfill_missing_interaction_vectors``).
74
+
75
+ Returns ``(interaction_id, embed_text)`` pairs where ``embed_text`` is
76
+ derived exactly as the ingest path derives it, so a backfilled vector is
77
+ byte-for-byte comparable to a freshly-ingested one.
78
+
79
+ Safe default: returns ``[]`` — "this backend does not support backfill
80
+ detection". Backends that store embeddings (SQLite here; Supabase and
81
+ Postgres in the enterprise repo) MUST override this so their rows are
82
+ actually recoverable. Bounded by ``limit`` per call.
83
+
84
+ Args:
85
+ limit: Maximum number of interactions to return.
86
+
87
+ Returns:
88
+ list[tuple[int, str]]: ``(interaction_id, embed_text)`` pairs, at
89
+ most ``limit`` long. Empty when nothing needs backfilling or the
90
+ backend does not support detection.
91
+ """
92
+ return []
93
+
94
+ def backfill_missing_interaction_vectors(
95
+ self,
96
+ limit: int, # noqa: ARG002
97
+ ) -> int:
98
+ """Re-embed and persist vectors for interactions missing them.
99
+
100
+ Idempotent and bounded: enumerates up to ``limit`` interactions via
101
+ ``iter_interactions_missing_vectors``, re-embeds their text using the
102
+ same embedder + derivation the ingest path uses, and writes the vector
103
+ back through the same code path a fresh insert uses. Once a vector
104
+ exists the row is skipped on the next call.
105
+
106
+ Fail-safe: if the embedder is unavailable the implementation must skip
107
+ gracefully and leave the work for a later call (never raise, never
108
+ hot-loop a down embedder).
109
+
110
+ Safe default: returns ``0`` — "this backend does not support backfill".
111
+ SQLite overrides this; the enterprise Supabase/Postgres backends must
112
+ override it (and ``iter_interactions_missing_vectors``) for production.
113
+
114
+ Args:
115
+ limit: Maximum number of interactions to re-embed this call.
116
+
117
+ Returns:
118
+ int: Number of interactions whose vector was backfilled.
119
+ """
120
+ return 0
121
+
41
122
  @abstractmethod
42
123
  def delete_user_interaction(self, request: DeleteUserInteractionRequest) -> None:
43
124
  raise NotImplementedError
@@ -66,8 +66,43 @@ class ProfileStoreMixin:
66
66
  raise NotImplementedError
67
67
 
68
68
  @abstractmethod
69
- def add_user_profile(self, user_id: str, user_profiles: list[UserProfile]) -> None:
70
- """Add the user profile for a given user id."""
69
+ def add_user_profile(
70
+ self,
71
+ user_id: str,
72
+ user_profiles: list[UserProfile],
73
+ *,
74
+ skip_embedding: bool = False,
75
+ ) -> None:
76
+ """Add the user profile for a given user id.
77
+
78
+ Args:
79
+ user_id: The owning user id (positional, unused by some backends).
80
+ user_profiles: Profiles to insert.
81
+ skip_embedding: When ``False`` (default — what every current caller
82
+ gets), the embedding (and, when document expansion is enabled,
83
+ ``expanded_terms``) is recomputed unconditionally at write time,
84
+ exactly as before. Callers that ``model_copy`` a DB-loaded row
85
+ with changed content but the old embedding preserved rely on
86
+ this recompute, so it must stay the default. When ``True``, the
87
+ embedding step is skipped because ``.embedding`` was already
88
+ populated up front by :meth:`precompute_profile_embeddings`
89
+ (the durable compute/persist split — the only caller that opts
90
+ in). A bare ``if not profile.embedding`` guard is deliberately
91
+ NOT used: it would persist a stale vector for the ``model_copy``
92
+ callers' changed content (silent search corruption).
93
+ """
94
+ raise NotImplementedError
95
+
96
+ @abstractmethod
97
+ def precompute_profile_embeddings(self, profiles: list[UserProfile]) -> None:
98
+ """Populate ``.embedding`` (and ``.expanded_terms`` when document
99
+ expansion is enabled) on each profile in place, issuing NO DB write.
100
+
101
+ Lets the durable learning worker do the (slow, LLM/embedding) compute
102
+ outside the writer transaction, then pass ``skip_embedding=True`` to
103
+ :meth:`add_user_profile` so the fenced persist writes the pre-computed
104
+ vector without re-embedding.
105
+ """
71
106
  raise NotImplementedError
72
107
 
73
108
  @abstractmethod
@@ -110,6 +145,28 @@ class ProfileStoreMixin:
110
145
  """
111
146
  raise NotImplementedError
112
147
 
148
+ def count_user_profiles_by_status(
149
+ self, user_ids: list[str], status: Status | None
150
+ ) -> int:
151
+ """Count non-expired profiles for concrete users with the given status.
152
+
153
+ Storage backends may override this with a single query. The default keeps
154
+ the storage contract backward-compatible for out-of-tree backends.
155
+
156
+ Args:
157
+ user_ids: Concrete user IDs to include. Empty list returns 0.
158
+ status: Profile status to match; None means CURRENT.
159
+
160
+ Returns:
161
+ int: Number of matching profiles
162
+ """
163
+ if not user_ids:
164
+ return 0
165
+ return sum(
166
+ len(self.get_user_profile(user_id=user_id, status_filter=[status]))
167
+ for user_id in user_ids
168
+ )
169
+
113
170
  @abstractmethod
114
171
  def update_all_profiles_status(
115
172
  self,
@@ -153,6 +210,8 @@ class ProfileStoreMixin:
153
210
  user_id: str,
154
211
  profile_ids: list[str],
155
212
  status_filter: list[Status | None] | None = None,
213
+ *,
214
+ include_inactive: bool = False,
156
215
  ) -> list[UserProfile]:
157
216
  """Fetch the subset of a user's profiles whose ids are in the list.
158
217
 
@@ -167,11 +226,24 @@ class ProfileStoreMixin:
167
226
  status_filter (list[Status | None] | None): Statuses to
168
227
  include. ``None`` (default) means CURRENT only — same
169
228
  default as ``get_user_profile`` for consistency.
229
+ include_inactive (bool): Return matching owned rows regardless of
230
+ lifecycle status or expiry. This is the *historical resolution*
231
+ mode (see ``RetrievedLearningEvaluator``): it answers "what did
232
+ this id point at", not "what is retrievable now". It is a strict
233
+ superset of ``include_tombstones`` on ``get_profile_by_id``,
234
+ which only unhides MERGED/SUPERSEDED for lineage walks —
235
+ ``include_inactive`` also returns ARCHIVED and EXPIRED rows.
236
+ ``user_id`` scoping still applies. The default preserves
237
+ retrieval behavior.
170
238
 
171
239
  Returns:
172
240
  list[UserProfile]: Matching profiles. Order is unspecified.
173
241
  Ids that do not exist (or do not match the user / status
174
242
  filter) are silently omitted.
243
+
244
+ Raises:
245
+ StorageError: If ``include_inactive`` is combined with an explicit
246
+ ``status_filter`` — the two are contradictory.
175
247
  """
176
248
  raise NotImplementedError
177
249
 
@@ -0,0 +1,226 @@
1
+ """Shared pure helpers for retrieved-learning evaluation state.
2
+
3
+ Used by every storage backend (SQLite, Supabase, native Postgres) and by the
4
+ group-evaluation runner, so this module must stay free of storage imports.
5
+
6
+ Concurrency model (no mutation-site instrumentation):
7
+
8
+ - The **session fingerprint** is a SHA-256 digest over every interaction ID in
9
+ the session plus each interaction's canonical attachment refs. Any
10
+ interaction publish or delete changes it (autoincrement interaction IDs make
11
+ ABA collisions unrealizable), so it covers transcript-only changes as well
12
+ as attachment changes.
13
+ - Each evaluation run receives a monotonically increasing **generation**
14
+ allocated in ``_operation_state``. Replacement CASes generation and a
15
+ fingerprint recomputed under the replacement transaction's lock, so an
16
+ older or stale snapshot cannot overwrite newer session state.
17
+ """
18
+
19
+ from __future__ import annotations
20
+
21
+ import hashlib
22
+ import json
23
+ from dataclasses import dataclass, field
24
+ from typing import Literal
25
+
26
+ RETRIEVED_LEARNING_STATE_PREFIX = "retrieved_learning_eval"
27
+
28
+ # Statuses persisted in _operation_state. "complete" and "not_applicable" are
29
+ # terminal (the fast path may short-circuit on them); "degraded" and "failed"
30
+ # are retried by the next scheduled or forced run.
31
+ type RetrievedLearningPersistedStatus = Literal[
32
+ "pending", "in_progress", "complete", "degraded", "failed", "not_applicable"
33
+ ]
34
+ TERMINAL_RETRIEVED_STATUSES: frozenset[str] = frozenset({"complete", "not_applicable"})
35
+
36
+ # Outcomes of one runner invocation. "stale"/"superseded"/"skipped" are
37
+ # invocation outcomes only and are never stored as persisted status.
38
+ type RetrievedLearningInvocationStatus = Literal[
39
+ "pending",
40
+ "complete",
41
+ "degraded",
42
+ "failed",
43
+ "not_applicable",
44
+ "stale",
45
+ "superseded",
46
+ "skipped",
47
+ ]
48
+
49
+ # Canonical kinds accepted and persisted by retrieved-learning evaluation.
50
+ # ``RetrievedLearning.kind`` validates these at the API boundary; storage
51
+ # parsers still filter defensively against this set.
52
+ CANONICAL_RETRIEVED_KINDS: frozenset[str] = frozenset(
53
+ {"profile", "user_playbook", "agent_playbook"}
54
+ )
55
+ DEFAULT_TRANSCRIPT_CHAR_LIMIT = 64_000
56
+
57
+
58
+ class SessionFingerprintBuilder:
59
+ """Incrementally build the canonical session fingerprint."""
60
+
61
+ def __init__(self) -> None:
62
+ self._digest = hashlib.sha256()
63
+ self._digest.update(b"[")
64
+ self._has_entries = False
65
+
66
+ def add(
67
+ self,
68
+ interaction_id: int,
69
+ refs: list[tuple[str, str]],
70
+ role: str = "",
71
+ content: str = "",
72
+ ) -> None:
73
+ """Fold one interaction into the running digest.
74
+
75
+ ``role``/``content`` are the transcript the judges actually saw, so a
76
+ content-only edit (e.g. ``INSERT OR REPLACE`` on an existing
77
+ interaction that keeps its id and attachments) still invalidates the
78
+ fingerprint. Callers on the precompute and commit-recompute sides MUST
79
+ pass content truncated identically (``DEFAULT_TRANSCRIPT_CHAR_LIMIT``);
80
+ otherwise a session would compare unequal and never commit.
81
+ """
82
+ if self._has_entries:
83
+ self._digest.update(b",")
84
+ self._digest.update(
85
+ json.dumps(
86
+ [interaction_id, sorted(refs), role, content],
87
+ separators=(",", ":"),
88
+ ).encode("utf-8")
89
+ )
90
+ self._has_entries = True
91
+
92
+ def hexdigest(self) -> str:
93
+ digest = self._digest.copy()
94
+ digest.update(b"]")
95
+ return digest.hexdigest()
96
+
97
+
98
+ def build_retrieved_learning_state_key(user_id: str, session_id: str) -> str:
99
+ """Build the ``_operation_state`` key for one session's evaluation state.
100
+
101
+ Length-prefixed (following the grade_on_demand cache-key precedent) so a
102
+ crafted user/session pair cannot collide with another pair. Org scoping is
103
+ unnecessary: ``_operation_state`` is already org-scoped on every backend.
104
+
105
+ Args:
106
+ user_id (str): Session owner.
107
+ session_id (str): Evaluated session.
108
+
109
+ Returns:
110
+ str: The state key.
111
+ """
112
+ return (
113
+ f"{RETRIEVED_LEARNING_STATE_PREFIX}"
114
+ f"::{len(user_id)}:{user_id}::{len(session_id)}:{session_id}"
115
+ )
116
+
117
+
118
+ @dataclass
119
+ class SnapshotInteraction:
120
+ """One interaction row in the bounded session projection.
121
+
122
+ ``refs`` holds ``(kind, learning_id)`` attachment tuples; interaction
123
+ content is carried only for transcript construction and is never
124
+ persisted in operation state.
125
+ """
126
+
127
+ interaction_id: int
128
+ role: str
129
+ content: str
130
+ created_at: int
131
+ refs: list[tuple[str, str]] = field(default_factory=list)
132
+
133
+
134
+ @dataclass
135
+ class BoundedRetrievedLearningSnapshot:
136
+ """Bounded projection of one session for retrieved-learning evaluation.
137
+
138
+ Loaded without constructing full ``Interaction`` objects (no embeddings,
139
+ no image encodings). ``attachment_limit_exceeded`` is set when the raw
140
+ attachment-occurrence cap was hit; the evaluator must then make zero LLM
141
+ calls.
142
+ """
143
+
144
+ interactions: list[SnapshotInteraction] = field(default_factory=list)
145
+ earliest_request_created_at: int | None = None
146
+ agent_version: str = ""
147
+ raw_attachment_count: int = 0
148
+ attachment_limit_exceeded: bool = False
149
+ precomputed_fingerprint: str | None = None
150
+
151
+
152
+ def append_bounded_snapshot_interaction(
153
+ snapshot: BoundedRetrievedLearningSnapshot,
154
+ *,
155
+ interaction_id: int,
156
+ role: str,
157
+ content: str,
158
+ created_at: int,
159
+ refs: list[tuple[str, str]],
160
+ transcript_chars_remaining: int,
161
+ ) -> int:
162
+ """Retain refs and only the transcript prefix that fits the char budget."""
163
+ retained_role = ""
164
+ retained_content = ""
165
+ if transcript_chars_remaining > 0 and content:
166
+ prefix_size = len(role) + 3
167
+ content_budget = max(0, transcript_chars_remaining - prefix_size)
168
+ if content_budget:
169
+ retained_role = role
170
+ retained_content = content[:content_budget]
171
+ transcript_chars_remaining -= prefix_size + len(retained_content)
172
+ if refs or retained_content:
173
+ snapshot.interactions.append(
174
+ SnapshotInteraction(
175
+ interaction_id=interaction_id,
176
+ role=retained_role,
177
+ content=retained_content,
178
+ created_at=created_at,
179
+ refs=refs,
180
+ )
181
+ )
182
+ return max(0, transcript_chars_remaining)
183
+
184
+
185
+ def session_fingerprint(snapshot: BoundedRetrievedLearningSnapshot) -> str:
186
+ """Compute the canonical session fingerprint for a bounded snapshot.
187
+
188
+ Covers every interaction ID, each interaction's ``(kind, learning_id)``
189
+ refs, and its transcript role/content, so any publish, delete, or in-place
190
+ content edit in the session invalidates it. Only this digest is ever
191
+ persisted.
192
+
193
+ Args:
194
+ snapshot (BoundedRetrievedLearningSnapshot): The session projection.
195
+
196
+ Returns:
197
+ str: Hex SHA-256 digest.
198
+ """
199
+ if snapshot.precomputed_fingerprint is not None:
200
+ return snapshot.precomputed_fingerprint
201
+ builder = SessionFingerprintBuilder()
202
+ for interaction in sorted(
203
+ snapshot.interactions,
204
+ key=lambda item: (item.created_at, item.interaction_id),
205
+ ):
206
+ builder.add(
207
+ interaction.interaction_id,
208
+ interaction.refs,
209
+ interaction.role,
210
+ interaction.content,
211
+ )
212
+ return builder.hexdigest()
213
+
214
+
215
+ @dataclass
216
+ class RetrievedLearningCommitResult:
217
+ """Outcome of one atomic result-set replacement.
218
+
219
+ ``status``/``committed_count`` are authoritative only when ``disposition``
220
+ is ``"applied"`` (commit-time eligibility can turn a proposed status into
221
+ ``"not_applicable"``).
222
+ """
223
+
224
+ disposition: Literal["applied", "stale", "superseded"]
225
+ status: RetrievedLearningPersistedStatus | None = None
226
+ committed_count: int = 0
@@ -20,8 +20,10 @@ import os
20
20
  import threading
21
21
  import time
22
22
  from collections.abc import Callable
23
+ from functools import partial
23
24
 
24
25
  from reflexio.server.api_endpoints.request_context import RequestContext
26
+ from reflexio.server.callback_executor import submit_callback
25
27
  from reflexio.server.llm.litellm_client import LiteLLMClient
26
28
  from reflexio.server.services.tagging.service import TaggingService
27
29
 
@@ -101,13 +103,10 @@ class TaggingScheduler:
101
103
  continue
102
104
 
103
105
  del self._scheduled[key]
104
- t = threading.Thread(
105
- target=self._run_callback,
106
- args=(key, callback),
107
- daemon=True,
108
- name=f"tagging-{key[1][:20]}",
106
+ submit_callback(
107
+ f"tagging-{key[1][:20]}",
108
+ partial(self._run_callback, key, callback),
109
109
  )
110
- t.start()
111
110
  except Exception:
112
111
  logger.exception("Error in tagging scheduler loop")
113
112
  time.sleep(1)