claude-smart 0.2.48 → 0.2.49

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 (50) hide show
  1. package/.claude-plugin/marketplace.json +1 -1
  2. package/README.md +6 -2
  3. package/bin/claude-smart.js +289 -56
  4. package/package.json +1 -1
  5. package/plugin/.claude-plugin/plugin.json +1 -1
  6. package/plugin/.codex-plugin/plugin.json +1 -1
  7. package/plugin/opencode/dist/server.mjs +60 -2
  8. package/plugin/opencode/server.mts +64 -2
  9. package/plugin/pyproject.toml +2 -2
  10. package/plugin/scripts/_lib.sh +58 -6
  11. package/plugin/scripts/backend-service.sh +15 -10
  12. package/plugin/scripts/codex-hook.js +16 -4
  13. package/plugin/scripts/dashboard-service.sh +1 -1
  14. package/plugin/scripts/ensure-plugin-root.sh +106 -8
  15. package/plugin/scripts/opencode-claude-compat.js +8 -1
  16. package/plugin/scripts/smart-install.sh +101 -20
  17. package/plugin/src/README.md +1 -1
  18. package/plugin/src/claude_smart/cli.py +79 -29
  19. package/plugin/src/claude_smart/env_config.py +53 -11
  20. package/plugin/uv.lock +5 -5
  21. package/plugin/vendor/reflexio/reflexio/cli/README.md +3 -0
  22. package/plugin/vendor/reflexio/reflexio/client/client.py +40 -6
  23. package/plugin/vendor/reflexio/reflexio/models/api_schema/domain/entities.py +18 -0
  24. package/plugin/vendor/reflexio/reflexio/server/api.py +15 -4
  25. package/plugin/vendor/reflexio/reflexio/server/llm/_litellm_text_generation.py +1 -1
  26. package/plugin/vendor/reflexio/reflexio/server/llm/litellm_client.py +1 -0
  27. package/plugin/vendor/reflexio/reflexio/server/llm/providers/local_embedding_provider.py +183 -1
  28. package/plugin/vendor/reflexio/reflexio/server/routes/interactions.py +63 -2
  29. package/plugin/vendor/reflexio/reflexio/server/services/durable_learning/__init__.py +13 -0
  30. package/plugin/vendor/reflexio/reflexio/server/services/durable_learning/scheduler.py +142 -0
  31. package/plugin/vendor/reflexio/reflexio/server/services/durable_learning/worker.py +193 -0
  32. package/plugin/vendor/reflexio/reflexio/server/services/extraction/resume_scheduler.py +1 -3
  33. package/plugin/vendor/reflexio/reflexio/server/services/generation_service.py +150 -37
  34. package/plugin/vendor/reflexio/reflexio/server/services/profile/service.py +44 -22
  35. package/plugin/vendor/reflexio/reflexio/server/services/storage/sqlite_storage/__init__.py +2 -0
  36. package/plugin/vendor/reflexio/reflexio/server/services/storage/sqlite_storage/_base.py +140 -2
  37. package/plugin/vendor/reflexio/reflexio/server/services/storage/sqlite_storage/_learning_jobs.py +379 -0
  38. package/plugin/vendor/reflexio/reflexio/server/services/storage/sqlite_storage/_lineage.py +11 -5
  39. package/plugin/vendor/reflexio/reflexio/server/services/storage/sqlite_storage/_requests.py +8 -3
  40. package/plugin/vendor/reflexio/reflexio/server/services/storage/sqlite_storage/base/_fts_vec.py +86 -2
  41. package/plugin/vendor/reflexio/reflexio/server/services/storage/sqlite_storage/playbook/_agent.py +16 -7
  42. package/plugin/vendor/reflexio/reflexio/server/services/storage/sqlite_storage/playbook/_source_linkage.py +8 -3
  43. package/plugin/vendor/reflexio/reflexio/server/services/storage/sqlite_storage/playbook/_user.py +12 -5
  44. package/plugin/vendor/reflexio/reflexio/server/services/storage/sqlite_storage/profiles/_interaction_store.py +52 -7
  45. package/plugin/vendor/reflexio/reflexio/server/services/storage/sqlite_storage/profiles/_profile_store.py +28 -4
  46. package/plugin/vendor/reflexio/reflexio/server/services/storage/storage_base/__init__.py +17 -0
  47. package/plugin/vendor/reflexio/reflexio/server/services/storage/storage_base/_commit_scope.py +9 -0
  48. package/plugin/vendor/reflexio/reflexio/server/services/storage/storage_base/_learning_jobs.py +219 -0
  49. package/plugin/vendor/reflexio/reflexio/server/services/storage/storage_base/profiles/_interaction_store.py +23 -1
  50. package/plugin/vendor/reflexio/reflexio/server/services/storage/storage_base/profiles/_profile_store.py +22 -0
@@ -6,6 +6,11 @@ index sidecar update is durable. They are sqlite-only — there is no
6
6
  supabase/postgres counterpart. Composed into ``SQLiteStorage`` ahead of
7
7
  ``SQLiteStorageBase``; the boot-time ``_migrate_vec_tables`` (residual in
8
8
  ``_base.py``) resolves ``self._vec_upsert`` through the composed MRO.
9
+
10
+ When a ``commit_scope`` is active (``_scope_depth > 0``), the public helpers
11
+ buffer the operation into ``_deferred_index_ops`` for post-commit flushing;
12
+ the ``_..._now`` variants always execute immediately and are called by
13
+ ``_flush_index_op`` after the outer commit.
9
14
  """
10
15
 
11
16
  from __future__ import annotations
@@ -23,10 +28,50 @@ class SQLiteFtsVecMixin:
23
28
  conn: sqlite3.Connection
24
29
  _fetchall: Any
25
30
  _has_sqlite_vec: bool
31
+ _scope_depth: int
32
+ _deferred_index_ops: list[tuple[str, Any]]
33
+
34
+ # ------------------------------------------------------------------
35
+ # Dispatcher — called by commit_scope after the outer commit
36
+ # ------------------------------------------------------------------
37
+
38
+ def _flush_index_op(self, kind: str, args: Any) -> None:
39
+ """Dispatch a buffered index op after the outer commit."""
40
+ if kind == "fts_upsert":
41
+ table, rowid, text_fields = args
42
+ self._fts_upsert_now(table, rowid, **text_fields)
43
+ elif kind == "fts_delete":
44
+ table, rowid = args
45
+ self._fts_delete_now(table, rowid)
46
+ elif kind == "fts_upsert_profile":
47
+ profile_id, content = args
48
+ self._fts_upsert_profile_now(profile_id, content)
49
+ elif kind == "fts_delete_profile":
50
+ (profile_id,) = args
51
+ self._fts_delete_profile_now(profile_id)
52
+ elif kind == "vec_upsert":
53
+ table, rowid, embedding = args
54
+ self._vec_upsert_now(table, rowid, embedding)
55
+ elif kind == "vec_delete":
56
+ table, rowid = args
57
+ self._vec_delete_now(table, rowid)
58
+ else:
59
+ raise ValueError(f"unknown index op kind: {kind!r}")
60
+
61
+ # ------------------------------------------------------------------
62
+ # FTS helpers — public (defer when in scope) + _now (immediate)
63
+ # ------------------------------------------------------------------
26
64
 
27
- # FTS helpers
28
65
  def _fts_upsert(self, table: str, rowid: int, **text_fields: str | None) -> None:
29
66
  """Insert or update an FTS row. Deletes old entry first to avoid duplicates."""
67
+ if self._scope_depth > 0:
68
+ self._deferred_index_ops.append(("fts_upsert", (table, rowid, text_fields)))
69
+ return
70
+ self._fts_upsert_now(table, rowid, **text_fields)
71
+
72
+ def _fts_upsert_now(
73
+ self, table: str, rowid: int, **text_fields: str | None
74
+ ) -> None:
30
75
  with self._lock:
31
76
  self.conn.execute(f"DELETE FROM {table} WHERE rowid = ?", (rowid,)) # noqa: S608
32
77
  cols = list(text_fields.keys())
@@ -40,12 +85,26 @@ class SQLiteFtsVecMixin:
40
85
  self.conn.commit()
41
86
 
42
87
  def _fts_delete(self, table: str, rowid: int) -> None:
88
+ if self._scope_depth > 0:
89
+ self._deferred_index_ops.append(("fts_delete", (table, rowid)))
90
+ return
91
+ self._fts_delete_now(table, rowid)
92
+
93
+ def _fts_delete_now(self, table: str, rowid: int) -> None:
43
94
  with self._lock:
44
95
  self.conn.execute(f"DELETE FROM {table} WHERE rowid = ?", (rowid,)) # noqa: S608
45
96
  self.conn.commit()
46
97
 
47
98
  def _fts_upsert_profile(self, profile_id: str, content: str) -> None:
48
99
  """FTS for profiles uses profile_id TEXT as key column."""
100
+ if self._scope_depth > 0:
101
+ self._deferred_index_ops.append(
102
+ ("fts_upsert_profile", (profile_id, content))
103
+ )
104
+ return
105
+ self._fts_upsert_profile_now(profile_id, content)
106
+
107
+ def _fts_upsert_profile_now(self, profile_id: str, content: str) -> None:
49
108
  with self._lock:
50
109
  self.conn.execute(
51
110
  "DELETE FROM profiles_fts WHERE profile_id = ?", (profile_id,)
@@ -57,15 +116,32 @@ class SQLiteFtsVecMixin:
57
116
  self.conn.commit()
58
117
 
59
118
  def _fts_delete_profile(self, profile_id: str) -> None:
119
+ if self._scope_depth > 0:
120
+ self._deferred_index_ops.append(("fts_delete_profile", (profile_id,)))
121
+ return
122
+ self._fts_delete_profile_now(profile_id)
123
+
124
+ def _fts_delete_profile_now(self, profile_id: str) -> None:
60
125
  with self._lock:
61
126
  self.conn.execute(
62
127
  "DELETE FROM profiles_fts WHERE profile_id = ?", (profile_id,)
63
128
  )
64
129
  self.conn.commit()
65
130
 
66
- # Vec helpers (sqlite-vec)
131
+ # ------------------------------------------------------------------
132
+ # Vec helpers (sqlite-vec) — public (defer when in scope) + _now (immediate)
133
+ # ------------------------------------------------------------------
134
+
67
135
  def _vec_upsert(self, table: str, rowid: int, embedding: list[float]) -> None:
68
136
  """Insert or update a vec table row. No-op when sqlite-vec is unavailable."""
137
+ if not self._has_sqlite_vec:
138
+ return
139
+ if self._scope_depth > 0:
140
+ self._deferred_index_ops.append(("vec_upsert", (table, rowid, embedding)))
141
+ return
142
+ self._vec_upsert_now(table, rowid, embedding)
143
+
144
+ def _vec_upsert_now(self, table: str, rowid: int, embedding: list[float]) -> None:
69
145
  if not self._has_sqlite_vec:
70
146
  return
71
147
  with self._lock:
@@ -78,6 +154,14 @@ class SQLiteFtsVecMixin:
78
154
 
79
155
  def _vec_delete(self, table: str, rowid: int) -> None:
80
156
  """Delete a vec table row. No-op when sqlite-vec is unavailable."""
157
+ if not self._has_sqlite_vec:
158
+ return
159
+ if self._scope_depth > 0:
160
+ self._deferred_index_ops.append(("vec_delete", (table, rowid)))
161
+ return
162
+ self._vec_delete_now(table, rowid)
163
+
164
+ def _vec_delete_now(self, table: str, rowid: int) -> None:
81
165
  if not self._has_sqlite_vec:
82
166
  return
83
167
  with self._lock:
@@ -87,6 +87,7 @@ class AgentPlaybookStoreMixin:
87
87
  _fts_upsert: Any
88
88
  _vec_upsert: Any
89
89
  _delete_playbook_search_rows: Any
90
+ _own_transaction: Any
90
91
 
91
92
  def _index_agent_playbook_fts_vec(self, ap: AgentPlaybook) -> None:
92
93
  """Update the FTS and vector indexes for a single agent playbook row.
@@ -178,7 +179,8 @@ class AgentPlaybookStoreMixin:
178
179
  created_at_iso = _epoch_to_iso(ap.created_at)
179
180
  with self._lock:
180
181
  self._insert_agent_playbook_row(self.conn, ap, created_at_iso)
181
- self.conn.commit()
182
+ if self._own_transaction():
183
+ self.conn.commit()
182
184
 
183
185
  self._index_agent_playbook_fts_vec(ap)
184
186
  saved.append(ap)
@@ -228,6 +230,7 @@ class AgentPlaybookStoreMixin:
228
230
 
229
231
  created_at_iso = _epoch_to_iso(ap.created_at)
230
232
  with self._lock:
233
+ own_txn = self._own_transaction()
231
234
  try:
232
235
  self._insert_agent_playbook_row(self.conn, ap, created_at_iso)
233
236
  _append_event_stmt(
@@ -242,9 +245,11 @@ class AgentPlaybookStoreMixin:
242
245
  request_id=request_id,
243
246
  reason=f"{AGGREGATE_REASON_PREFIX}{run_mode}",
244
247
  )
245
- self.conn.commit()
248
+ if own_txn:
249
+ self.conn.commit()
246
250
  except Exception:
247
- self.conn.rollback()
251
+ if own_txn:
252
+ self.conn.rollback()
248
253
  raise
249
254
 
250
255
  # FTS/vec indexing AFTER commit — these helpers self-commit and must
@@ -609,7 +614,8 @@ class AgentPlaybookStoreMixin:
609
614
  to_status="archived",
610
615
  status_namespace="lifecycle_status",
611
616
  )
612
- self.conn.commit()
617
+ if self._own_transaction():
618
+ self.conn.commit()
613
619
 
614
620
  @SQLiteStorageBase.handle_exceptions
615
621
  def archive_agent_playbooks_by_ids(self, agent_playbook_ids: list[int]) -> None:
@@ -648,7 +654,8 @@ class AgentPlaybookStoreMixin:
648
654
  to_status="archived",
649
655
  status_namespace="lifecycle_status",
650
656
  )
651
- self.conn.commit()
657
+ if self._own_transaction():
658
+ self.conn.commit()
652
659
 
653
660
  @SQLiteStorageBase.handle_exceptions
654
661
  def supersede_agent_playbooks_by_ids(
@@ -712,7 +719,8 @@ class AgentPlaybookStoreMixin:
712
719
  request_id=request_id,
713
720
  )
714
721
  updated += 1
715
- self.conn.commit()
722
+ if self._own_transaction():
723
+ self.conn.commit()
716
724
  return updated
717
725
 
718
726
  @SQLiteStorageBase.handle_exceptions
@@ -773,7 +781,8 @@ class AgentPlaybookStoreMixin:
773
781
  request_id=request_id,
774
782
  )
775
783
  updated += 1
776
- self.conn.commit()
784
+ if self._own_transaction():
785
+ self.conn.commit()
777
786
  return updated
778
787
 
779
788
  @SQLiteStorageBase.handle_exceptions
@@ -22,6 +22,7 @@ class PlaybookSourceLinkageMixin:
22
22
  _fetchall: Any
23
23
  _subject_ref_for_user_id: Any
24
24
  _assert_subject_writable_locked: Any
25
+ _own_transaction: Any
25
26
 
26
27
  @SQLiteStorageBase.handle_exceptions
27
28
  def set_source_user_playbook_ids_for_agent_playbook(
@@ -87,8 +88,10 @@ class PlaybookSourceLinkageMixin:
87
88
  ids.append(source_id)
88
89
  seen.add(source_id)
89
90
  with self._lock:
91
+ own_txn = self._own_transaction()
90
92
  try:
91
- self.conn.execute("BEGIN IMMEDIATE")
93
+ if own_txn:
94
+ self.conn.execute("BEGIN IMMEDIATE")
92
95
  for user_playbook_id in by_id:
93
96
  row = self.conn.execute(
94
97
  """SELECT user_id, governance_subject_ref FROM user_playbooks
@@ -120,9 +123,11 @@ class PlaybookSourceLinkageMixin:
120
123
  for upid, source_interaction_ids in by_id.items()
121
124
  ],
122
125
  )
123
- self.conn.commit()
126
+ if own_txn:
127
+ self.conn.commit()
124
128
  except Exception:
125
- self.conn.rollback()
129
+ if own_txn:
130
+ self.conn.rollback()
126
131
  raise
127
132
 
128
133
  @SQLiteStorageBase.handle_exceptions
@@ -71,6 +71,7 @@ class UserPlaybookStoreMixin:
71
71
  _delete_playbook_search_rows: Any
72
72
  _subject_ref_for_user_id: Any
73
73
  _assert_subject_writable_locked: Any
74
+ _own_transaction: Any
74
75
 
75
76
  def _subject_ref_from_user_playbook_row(self, row: sqlite3.Row) -> str:
76
77
  subject_ref = row["governance_subject_ref"]
@@ -119,8 +120,10 @@ class UserPlaybookStoreMixin:
119
120
 
120
121
  created_at_iso = _epoch_to_iso(up.created_at)
121
122
  with self._lock:
123
+ own_txn = self._own_transaction()
122
124
  try:
123
- self.conn.execute("BEGIN IMMEDIATE")
125
+ if own_txn:
126
+ self.conn.execute("BEGIN IMMEDIATE")
124
127
  self._assert_subject_writable_locked(subject_ref)
125
128
  cur = self.conn.execute(
126
129
  """INSERT INTO user_playbooks
@@ -159,9 +162,11 @@ class UserPlaybookStoreMixin:
159
162
  )
160
163
  upid = cur.lastrowid or 0
161
164
  up.user_playbook_id = upid
162
- self.conn.commit()
165
+ if own_txn:
166
+ self.conn.commit()
163
167
  except Exception:
164
- self.conn.rollback()
168
+ if own_txn:
169
+ self.conn.rollback()
165
170
  raise
166
171
 
167
172
  fts_parts = [up.trigger or "", up.content or ""]
@@ -789,7 +794,8 @@ class UserPlaybookStoreMixin:
789
794
  to_status=None,
790
795
  status_namespace=None,
791
796
  )
792
- self.conn.commit()
797
+ if self._own_transaction():
798
+ self.conn.commit()
793
799
 
794
800
  @SQLiteStorageBase.handle_exceptions
795
801
  def supersede_user_playbooks_by_ids(
@@ -840,5 +846,6 @@ class UserPlaybookStoreMixin:
840
846
  request_id=request_id,
841
847
  )
842
848
  updated += 1
843
- self.conn.commit()
849
+ if self._own_transaction():
850
+ self.conn.commit()
844
851
  return updated
@@ -45,6 +45,7 @@ class InteractionStoreMixin:
45
45
  embedding_dimensions: int
46
46
  _subject_ref_for_user_id: Any
47
47
  _assert_subject_writable_locked: Any
48
+ _own_transaction: Any
48
49
 
49
50
  # ------------------------------------------------------------------
50
51
  # CRUD — Interactions
@@ -81,8 +82,10 @@ class InteractionStoreMixin:
81
82
  created_at_iso = _epoch_to_iso(interaction.created_at)
82
83
  subject_ref = self._subject_ref_for_user_id(interaction.user_id)
83
84
  with self._lock:
85
+ own_txn = self._own_transaction()
84
86
  try:
85
- self.conn.execute("BEGIN IMMEDIATE")
87
+ if own_txn:
88
+ self.conn.execute("BEGIN IMMEDIATE")
86
89
  self._assert_subject_writable_locked(subject_ref)
87
90
  if interaction.interaction_id:
88
91
  self.conn.execute(
@@ -148,9 +151,11 @@ class InteractionStoreMixin:
148
151
  )
149
152
  iid = cur.lastrowid or 0
150
153
  interaction.interaction_id = iid
151
- self.conn.commit()
154
+ if own_txn:
155
+ self.conn.commit()
152
156
  except Exception:
153
- self.conn.rollback()
157
+ if own_txn:
158
+ self.conn.rollback()
154
159
  raise
155
160
  # Update FTS and vec
156
161
  self._fts_upsert(
@@ -168,12 +173,53 @@ class InteractionStoreMixin:
168
173
  self,
169
174
  user_id: str, # noqa: ARG002
170
175
  interactions: list[Interaction],
176
+ *,
177
+ embeddings_prepared: bool = False,
171
178
  ) -> None:
172
179
  if not interactions:
173
180
  return
181
+ if not embeddings_prepared:
182
+ # Only generate embeddings for interactions that do not already have them.
183
+ # This allows callers to pre-populate embeddings (e.g. via
184
+ # prepare_interaction_embeddings) before opening a commit_scope so that no
185
+ # network I/O occurs inside the transaction.
186
+ to_embed = [i for i in interactions if not i.embedding]
187
+ if to_embed:
188
+ texts = [
189
+ "\n".join([i.content or "", i.user_action_description or ""])
190
+ for i in to_embed
191
+ ]
192
+ try:
193
+ embeddings = self.llm_client.get_embeddings(
194
+ texts, self.embedding_model_name, self.embedding_dimensions
195
+ )
196
+ except EmbeddingUnavailableError as exc:
197
+ logger.warning(
198
+ "Embedding unavailable for interaction bulk insert; "
199
+ "continuing without vectors: %s",
200
+ exc,
201
+ )
202
+ embeddings = [[] for _ in texts]
203
+ for interaction, embedding in zip(to_embed, embeddings, strict=False):
204
+ interaction.embedding = embedding
205
+ for interaction in interactions:
206
+ self._insert_interaction(interaction)
207
+
208
+ @SQLiteStorageBase.handle_exceptions
209
+ def prepare_interaction_embeddings(self, interactions: list[Interaction]) -> None:
210
+ """Pre-populate interaction.embedding for each interaction (no DB write).
211
+
212
+ Generates embeddings in one batch call so the write path inside a
213
+ commit_scope can skip the network round-trip.
214
+ """
215
+ if not interactions:
216
+ return
217
+ to_embed = [i for i in interactions if not i.embedding]
218
+ if not to_embed:
219
+ return
174
220
  texts = [
175
221
  "\n".join([i.content or "", i.user_action_description or ""])
176
- for i in interactions
222
+ for i in to_embed
177
223
  ]
178
224
  try:
179
225
  embeddings = self.llm_client.get_embeddings(
@@ -181,14 +227,13 @@ class InteractionStoreMixin:
181
227
  )
182
228
  except EmbeddingUnavailableError as exc:
183
229
  logger.warning(
184
- "Embedding unavailable for interaction bulk insert; "
230
+ "Embedding unavailable during prepare_interaction_embeddings; "
185
231
  "continuing without vectors: %s",
186
232
  exc,
187
233
  )
188
234
  embeddings = [[] for _ in texts]
189
- for interaction, embedding in zip(interactions, embeddings, strict=False):
235
+ for interaction, embedding in zip(to_embed, embeddings, strict=False):
190
236
  interaction.embedding = embedding
191
- self._insert_interaction(interaction)
192
237
 
193
238
  @SQLiteStorageBase.handle_exceptions
194
239
  def delete_user_interaction(self, request: DeleteUserInteractionRequest) -> None:
@@ -77,6 +77,7 @@ class ProfileStoreMixin:
77
77
  _has_sqlite_vec: bool
78
78
  _subject_ref_for_user_id: Any
79
79
  _assert_subject_writable_locked: Any
80
+ _own_transaction: Any
80
81
 
81
82
  def _subject_ref_from_profile_row(self, row: sqlite3.Row) -> str:
82
83
  subject_ref = row["governance_subject_ref"]
@@ -224,8 +225,10 @@ class ProfileStoreMixin:
224
225
  profile.embedding = self._get_embedding(embedding_text)
225
226
  embedding = profile.embedding
226
227
  with self._lock:
228
+ own_txn = self._own_transaction()
227
229
  try:
228
- self.conn.execute("BEGIN IMMEDIATE")
230
+ if own_txn:
231
+ self.conn.execute("BEGIN IMMEDIATE")
229
232
  self._assert_subject_writable_locked(subject_ref)
230
233
  self.conn.execute(
231
234
  """INSERT OR REPLACE INTO profiles
@@ -261,9 +264,11 @@ class ProfileStoreMixin:
261
264
  subject_ref,
262
265
  ),
263
266
  )
264
- self.conn.commit()
267
+ if own_txn:
268
+ self.conn.commit()
265
269
  except Exception:
266
- self.conn.rollback()
270
+ if own_txn:
271
+ self.conn.rollback()
267
272
  raise
268
273
  fts_parts = [profile.content or ""]
269
274
  if profile.custom_features:
@@ -473,6 +478,24 @@ class ProfileStoreMixin:
473
478
  row = self._fetchone("SELECT COUNT(*) as cnt FROM profiles")
474
479
  return row["cnt"] if row else 0
475
480
 
481
+ @SQLiteStorageBase.handle_exceptions
482
+ def count_user_profiles_by_status(
483
+ self, user_ids: list[str], status: Status | None
484
+ ) -> int:
485
+ if not user_ids:
486
+ return 0
487
+
488
+ current_ts = _epoch_now()
489
+ status_frag, status_params = _build_status_sql([status])
490
+ ph = ",".join("?" for _ in user_ids)
491
+ sql = (
492
+ f"SELECT COUNT(*) as cnt FROM profiles "
493
+ f"WHERE user_id IN ({ph}) "
494
+ f"AND expiration_timestamp >= ? AND {status_frag}"
495
+ )
496
+ row = self._fetchone(sql, [*user_ids, current_ts, *status_params])
497
+ return row["cnt"] if row else 0
498
+
476
499
  @SQLiteStorageBase.handle_exceptions
477
500
  def update_all_profiles_status(
478
501
  self,
@@ -809,7 +832,8 @@ class ProfileStoreMixin:
809
832
  status_namespace="lifecycle_status",
810
833
  )
811
834
  committed_ids.append(pid)
812
- self.conn.commit()
835
+ if self._own_transaction():
836
+ self.conn.commit()
813
837
  return committed_ids
814
838
 
815
839
  @SQLiteStorageBase.handle_exceptions
@@ -1,5 +1,6 @@
1
1
  from reflexio.models.api_schema.domain.enums import Status
2
2
 
3
+ from ._learning_jobs import LearningJob, LearningJobStatus, LearningJobStoreABC
3
4
  from ._agent_run import (
4
5
  NOT_APPLICABLE_ANSWER,
5
6
  AgentBinding,
@@ -22,6 +23,7 @@ from ._agent_run import (
22
23
  not_applicable_tool_result,
23
24
  )
24
25
  from ._base import BaseStorageCore, matches_status_filter
26
+ from ._commit_scope import CommitScopeMixin
25
27
  from ._extras import ExtrasMixin
26
28
  from ._lineage import EntityType, LineageEventMixin
27
29
  from ._operations import OperationMixin
@@ -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