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
@@ -8,6 +8,7 @@ from dataclasses import dataclass
8
8
 
9
9
  DEFAULT_ROW_RETENTION_LIMIT = 250_000
10
10
  ROW_RETENTION_DELETE_FRACTION = 0.20
11
+ TOMBSTONE_STATUSES = ("archived", "merged", "superseded", "expired")
11
12
 
12
13
 
13
14
  @dataclass(frozen=True, slots=True)
@@ -18,17 +19,32 @@ class RetentionTarget:
18
19
  table_name: str
19
20
  order_column: str
20
21
  id_columns: tuple[str, ...]
22
+ priority_statuses: tuple[str, ...] = ()
21
23
 
22
24
 
23
25
  RETENTION_TARGETS: tuple[RetentionTarget, ...] = (
24
- RetentionTarget("profiles", "profiles", "created_at", ("profile_id",)),
26
+ RetentionTarget(
27
+ "profiles",
28
+ "profiles",
29
+ "created_at",
30
+ ("profile_id",),
31
+ priority_statuses=TOMBSTONE_STATUSES,
32
+ ),
25
33
  RetentionTarget("interactions", "interactions", "created_at", ("interaction_id",)),
26
34
  RetentionTarget("requests", "requests", "created_at", ("request_id",)),
27
35
  RetentionTarget(
28
- "user_playbooks", "user_playbooks", "created_at", ("user_playbook_id",)
36
+ "user_playbooks",
37
+ "user_playbooks",
38
+ "created_at",
39
+ ("user_playbook_id",),
40
+ priority_statuses=TOMBSTONE_STATUSES,
29
41
  ),
30
42
  RetentionTarget(
31
- "agent_playbooks", "agent_playbooks", "created_at", ("agent_playbook_id",)
43
+ "agent_playbooks",
44
+ "agent_playbooks",
45
+ "created_at",
46
+ ("agent_playbook_id",),
47
+ priority_statuses=TOMBSTONE_STATUSES,
32
48
  ),
33
49
  RetentionTarget(
34
50
  "agent_success_evaluation_result",
@@ -36,6 +52,25 @@ RETENTION_TARGETS: tuple[RetentionTarget, ...] = (
36
52
  "created_at",
37
53
  ("result_id",),
38
54
  ),
55
+ # Grouped session target: keyed on (user_id, session_id) — not result_id —
56
+ # so retention always removes whole session snapshots, never a partial
57
+ # per-learning subset. Rows within a session share created_at (earliest
58
+ # request timestamp), so ordering keeps groups adjacent. The session's
59
+ # retrieved-eval _operation_state row is intentionally left in place: it
60
+ # is content-free (digest + counters) and self-heals on the next
61
+ # publish/forced evaluation.
62
+ RetentionTarget(
63
+ "retrieved_learning_evaluation",
64
+ "retrieved_learning_evaluation",
65
+ "created_at",
66
+ ("user_id", "session_id"),
67
+ ),
68
+ RetentionTarget(
69
+ "offline_tuner_reward_label",
70
+ "offline_tuner_reward_label",
71
+ "label_created_at",
72
+ ("reward_label_id",),
73
+ ),
39
74
  RetentionTarget("share_links", "share_links", "created_at", ("id",)),
40
75
  RetentionTarget(
41
76
  "agent_playbook_source_user_playbooks",
@@ -117,6 +152,9 @@ RETENTION_CASCADES: dict[str, tuple[CascadeRef, ...]] = {
117
152
  "playbook_retrieval_logs": (
118
153
  CascadeRef("playbook_retrieval_log_items", "retrieval_log_id"),
119
154
  ),
155
+ "offline_tuner_reward_label": (
156
+ CascadeRef("offline_tuner_reward_label_target", "reward_label_id"),
157
+ ),
120
158
  }
121
159
 
122
160
 
@@ -98,12 +98,40 @@ class RetentionMixin(ABC):
98
98
  target = get_retention_target(target_name)
99
99
  if not self._retention_table_exists(target.table_name):
100
100
  return 0
101
- keys = self._retention_select_oldest_keys(target, count)
101
+ keys = self._retention_select_keys(target, count)
102
102
  if not keys:
103
103
  return 0
104
104
  self._retention_perform_delete(target, keys)
105
105
  return len(keys)
106
106
 
107
+ def _retention_select_keys(
108
+ self, target: RetentionTarget, count: int
109
+ ) -> list[tuple[Any, ...]]:
110
+ """Select tombstones first, then oldest rows when a target opts in.
111
+
112
+ The tombstone pass can never under-delete: the fallback select returns
113
+ ``min(count, table_rows)`` keys and ``seen`` only ever holds tombstones
114
+ already counted, so the union still reaches ``count`` whenever the
115
+ table holds that many rows.
116
+ """
117
+ if not target.priority_statuses:
118
+ return self._retention_select_oldest_keys(target, count)
119
+
120
+ keys = self._retention_select_oldest_keys(
121
+ target, count, statuses=target.priority_statuses
122
+ )
123
+ if len(keys) >= count:
124
+ return keys
125
+
126
+ seen = set(keys)
127
+ for key in self._retention_select_oldest_keys(target, count):
128
+ if key not in seen:
129
+ keys.append(key)
130
+ seen.add(key)
131
+ if len(keys) == count:
132
+ break
133
+ return keys
134
+
107
135
  def _retention_perform_delete(
108
136
  self, target: RetentionTarget, keys: list[tuple[Any, ...]]
109
137
  ) -> None:
@@ -130,13 +158,23 @@ class RetentionMixin(ABC):
130
158
 
131
159
  @abstractmethod
132
160
  def _retention_select_oldest_keys(
133
- self, target: RetentionTarget, count: int
161
+ self,
162
+ target: RetentionTarget,
163
+ count: int,
164
+ statuses: tuple[str, ...] | None = None,
134
165
  ) -> list[tuple[Any, ...]]:
135
166
  """Return up to ``count`` oldest key tuples for ``target``.
136
167
 
137
168
  Each key tuple contains values aligned with ``target.id_columns``.
138
169
  Ordering is by ``target.order_column`` ascending with the id
139
170
  columns as a stable tiebreaker.
171
+
172
+ Args:
173
+ target (RetentionTarget): Target whose keys are being selected.
174
+ count (int): Maximum number of key tuples to return.
175
+ statuses (tuple[str, ...] | None): When not None, restrict the
176
+ select to rows whose ``status`` is one of these values. An
177
+ empty tuple matches nothing (never every row).
140
178
  """
141
179
  raise NotImplementedError
142
180
 
@@ -9,6 +9,7 @@ from ._base import (
9
9
  )
10
10
  from ._extras import ExtrasMixin
11
11
  from ._governance import SQLiteGovernanceMixin
12
+ from ._learning_jobs import SQLiteLearningJobStoreMixin
12
13
  from ._lineage import SQLiteLineageMixin
13
14
  from ._operations import OperationMixin
14
15
  from ._requests import RequestMixin
@@ -48,6 +49,7 @@ from .profiles import InteractionStoreMixin, ProfileSearchMixin, ProfileStoreMix
48
49
 
49
50
 
50
51
  class SQLiteStorage(
52
+ SQLiteLearningJobStoreMixin,
51
53
  SQLiteAgentRunStoreMixin,
52
54
  SQLitePendingToolCallStoreMixin,
53
55
  SQLiteRunToolDependencyStoreMixin,
@@ -7,6 +7,7 @@ are available.
7
7
 
8
8
  """
9
9
 
10
+ import contextlib
10
11
  import functools
11
12
  import json
12
13
  import logging
@@ -14,7 +15,7 @@ import math
14
15
  import re
15
16
  import sqlite3
16
17
  import threading
17
- from collections.abc import Callable, Sequence
18
+ from collections.abc import Callable, Generator, Sequence
18
19
  from datetime import UTC, datetime
19
20
  from pathlib import Path
20
21
  from typing import Any, ClassVar, Literal
@@ -29,6 +30,7 @@ from reflexio.models.api_schema.service_schemas import (
29
30
  ProfileTimeToLive,
30
31
  RegularVsShadow,
31
32
  Request,
33
+ RetrievedLearning,
32
34
  Status,
33
35
  ToolUsed,
34
36
  UserActionType,
@@ -455,6 +457,12 @@ def _row_to_interaction(row: sqlite3.Row) -> Interaction:
455
457
  if citations_raw and isinstance(citations_raw, list)
456
458
  else []
457
459
  )
460
+ retrieved_learnings_raw = _json_loads(d.get("retrieved_learnings"))
461
+ retrieved_learnings = (
462
+ [RetrievedLearning(**c) for c in retrieved_learnings_raw if isinstance(c, dict)]
463
+ if retrieved_learnings_raw and isinstance(retrieved_learnings_raw, list)
464
+ else []
465
+ )
458
466
  return Interaction(
459
467
  interaction_id=d["interaction_id"],
460
468
  user_id=d["user_id"],
@@ -470,6 +478,7 @@ def _row_to_interaction(row: sqlite3.Row) -> Interaction:
470
478
  expert_content=d.get("expert_content") or "",
471
479
  tools_used=tools_used,
472
480
  citations=citations,
481
+ retrieved_learnings=retrieved_learnings,
473
482
  )
474
483
 
475
484
 
@@ -590,6 +599,7 @@ class SQLiteStorageBase(RetentionMixin, BaseStorage):
590
599
  # FTS/vec index helpers provided by SQLiteFtsVecMixin via the composed
591
600
  # SQLiteStorage MRO; declared here for _migrate_vec_tables's benefit.
592
601
  _vec_upsert: Any
602
+ _flush_index_op: Any
593
603
 
594
604
  @staticmethod
595
605
  def handle_exceptions(func: Callable[..., Any]) -> Callable[..., Any]:
@@ -633,6 +643,10 @@ class SQLiteStorageBase(RetentionMixin, BaseStorage):
633
643
 
634
644
  self.db_path = db_path
635
645
  self._lock = threading.RLock()
646
+ self._scope_depth = 0
647
+ self._deferred_index_ops: list[
648
+ tuple[str, Any]
649
+ ] = [] # (kind, args) flushed post-commit
636
650
 
637
651
  logger.info("SQLite Storage for org %s using db_path: %s", org_id, db_path)
638
652
 
@@ -656,6 +670,16 @@ class SQLiteStorageBase(RetentionMixin, BaseStorage):
656
670
  api_key_config=self.api_key_config,
657
671
  )
658
672
  self.embedding_dimensions = EMBEDDING_DIMENSIONS
673
+ # Text-generation model for storage-time document expansion. The
674
+ # shared self.llm_client is pinned to the EMBEDDING model, which is
675
+ # not a chat model — expansion calls must override the model or they
676
+ # fail (e.g. local/minilm-l6-v2 cannot serve completions).
677
+ self._expansion_model_name = resolve_model_name(
678
+ ModelRole.GENERATION,
679
+ site_var_value=site_var.get("default_generation_model_name"),
680
+ config_override=llm_config.generation_model_name if llm_config else None,
681
+ api_key_config=self.api_key_config,
682
+ )
659
683
 
660
684
  litellm_config = LiteLLMConfig(
661
685
  model=self.embedding_model_name,
@@ -670,6 +694,45 @@ class SQLiteStorageBase(RetentionMixin, BaseStorage):
670
694
  # Create tables
671
695
  self.migrate()
672
696
 
697
+ # ------------------------------------------------------------------
698
+ # Transaction scope
699
+ # ------------------------------------------------------------------
700
+
701
+ def _own_transaction(self) -> bool:
702
+ """True when the caller is NOT inside a commit_scope (owns its BEGIN/commit)."""
703
+ return self._scope_depth == 0
704
+
705
+ @contextlib.contextmanager
706
+ def commit_scope(self) -> Generator[None, None, None]:
707
+ """Group writes into one atomic commit; defer FTS/vec index ops.
708
+
709
+ Nested scopes join the outermost: only the outer scope commits. On
710
+ exception the whole transaction rolls back and deferred index ops
711
+ are discarded.
712
+ """
713
+ with self._lock:
714
+ if self._scope_depth > 0:
715
+ self._scope_depth += 1
716
+ try:
717
+ yield
718
+ finally:
719
+ self._scope_depth -= 1
720
+ return
721
+ self.conn.execute("BEGIN IMMEDIATE")
722
+ self._scope_depth = 1
723
+ try:
724
+ yield
725
+ self.conn.commit()
726
+ ops, self._deferred_index_ops = self._deferred_index_ops, []
727
+ for kind, args in ops:
728
+ self._flush_index_op(kind, args) # self-commits — fine post-commit
729
+ except Exception:
730
+ self.conn.rollback()
731
+ self._deferred_index_ops = []
732
+ raise
733
+ finally:
734
+ self._scope_depth = 0
735
+
673
736
  # ------------------------------------------------------------------
674
737
  # DDL / migration
675
738
  # ------------------------------------------------------------------
@@ -714,6 +777,7 @@ class SQLiteStorageBase(RetentionMixin, BaseStorage):
714
777
  self._migrate_retire_profile_change_logs()
715
778
  self._migrate_retire_playbook_aggregation_change_logs()
716
779
  init_stall_state_table(self.conn)
780
+ self._migrate_learning_jobs()
717
781
  return True
718
782
 
719
783
  def _try_load_sqlite_vec(self) -> bool:
@@ -809,6 +873,14 @@ class SQLiteStorageBase(RetentionMixin, BaseStorage):
809
873
  )
810
874
  self.conn.commit()
811
875
 
876
+ if "retrieved_learnings" not in columns:
877
+ logger.info("Adding retrieved_learnings column to interactions table.")
878
+ with self._lock:
879
+ self.conn.execute(
880
+ "ALTER TABLE interactions ADD COLUMN retrieved_learnings TEXT"
881
+ )
882
+ self.conn.commit()
883
+
812
884
  def _migrate_feedback_schema(self) -> None:
813
885
  """Drop old-schema feedback/playbook tables so _DDL can recreate them.
814
886
 
@@ -1261,6 +1333,70 @@ class SQLiteStorageBase(RetentionMixin, BaseStorage):
1261
1333
  )
1262
1334
  self.conn.commit()
1263
1335
 
1336
+ def _migrate_learning_jobs(self) -> None:
1337
+ """Create the learning_jobs table + partial indexes if missing (idempotent).
1338
+
1339
+ Runs ``CREATE TABLE IF NOT EXISTS`` and ``CREATE INDEX IF NOT EXISTS`` only —
1340
+ both are no-ops when the table / indexes already exist. New columns added in
1341
+ subsequent tasks are backfilled via ``PRAGMA table_info`` + ``ALTER TABLE … ADD
1342
+ COLUMN`` (mirroring ``_migrate_lineage_event_table``), because
1343
+ ``CREATE TABLE IF NOT EXISTS`` silently skips the DDL on existing databases.
1344
+
1345
+ Called at the end of migrate() so the table is always present on startup.
1346
+ """
1347
+ with self._lock:
1348
+ self.conn.executescript("""
1349
+ CREATE TABLE IF NOT EXISTS learning_jobs (
1350
+ job_id TEXT PRIMARY KEY,
1351
+ org_id TEXT NOT NULL,
1352
+ user_id TEXT NOT NULL,
1353
+ job_type TEXT NOT NULL DEFAULT 'learning',
1354
+ latest_request_id TEXT,
1355
+ status TEXT NOT NULL DEFAULT 'pending',
1356
+ attempts INTEGER NOT NULL DEFAULT 0,
1357
+ max_attempts INTEGER NOT NULL DEFAULT 3,
1358
+ claimed_by TEXT,
1359
+ claim_token TEXT,
1360
+ claim_expires_at TEXT,
1361
+ covers_through TEXT,
1362
+ force_extraction INTEGER NOT NULL DEFAULT 0,
1363
+ skip_aggregation INTEGER NOT NULL DEFAULT 0,
1364
+ created_at TEXT NOT NULL DEFAULT (strftime('%Y-%m-%dT%H:%M:%fZ','now')),
1365
+ updated_at TEXT NOT NULL DEFAULT (strftime('%Y-%m-%dT%H:%M:%fZ','now'))
1366
+ );
1367
+ CREATE UNIQUE INDEX IF NOT EXISTS learning_jobs_coalesce
1368
+ ON learning_jobs (org_id, user_id, job_type) WHERE status = 'pending';
1369
+ -- Recreate the poll index with 'failed' in the predicate. CREATE
1370
+ -- INDEX IF NOT EXISTS is a no-op on an existing index with the old
1371
+ -- ('pending','claimed') predicate, so DROP first to migrate it.
1372
+ DROP INDEX IF EXISTS learning_jobs_poll;
1373
+ CREATE INDEX IF NOT EXISTS learning_jobs_poll
1374
+ ON learning_jobs (created_at) WHERE status IN ('pending','failed','claimed');
1375
+ """)
1376
+ # Backfill columns added after the initial release (existing DBs skip
1377
+ # CREATE TABLE IF NOT EXISTS so these must be applied separately).
1378
+ existing_cols = {
1379
+ row["name"]
1380
+ for row in self.conn.execute(
1381
+ "PRAGMA table_info(learning_jobs)"
1382
+ ).fetchall()
1383
+ }
1384
+ if "force_extraction" not in existing_cols:
1385
+ self.conn.execute(
1386
+ "ALTER TABLE learning_jobs ADD COLUMN force_extraction INTEGER NOT NULL DEFAULT 0"
1387
+ )
1388
+ if "skip_aggregation" not in existing_cols:
1389
+ self.conn.execute(
1390
+ "ALTER TABLE learning_jobs ADD COLUMN skip_aggregation INTEGER NOT NULL DEFAULT 0"
1391
+ )
1392
+ self.conn.commit()
1393
+
1394
+ def learning_jobs_columns(self) -> list[str]:
1395
+ """Return the column names of the learning_jobs table."""
1396
+ with self._lock:
1397
+ rows = self.conn.execute("PRAGMA table_info(learning_jobs)").fetchall()
1398
+ return [row["name"] for row in rows]
1399
+
1264
1400
  def _migrate_agent_playbook_source_windows(self) -> None:
1265
1401
  """Add source window snapshots to existing agent source mappings."""
1266
1402
  cols = {
@@ -1451,7 +1587,8 @@ class SQLiteStorageBase(RetentionMixin, BaseStorage):
1451
1587
  ) -> sqlite3.Cursor:
1452
1588
  with self._lock:
1453
1589
  cur = self.conn.execute(sql, params)
1454
- self.conn.commit()
1590
+ if self._own_transaction():
1591
+ self.conn.commit()
1455
1592
  return cur
1456
1593
 
1457
1594
  def _fetchone(
@@ -1519,6 +1656,7 @@ class SQLiteStorageBase(RetentionMixin, BaseStorage):
1519
1656
  expander = DocumentExpander(
1520
1657
  llm_client=self.llm_client,
1521
1658
  prompt_manager=PromptManager(),
1659
+ model_name=self._expansion_model_name,
1522
1660
  )
1523
1661
  result = expander.expand(content)
1524
1662
  return result.expanded_text or None
@@ -1737,6 +1875,7 @@ CREATE TABLE IF NOT EXISTS interactions (
1737
1875
  expert_content TEXT NOT NULL DEFAULT '',
1738
1876
  tools_used TEXT,
1739
1877
  citations TEXT,
1878
+ retrieved_learnings TEXT,
1740
1879
  embedding TEXT
1741
1880
  );
1742
1881
  CREATE INDEX IF NOT EXISTS idx_interactions_user_id ON interactions(user_id);
@@ -1838,6 +1977,28 @@ CREATE INDEX IF NOT EXISTS idx_eval_agent_version_created_at_desc
1838
1977
  CREATE INDEX IF NOT EXISTS idx_eval_identity_created_at_desc
1839
1978
  ON agent_success_evaluation_result(user_id, session_id, evaluation_name, agent_version, created_at DESC);
1840
1979
 
1980
+ CREATE TABLE IF NOT EXISTS retrieved_learning_evaluation (
1981
+ result_id INTEGER PRIMARY KEY AUTOINCREMENT,
1982
+ user_id TEXT NOT NULL,
1983
+ session_id TEXT NOT NULL,
1984
+ agent_version TEXT NOT NULL DEFAULT '',
1985
+ kind TEXT NOT NULL,
1986
+ learning_id TEXT NOT NULL,
1987
+ is_relevant INTEGER,
1988
+ relevance_reason TEXT NOT NULL DEFAULT '',
1989
+ impact TEXT,
1990
+ impact_reason TEXT NOT NULL DEFAULT '',
1991
+ created_at INTEGER NOT NULL,
1992
+ governance_subject_ref TEXT,
1993
+ UNIQUE (user_id, session_id, kind, learning_id)
1994
+ );
1995
+ CREATE INDEX IF NOT EXISTS idx_rle_created_at_result_id
1996
+ ON retrieved_learning_evaluation(created_at DESC, result_id DESC);
1997
+ CREATE INDEX IF NOT EXISTS idx_rle_session_id
1998
+ ON retrieved_learning_evaluation(session_id);
1999
+ CREATE INDEX IF NOT EXISTS idx_rle_subject_ref
2000
+ ON retrieved_learning_evaluation(governance_subject_ref);
2001
+
1841
2002
  CREATE TABLE IF NOT EXISTS agent_playbook_source_user_playbooks (
1842
2003
  agent_playbook_id INTEGER NOT NULL,
1843
2004
  user_playbook_id INTEGER NOT NULL,
@@ -2094,4 +2255,31 @@ CREATE TABLE IF NOT EXISTS lineage_event (
2094
2255
  );
2095
2256
  CREATE INDEX IF NOT EXISTS idx_lineage_entity ON lineage_event (entity_type, entity_id);
2096
2257
 
2258
+ -- ============================================================================
2259
+ -- Durable learning pipeline — cross-org job queue
2260
+ -- ============================================================================
2261
+
2262
+ CREATE TABLE IF NOT EXISTS learning_jobs (
2263
+ job_id TEXT PRIMARY KEY,
2264
+ org_id TEXT NOT NULL,
2265
+ user_id TEXT NOT NULL,
2266
+ job_type TEXT NOT NULL DEFAULT 'learning',
2267
+ latest_request_id TEXT,
2268
+ status TEXT NOT NULL DEFAULT 'pending',
2269
+ attempts INTEGER NOT NULL DEFAULT 0,
2270
+ max_attempts INTEGER NOT NULL DEFAULT 3,
2271
+ claimed_by TEXT,
2272
+ claim_token TEXT,
2273
+ claim_expires_at TEXT,
2274
+ covers_through TEXT,
2275
+ force_extraction INTEGER NOT NULL DEFAULT 0,
2276
+ skip_aggregation INTEGER NOT NULL DEFAULT 0,
2277
+ created_at TEXT NOT NULL DEFAULT (strftime('%Y-%m-%dT%H:%M:%fZ','now')),
2278
+ updated_at TEXT NOT NULL DEFAULT (strftime('%Y-%m-%dT%H:%M:%fZ','now'))
2279
+ );
2280
+ CREATE UNIQUE INDEX IF NOT EXISTS learning_jobs_coalesce
2281
+ ON learning_jobs (org_id, user_id, job_type) WHERE status = 'pending';
2282
+ CREATE INDEX IF NOT EXISTS learning_jobs_poll
2283
+ ON learning_jobs (created_at) WHERE status IN ('pending','failed','claimed');
2284
+
2097
2285
  """
@@ -25,7 +25,14 @@ from ._base import (
25
25
  _row_to_interaction,
26
26
  )
27
27
 
28
- type _CitationKind = Literal["playbook", "profile"]
28
+ type _CitationKind = Literal["playbook", "profile", "user_playbook", "agent_playbook"]
29
+
30
+ _SUPPORTED_CITATION_KINDS: tuple[_CitationKind, ...] = (
31
+ "playbook",
32
+ "profile",
33
+ "user_playbook",
34
+ "agent_playbook",
35
+ )
29
36
 
30
37
 
31
38
  class ExtrasMixin:
@@ -291,7 +298,7 @@ class ExtrasMixin:
291
298
  continue
292
299
  kind = c.get("kind")
293
300
  real_id = c.get("real_id")
294
- if kind not in ("playbook", "profile") or not real_id:
301
+ if kind not in _SUPPORTED_CITATION_KINDS or not real_id:
295
302
  continue
296
303
  key: tuple[_CitationKind, str] = (
297
304
  cast(_CitationKind, kind),
@@ -453,12 +460,12 @@ class ExtrasMixin:
453
460
  continue
454
461
  kind = citation.get("kind")
455
462
  real_id = citation.get("real_id")
456
- if kind and real_id:
463
+ if kind in _SUPPORTED_CITATION_KINDS and real_id:
457
464
  out.append(
458
465
  SessionCitation(
459
466
  user_id=row["user_id"],
460
467
  session_id=row["session_id"],
461
- kind=str(kind),
468
+ kind=cast(_CitationKind, kind),
462
469
  real_id=str(real_id),
463
470
  title=str(citation.get("title") or ""),
464
471
  )
@@ -392,11 +392,27 @@ class SQLiteGovernanceMixin:
392
392
  WHERE user_id = ?""",
393
393
  (user_id,),
394
394
  ).fetchone()
395
+ rle_result_row = self.conn.execute(
396
+ """SELECT COUNT(*) AS cnt
397
+ FROM retrieved_learning_evaluation
398
+ WHERE user_id = ?""",
399
+ (user_id,),
400
+ ).fetchone()
401
+ session_count_row = self.conn.execute(
402
+ "SELECT COUNT(DISTINCT session_id) AS cnt FROM requests WHERE user_id = ?",
403
+ (user_id,),
404
+ ).fetchone()
395
405
  profile_rows = self.conn.execute(
396
406
  "SELECT profile_id FROM profiles WHERE user_id = ?",
397
407
  (user_id,),
398
408
  ).fetchall()
399
- if request_row is None or interaction_row is None or eval_result_row is None:
409
+ if (
410
+ request_row is None
411
+ or interaction_row is None
412
+ or eval_result_row is None
413
+ or rle_result_row is None
414
+ or session_count_row is None
415
+ ):
400
416
  raise ValueError("Missing governance count rows")
401
417
  profile_ids = [str(row["profile_id"]) for row in profile_rows]
402
418
  purge_profile_ids, delete_profile_ids = self._deps()._partition_purge_vs_delete(
@@ -420,6 +436,17 @@ class SQLiteGovernanceMixin:
420
436
  "profile_purge": len(purge_profile_ids),
421
437
  "user_playbook": len(delete_playbook_ids),
422
438
  "agent_success_evaluation_result": int(eval_result_row["cnt"]),
439
+ # Offline tuner tables are enterprise-only (tenant stream); the
440
+ # OSS SQLite backend has no such tables, so the planned and
441
+ # deleted counts are structurally zero. The delete-target matrix
442
+ # validator still requires the target rows to exist.
443
+ "offline_tuner_reward_label": 0,
444
+ "offline_tuner_reward_label_target_by_target_owner": 0,
445
+ "retrieved_learning_evaluation_result": int(rle_result_row["cnt"]),
446
+ # Planned as an upper bound: up to 3 evaluation state namespaces
447
+ # per session (retrieved-eval state, agent-success marker,
448
+ # grade-cache rows). The delete phase reports the exact count.
449
+ "evaluation_operation_state": 3 * int(session_count_row["cnt"]),
423
450
  "user_playbook_purge": len(purge_playbook_ids),
424
451
  }
425
452