claude-smart 0.2.49 → 0.2.50

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (177) hide show
  1. package/.claude-plugin/marketplace.json +2 -2
  2. package/README.md +10 -43
  3. package/bin/claude-smart.js +105 -0
  4. package/package.json +3 -2
  5. package/plugin/.claude-plugin/plugin.json +1 -1
  6. package/plugin/.codex-plugin/plugin.json +1 -1
  7. package/plugin/.coverage +0 -0
  8. package/plugin/README.md +4 -3
  9. package/plugin/dashboard/app/api/reflexio/[...path]/route.ts +4 -2
  10. package/plugin/dashboard/app/dashboard/page.tsx +6 -1
  11. package/plugin/dashboard/app/preferences/[id]/page.tsx +18 -6
  12. package/plugin/dashboard/app/preferences/page.tsx +32 -35
  13. package/plugin/dashboard/app/sessions/[sessionId]/page.tsx +16 -1
  14. package/plugin/dashboard/app/sessions/page.tsx +2 -0
  15. package/plugin/dashboard/app/skills/page.tsx +65 -50
  16. package/plugin/dashboard/app/skills/project/[id]/page.tsx +17 -8
  17. package/plugin/dashboard/app/skills/shared/[id]/page.tsx +1 -6
  18. package/plugin/dashboard/components/common/host-badge.tsx +118 -0
  19. package/plugin/dashboard/components/common/learning-application-badge.tsx +34 -0
  20. package/plugin/dashboard/components/common/learnings-badge.tsx +1 -1
  21. package/plugin/dashboard/components/common/page-header.tsx +3 -3
  22. package/plugin/dashboard/lib/config-file.ts +5 -1
  23. package/plugin/dashboard/lib/host-attribution.ts +62 -0
  24. package/plugin/dashboard/lib/session-reader.ts +40 -2
  25. package/plugin/dashboard/lib/types.ts +7 -1
  26. package/plugin/pyproject.toml +1 -1
  27. package/plugin/scripts/_lib.sh +197 -1
  28. package/plugin/scripts/backend-python-runner.py +46 -0
  29. package/plugin/scripts/backend-service.sh +757 -119
  30. package/plugin/scripts/codex-hook.js +63 -225
  31. package/plugin/scripts/dashboard-open.sh +6 -4
  32. package/plugin/scripts/dashboard-service.sh +117 -136
  33. package/plugin/scripts/hook_entry.sh +3 -0
  34. package/plugin/scripts/smart-install.sh +15 -1
  35. package/plugin/src/claude_smart/cli.py +14 -0
  36. package/plugin/src/claude_smart/context_inject.py +3 -0
  37. package/plugin/src/claude_smart/env_config.py +4 -1
  38. package/plugin/src/claude_smart/events/post_tool.py +2 -1
  39. package/plugin/src/claude_smart/events/session_end.py +2 -1
  40. package/plugin/src/claude_smart/events/stop.py +3 -0
  41. package/plugin/src/claude_smart/events/user_prompt.py +2 -1
  42. package/plugin/src/claude_smart/internal_call.py +5 -2
  43. package/plugin/src/claude_smart/optimizer_assistant.py +59 -13
  44. package/plugin/src/claude_smart/publish.py +59 -7
  45. package/plugin/src/claude_smart/reflexio_adapter.py +137 -14
  46. package/plugin/src/claude_smart/runtime.py +15 -6
  47. package/plugin/src/claude_smart/state.py +211 -52
  48. package/plugin/uv.lock +1 -1
  49. package/plugin/vendor/reflexio/.env.example +13 -0
  50. package/plugin/vendor/reflexio/reflexio/README.md +7 -3
  51. package/plugin/vendor/reflexio/reflexio/__init__.py +12 -0
  52. package/plugin/vendor/reflexio/reflexio/client/client.py +126 -3
  53. package/plugin/vendor/reflexio/reflexio/integrations/openclaw/plugin/src/openclaw_smart/state.py +10 -3
  54. package/plugin/vendor/reflexio/reflexio/integrations/openclaw/plugin/tests/test_state.py +28 -0
  55. package/plugin/vendor/reflexio/reflexio/lib/_search.py +41 -0
  56. package/plugin/vendor/reflexio/reflexio/models/api_schema/domain/entities.py +177 -25
  57. package/plugin/vendor/reflexio/reflexio/models/api_schema/eval_overview_schema.py +7 -1
  58. package/plugin/vendor/reflexio/reflexio/models/api_schema/internal_schema.py +2 -1
  59. package/plugin/vendor/reflexio/reflexio/models/api_schema/retriever_schema.py +63 -4
  60. package/plugin/vendor/reflexio/reflexio/models/api_schema/ui/converters.py +1 -0
  61. package/plugin/vendor/reflexio/reflexio/models/api_schema/ui/entities.py +8 -1
  62. package/plugin/vendor/reflexio/reflexio/models/config_schema.py +1 -1
  63. package/plugin/vendor/reflexio/reflexio/server/README.md +22 -4
  64. package/plugin/vendor/reflexio/reflexio/server/__init__.py +18 -8
  65. package/plugin/vendor/reflexio/reflexio/server/__main__.py +6 -0
  66. package/plugin/vendor/reflexio/reflexio/server/api.py +66 -3
  67. package/plugin/vendor/reflexio/reflexio/server/billing_meter.py +263 -3
  68. package/plugin/vendor/reflexio/reflexio/server/callback_executor.py +164 -0
  69. package/plugin/vendor/reflexio/reflexio/server/llm/_litellm_embedding.py +81 -81
  70. package/plugin/vendor/reflexio/reflexio/server/llm/_litellm_subprocess.py +28 -0
  71. package/plugin/vendor/reflexio/reflexio/server/llm/_litellm_text_generation.py +62 -5
  72. package/plugin/vendor/reflexio/reflexio/server/llm/embedding_service.py +19 -52
  73. package/plugin/vendor/reflexio/reflexio/server/llm/model_defaults.py +28 -0
  74. package/plugin/vendor/reflexio/reflexio/server/llm/providers/claude_code_provider.py +9 -1
  75. package/plugin/vendor/reflexio/reflexio/server/llm/providers/embedder_warmup.py +329 -0
  76. package/plugin/vendor/reflexio/reflexio/server/llm/providers/embedding_service_provider.py +85 -10
  77. package/plugin/vendor/reflexio/reflexio/server/llm/providers/local_embedding_provider.py +20 -5
  78. package/plugin/vendor/reflexio/reflexio/server/llm/providers/nomic_embedding_provider.py +77 -9
  79. package/plugin/vendor/reflexio/reflexio/server/org_fanout.py +184 -0
  80. package/plugin/vendor/reflexio/reflexio/server/prompt/prompt_bank/document_expansion/v1.0.0.prompt.md +1 -1
  81. package/plugin/vendor/reflexio/reflexio/server/prompt/prompt_bank/document_expansion/v1.1.0.prompt.md +32 -0
  82. package/plugin/vendor/reflexio/reflexio/server/prompt/prompt_bank/playbook_consolidation/v2.3.3.prompt.md +1 -1
  83. package/plugin/vendor/reflexio/reflexio/server/prompt/prompt_bank/playbook_consolidation/v2.4.0.prompt.md +63 -0
  84. package/plugin/vendor/reflexio/reflexio/server/prompt/prompt_bank/query_reformulation/v1.0.0.prompt.md +1 -1
  85. package/plugin/vendor/reflexio/reflexio/server/prompt/prompt_bank/query_reformulation/v2.0.0.prompt.md +30 -0
  86. package/plugin/vendor/reflexio/reflexio/server/prompt/prompt_bank/retrieved_learning_impact/v1.0.0.prompt.md +51 -0
  87. package/plugin/vendor/reflexio/reflexio/server/prompt/prompt_bank/retrieved_learning_relevance/v1.0.0.prompt.md +39 -0
  88. package/plugin/vendor/reflexio/reflexio/server/prompt/prompt_bank/shadow_comparison/v1.0.0.prompt.md +1 -1
  89. package/plugin/vendor/reflexio/reflexio/server/prompt/prompt_bank/shadow_comparison/v1.1.0.prompt.md +43 -0
  90. package/plugin/vendor/reflexio/reflexio/server/routes/config.py +3 -3
  91. package/plugin/vendor/reflexio/reflexio/server/routes/evaluation.py +122 -28
  92. package/plugin/vendor/reflexio/reflexio/server/routes/system.py +22 -3
  93. package/plugin/vendor/reflexio/reflexio/server/scheduling.py +64 -3
  94. package/plugin/vendor/reflexio/reflexio/server/services/README.md +6 -4
  95. package/plugin/vendor/reflexio/reflexio/server/services/agent_success_evaluation/README.md +3 -2
  96. package/plugin/vendor/reflexio/reflexio/server/services/agent_success_evaluation/_eval_health.py +41 -0
  97. package/plugin/vendor/reflexio/reflexio/server/services/agent_success_evaluation/components/retrieved_learning_evaluator.py +554 -0
  98. package/plugin/vendor/reflexio/reflexio/server/services/agent_success_evaluation/regen_jobs.py +35 -1
  99. package/plugin/vendor/reflexio/reflexio/server/services/agent_success_evaluation/runner.py +253 -101
  100. package/plugin/vendor/reflexio/reflexio/server/services/agent_success_evaluation/scheduler.py +5 -7
  101. package/plugin/vendor/reflexio/reflexio/server/services/agent_success_evaluation/service.py +27 -0
  102. package/plugin/vendor/reflexio/reflexio/server/services/base_generation/_extraction_lifecycle.py +11 -5
  103. package/plugin/vendor/reflexio/reflexio/server/services/base_generation/_should_run.py +4 -4
  104. package/plugin/vendor/reflexio/reflexio/server/services/base_generation/_usage_billing.py +6 -2
  105. package/plugin/vendor/reflexio/reflexio/server/services/base_generation_service.py +255 -74
  106. package/plugin/vendor/reflexio/reflexio/server/services/deduplication_utils.py +72 -0
  107. package/plugin/vendor/reflexio/reflexio/server/services/deferred_learning_plan.py +270 -0
  108. package/plugin/vendor/reflexio/reflexio/server/services/durable_learning/worker.py +85 -16
  109. package/plugin/vendor/reflexio/reflexio/server/services/evaluation_overview/components/hero_state.py +2 -11
  110. package/plugin/vendor/reflexio/reflexio/server/services/evaluation_overview/components/rule_attribution.py +6 -2
  111. package/plugin/vendor/reflexio/reflexio/server/services/evaluation_overview/service.py +17 -13
  112. package/plugin/vendor/reflexio/reflexio/server/services/extraction/agent_run_records.py +18 -3
  113. package/plugin/vendor/reflexio/reflexio/server/services/extraction/outcome.py +12 -1
  114. package/plugin/vendor/reflexio/reflexio/server/services/extraction/prior_answer_search.py +1 -1
  115. package/plugin/vendor/reflexio/reflexio/server/services/extraction/resumable_agent.py +2 -1
  116. package/plugin/vendor/reflexio/reflexio/server/services/extraction/resume_worker.py +66 -0
  117. package/plugin/vendor/reflexio/reflexio/server/services/generation_service.py +510 -63
  118. package/plugin/vendor/reflexio/reflexio/server/services/governance/service.py +2 -0
  119. package/plugin/vendor/reflexio/reflexio/server/services/lineage/gc_scheduler.py +179 -99
  120. package/plugin/vendor/reflexio/reflexio/server/services/lineage/vector_backfill_sweep.py +139 -0
  121. package/plugin/vendor/reflexio/reflexio/server/services/operation_state_utils.py +66 -27
  122. package/plugin/vendor/reflexio/reflexio/server/services/playbook/aggregation_trigger.py +177 -0
  123. package/plugin/vendor/reflexio/reflexio/server/services/playbook/components/aggregator.py +68 -2
  124. package/plugin/vendor/reflexio/reflexio/server/services/playbook/components/consolidator.py +360 -49
  125. package/plugin/vendor/reflexio/reflexio/server/services/playbook/components/extractor.py +27 -30
  126. package/plugin/vendor/reflexio/reflexio/server/services/playbook/playbook_edit_apply.py +8 -1
  127. package/plugin/vendor/reflexio/reflexio/server/services/playbook/service.py +137 -69
  128. package/plugin/vendor/reflexio/reflexio/server/services/playbook_optimizer/optimizer.py +20 -1
  129. package/plugin/vendor/reflexio/reflexio/server/services/playbook_optimizer/scheduler.py +13 -6
  130. package/plugin/vendor/reflexio/reflexio/server/services/pre_retrieval/_query_reformulator.py +40 -25
  131. package/plugin/vendor/reflexio/reflexio/server/services/profile/components/consolidator.py +12 -39
  132. package/plugin/vendor/reflexio/reflexio/server/services/profile/components/extractor.py +30 -35
  133. package/plugin/vendor/reflexio/reflexio/server/services/profile/service.py +122 -44
  134. package/plugin/vendor/reflexio/reflexio/server/services/reflection/service.py +457 -107
  135. package/plugin/vendor/reflexio/reflexio/server/services/retrieval/session_dedup.py +127 -0
  136. package/plugin/vendor/reflexio/reflexio/server/services/retrieval/temporal.py +104 -0
  137. package/plugin/vendor/reflexio/reflexio/server/services/shadow_comparison/dispatcher.py +139 -0
  138. package/plugin/vendor/reflexio/reflexio/server/services/shadow_comparison/judge.py +16 -6
  139. package/plugin/vendor/reflexio/reflexio/server/services/shadow_comparison/worker.py +137 -0
  140. package/plugin/vendor/reflexio/reflexio/server/services/storage/governance_validation.py +12 -0
  141. package/plugin/vendor/reflexio/reflexio/server/services/storage/lifecycle_filters.py +54 -0
  142. package/plugin/vendor/reflexio/reflexio/server/services/storage/retention.py +41 -3
  143. package/plugin/vendor/reflexio/reflexio/server/services/storage/retention_mixin.py +40 -2
  144. package/plugin/vendor/reflexio/reflexio/server/services/storage/sqlite_storage/__init__.py +1 -1
  145. package/plugin/vendor/reflexio/reflexio/server/services/storage/sqlite_storage/_base.py +50 -0
  146. package/plugin/vendor/reflexio/reflexio/server/services/storage/sqlite_storage/_extras.py +11 -4
  147. package/plugin/vendor/reflexio/reflexio/server/services/storage/sqlite_storage/_governance.py +28 -1
  148. package/plugin/vendor/reflexio/reflexio/server/services/storage/sqlite_storage/_learning_jobs.py +36 -1
  149. package/plugin/vendor/reflexio/reflexio/server/services/storage/sqlite_storage/_shadow_verdicts.py +4 -0
  150. package/plugin/vendor/reflexio/reflexio/server/services/storage/sqlite_storage/base/_deletion.py +16 -5
  151. package/plugin/vendor/reflexio/reflexio/server/services/storage/sqlite_storage/governance/_erase_execution.py +104 -42
  152. package/plugin/vendor/reflexio/reflexio/server/services/storage/sqlite_storage/governance/_subject_barrier.py +7 -1
  153. package/plugin/vendor/reflexio/reflexio/server/services/storage/sqlite_storage/playbook/_agent.py +43 -0
  154. package/plugin/vendor/reflexio/reflexio/server/services/storage/sqlite_storage/playbook/_eval_results.py +430 -5
  155. package/plugin/vendor/reflexio/reflexio/server/services/storage/sqlite_storage/playbook/_user.py +45 -7
  156. package/plugin/vendor/reflexio/reflexio/server/services/storage/sqlite_storage/profiles/_interaction_store.py +146 -6
  157. package/plugin/vendor/reflexio/reflexio/server/services/storage/sqlite_storage/profiles/_profile_store.py +42 -7
  158. package/plugin/vendor/reflexio/reflexio/server/services/storage/storage_base/__init__.py +1 -1
  159. package/plugin/vendor/reflexio/reflexio/server/services/storage/storage_base/_extras.py +9 -2
  160. package/plugin/vendor/reflexio/reflexio/server/services/storage/storage_base/_learning_jobs.py +50 -0
  161. package/plugin/vendor/reflexio/reflexio/server/services/storage/storage_base/_operations.py +7 -0
  162. package/plugin/vendor/reflexio/reflexio/server/services/storage/storage_base/_retrieval_log.py +3 -1
  163. package/plugin/vendor/reflexio/reflexio/server/services/storage/storage_base/_shadow_verdicts.py +4 -0
  164. package/plugin/vendor/reflexio/reflexio/server/services/storage/storage_base/agent_run/_models.py +12 -1
  165. package/plugin/vendor/reflexio/reflexio/server/services/storage/storage_base/evaluation_state_keys.py +78 -0
  166. package/plugin/vendor/reflexio/reflexio/server/services/storage/storage_base/playbook/_agent.py +38 -0
  167. package/plugin/vendor/reflexio/reflexio/server/services/storage/storage_base/playbook/_eval_results.py +171 -0
  168. package/plugin/vendor/reflexio/reflexio/server/services/storage/storage_base/playbook/_user.py +52 -1
  169. package/plugin/vendor/reflexio/reflexio/server/services/storage/storage_base/profiles/_interaction_store.py +59 -0
  170. package/plugin/vendor/reflexio/reflexio/server/services/storage/storage_base/profiles/_profile_store.py +52 -2
  171. package/plugin/vendor/reflexio/reflexio/server/services/storage/storage_base/retrieved_learning_state.py +226 -0
  172. package/plugin/vendor/reflexio/reflexio/server/services/tagging/tagging_scheduler.py +5 -6
  173. package/plugin/vendor/reflexio/reflexio/server/services/unified_search_service.py +209 -29
  174. package/plugin/vendor/reflexio/reflexio/server/usage_metrics.py +3 -0
  175. package/plugin/vendor/reflexio/reflexio/test_support/llm_mock.py +61 -0
  176. package/plugin/vendor/reflexio/reflexio/test_support/llm_model_registry.py +33 -0
  177. package/plugin/vendor/reflexio/reflexio/server/services/search/__init__.py +0 -0
@@ -166,6 +166,7 @@ class SubjectBarrierMixin:
166
166
  "profiles",
167
167
  "user_playbooks",
168
168
  "agent_success_evaluation_result",
169
+ "retrieved_learning_evaluation",
169
170
  ):
170
171
  row = self.conn.execute(
171
172
  f"""SELECT 1 FROM {table}
@@ -198,9 +199,14 @@ class SubjectBarrierMixin:
198
199
  request_id_column="request_id",
199
200
  ):
200
201
  return True
201
- return self._legacy_user_id_rows_remain_locked(
202
+ if self._legacy_user_id_rows_remain_locked(
202
203
  table="agent_success_evaluation_result",
203
204
  subject_ref=subject_ref,
205
+ ):
206
+ return True
207
+ return self._legacy_user_id_rows_remain_locked(
208
+ table="retrieved_learning_evaluation",
209
+ subject_ref=subject_ref,
204
210
  )
205
211
 
206
212
  def begin_subject_erasure_barrier(
@@ -19,6 +19,9 @@ from reflexio.models.api_schema.service_schemas import (
19
19
  Status,
20
20
  )
21
21
  from reflexio.models.config_schema import SearchMode, SearchOptions
22
+ from reflexio.server.services.storage.lifecycle_filters import (
23
+ validate_include_inactive,
24
+ )
22
25
  from reflexio.server.services.storage.storage_base._playbook import (
23
26
  AGGREGATE_REASON_PREFIX,
24
27
  )
@@ -344,6 +347,46 @@ class AgentPlaybookStoreMixin:
344
347
  row = self._fetchone(sql, (agent_playbook_id,))
345
348
  return _row_to_agent_playbook(row) if row else None
346
349
 
350
+ @SQLiteStorageBase.handle_exceptions
351
+ def get_agent_playbooks_by_ids(
352
+ self,
353
+ agent_playbook_ids: list[int],
354
+ *,
355
+ status_filter: list[Status | None] | None = None,
356
+ playbook_status_filter: list[PlaybookStatus] | None = None,
357
+ include_inactive: bool = False,
358
+ ) -> list[AgentPlaybook]:
359
+ validate_include_inactive(
360
+ include_inactive=include_inactive,
361
+ status_filter=status_filter,
362
+ playbook_status_filter=playbook_status_filter,
363
+ )
364
+ if not agent_playbook_ids:
365
+ return []
366
+ unique_ids = list(dict.fromkeys(agent_playbook_ids))
367
+ rows: list[sqlite3.Row] = []
368
+ for start in range(0, len(unique_ids), 900):
369
+ chunk = unique_ids[start : start + 900]
370
+ placeholders = ",".join("?" for _ in chunk)
371
+ query = (
372
+ "SELECT * FROM agent_playbooks "
373
+ f"WHERE agent_playbook_id IN ({placeholders})"
374
+ )
375
+ params: list[Any] = list(chunk)
376
+ if not include_inactive:
377
+ if status_filter is not None:
378
+ fragment, status_params = _build_status_sql(status_filter)
379
+ query += f" AND {fragment}"
380
+ params.extend(status_params)
381
+ else:
382
+ query += " AND status IS NULL"
383
+ if playbook_status_filter:
384
+ status_placeholders = ",".join("?" for _ in playbook_status_filter)
385
+ query += f" AND playbook_status IN ({status_placeholders})"
386
+ params.extend(status.value for status in playbook_status_filter)
387
+ rows.extend(self._fetchall(query, params))
388
+ return [_row_to_agent_playbook(row) for row in rows]
389
+
347
390
  @SQLiteStorageBase.handle_exceptions
348
391
  def delete_all_agent_playbooks(self) -> None:
349
392
  batch_request_id = uuid.uuid4().hex
@@ -1,16 +1,30 @@
1
1
  """Agent success evaluation result store methods for SQLite storage."""
2
2
 
3
3
  import sqlite3
4
+ from datetime import UTC, datetime
4
5
  from typing import Any
5
6
 
6
7
  from reflexio.models.api_schema.service_schemas import (
7
8
  AgentSuccessEvaluationResult,
9
+ RetrievedLearningEvaluationResult,
8
10
  )
9
11
 
12
+ from ...storage_base.retrieved_learning_state import (
13
+ CANONICAL_RETRIEVED_KINDS,
14
+ DEFAULT_TRANSCRIPT_CHAR_LIMIT,
15
+ TERMINAL_RETRIEVED_STATUSES,
16
+ BoundedRetrievedLearningSnapshot,
17
+ RetrievedLearningCommitResult,
18
+ SessionFingerprintBuilder,
19
+ append_bounded_snapshot_interaction,
20
+ build_retrieved_learning_state_key,
21
+ )
10
22
  from .._base import (
11
23
  SQLiteStorageBase,
12
24
  _epoch_to_iso,
25
+ _iso_to_epoch,
13
26
  _json_dumps,
27
+ _json_loads,
14
28
  _row_to_eval_result,
15
29
  )
16
30
 
@@ -23,9 +37,12 @@ class AgentEvaluationResultStoreMixin:
23
37
  conn: sqlite3.Connection
24
38
  _execute: Any
25
39
  _fetchall: Any
40
+ _fetchone: Any
26
41
  _get_embedding: Any
27
42
  _subject_ref_for_user_id: Any
28
43
  _assert_subject_writable_locked: Any
44
+ _current_timestamp: Any
45
+ get_operation_state: Any
29
46
 
30
47
  # ------------------------------------------------------------------
31
48
  # Agent Success Evaluation methods
@@ -36,11 +53,12 @@ class AgentEvaluationResultStoreMixin:
36
53
  self, results: list[AgentSuccessEvaluationResult]
37
54
  ) -> None:
38
55
  for result in results:
39
- embedding_text = f"{result.failure_type} {result.failure_reason}"
40
- if embedding_text.strip():
41
- result.embedding = self._get_embedding(embedding_text)
42
- else:
43
- result.embedding = []
56
+ if not result.embedding:
57
+ embedding_text = f"{result.failure_type} {result.failure_reason}"
58
+ if embedding_text.strip():
59
+ result.embedding = self._get_embedding(embedding_text)
60
+ else:
61
+ result.embedding = []
44
62
 
45
63
  created_at_iso = _epoch_to_iso(result.created_at)
46
64
  subject_ref = self._subject_ref_for_user_id(result.user_id)
@@ -187,3 +205,410 @@ class AgentEvaluationResultStoreMixin:
187
205
  list(result_ids),
188
206
  )
189
207
  return cur.rowcount
208
+
209
+ # ------------------------------------------------------------------
210
+ # Retrieved-learning evaluation methods
211
+ # ------------------------------------------------------------------
212
+ # Generation fencing + session-fingerprint CAS; see
213
+ # storage_base/retrieved_learning_state.py for the concurrency contract.
214
+ # All state reads/writes inside a transaction use raw self.conn.execute
215
+ # (never self._execute or self-committing helpers) so replacement stays
216
+ # atomic on the shared connection.
217
+
218
+ def _rle_state_row(self, state_key: str) -> dict[str, Any]:
219
+ cur = self.conn.execute(
220
+ "SELECT operation_state FROM _operation_state WHERE service_name = ?",
221
+ (state_key,),
222
+ )
223
+ row = cur.fetchone()
224
+ if not row:
225
+ return {}
226
+ return _json_loads(row["operation_state"]) or {}
227
+
228
+ def _rle_upsert_state(self, state_key: str, state: dict[str, Any]) -> None:
229
+ self.conn.execute(
230
+ """INSERT INTO _operation_state (service_name, operation_state, updated_at)
231
+ VALUES (?,?,?)
232
+ ON CONFLICT(service_name) DO UPDATE SET
233
+ operation_state = excluded.operation_state,
234
+ updated_at = excluded.updated_at""",
235
+ (state_key, _json_dumps(state), self._current_timestamp()),
236
+ )
237
+
238
+ def _rle_fingerprint_now(self, user_id: str, session_id: str) -> str:
239
+ """Recompute the session fingerprint from live rows (in-transaction)."""
240
+ cur = self.conn.execute(
241
+ """SELECT i.interaction_id, i.role,
242
+ substr(i.content, 1, ?) AS content, i.retrieved_learnings
243
+ FROM interactions i JOIN requests r ON i.request_id = r.request_id
244
+ WHERE r.session_id = ? AND i.user_id = ?
245
+ ORDER BY i.created_at ASC, i.interaction_id ASC""",
246
+ (DEFAULT_TRANSCRIPT_CHAR_LIMIT, session_id, user_id),
247
+ )
248
+ builder = SessionFingerprintBuilder()
249
+ for row in cur:
250
+ builder.add(
251
+ int(row["interaction_id"]),
252
+ _parse_attachment_refs(row["retrieved_learnings"]),
253
+ row["role"] or "User",
254
+ row["content"] or "",
255
+ )
256
+ return builder.hexdigest()
257
+
258
+ def _rle_attached_refs(self, user_id: str, session_id: str) -> set[tuple[str, str]]:
259
+ """Canonical ``(kind, learning_id)`` refs attached to the live session."""
260
+ cur = self.conn.execute(
261
+ """SELECT i.retrieved_learnings
262
+ FROM interactions i JOIN requests r ON i.request_id = r.request_id
263
+ WHERE r.session_id = ? AND i.user_id = ?""",
264
+ (session_id, user_id),
265
+ )
266
+ attached: set[tuple[str, str]] = set()
267
+ for row in cur:
268
+ attached.update(_parse_attachment_refs(row["retrieved_learnings"]))
269
+ return attached
270
+
271
+ def _rle_resolvable_refs(
272
+ self, user_id: str, results: list[RetrievedLearningEvaluationResult]
273
+ ) -> set[tuple[str, str]]:
274
+ """Recheck original source rows for historical resolution (in-transaction)."""
275
+ by_kind: dict[str, list[str]] = {}
276
+ for r in results:
277
+ by_kind.setdefault(r.kind, []).append(r.learning_id)
278
+ resolvable: set[tuple[str, str]] = set()
279
+ for chunk in _chunked(by_kind.get("profile", [])):
280
+ ph = ",".join("?" for _ in chunk)
281
+ cur = self.conn.execute(
282
+ f"""SELECT profile_id FROM profiles
283
+ WHERE user_id = ? AND profile_id IN ({ph})""",
284
+ (user_id, *chunk),
285
+ )
286
+ resolvable.update(("profile", str(row[0])) for row in cur.fetchall())
287
+ user_playbook_ids = [
288
+ i for i in (_as_int(x) for x in by_kind.get("user_playbook", [])) if i
289
+ ]
290
+ for chunk in _chunked(user_playbook_ids):
291
+ ph = ",".join("?" for _ in chunk)
292
+ cur = self.conn.execute(
293
+ f"""SELECT user_playbook_id FROM user_playbooks
294
+ WHERE user_id = ? AND user_playbook_id IN ({ph})""",
295
+ (user_id, *chunk),
296
+ )
297
+ resolvable.update(("user_playbook", str(row[0])) for row in cur.fetchall())
298
+ agent_playbook_ids = [
299
+ i for i in (_as_int(x) for x in by_kind.get("agent_playbook", [])) if i
300
+ ]
301
+ for chunk in _chunked(agent_playbook_ids):
302
+ ph = ",".join("?" for _ in chunk)
303
+ cur = self.conn.execute(
304
+ f"""SELECT agent_playbook_id FROM agent_playbooks
305
+ WHERE agent_playbook_id IN ({ph})""",
306
+ chunk,
307
+ )
308
+ resolvable.update(("agent_playbook", str(row[0])) for row in cur.fetchall())
309
+ return resolvable
310
+
311
+ @SQLiteStorageBase.handle_exceptions
312
+ def begin_retrieved_learning_evaluation_run(
313
+ self, user_id: str, session_id: str
314
+ ) -> int:
315
+ state_key = build_retrieved_learning_state_key(user_id, session_id)
316
+ subject_ref = self._subject_ref_for_user_id(user_id)
317
+ with self._lock:
318
+ try:
319
+ self.conn.execute("BEGIN IMMEDIATE")
320
+ self._assert_subject_writable_locked(subject_ref)
321
+ state = self._rle_state_row(state_key)
322
+ generation = int(state.get("generation") or 0) + 1
323
+ state.update(
324
+ {
325
+ "user_id": user_id,
326
+ "session_id": session_id,
327
+ "generation": generation,
328
+ "attempted_at": int(self._current_epoch()),
329
+ }
330
+ )
331
+ state.setdefault("status", "pending")
332
+ state.setdefault("session_fingerprint", "")
333
+ self._rle_upsert_state(state_key, state)
334
+ self.conn.commit()
335
+ except Exception:
336
+ self.conn.rollback()
337
+ raise
338
+ return generation
339
+
340
+ @SQLiteStorageBase.handle_exceptions
341
+ def load_bounded_retrieved_learning_snapshot(
342
+ self,
343
+ user_id: str,
344
+ session_id: str,
345
+ raw_ref_limit: int = 5_000,
346
+ transcript_char_limit: int = DEFAULT_TRANSCRIPT_CHAR_LIMIT,
347
+ ) -> BoundedRetrievedLearningSnapshot:
348
+ snapshot = BoundedRetrievedLearningSnapshot()
349
+ row = self._fetchone(
350
+ """SELECT MIN(created_at) AS earliest FROM requests
351
+ WHERE session_id = ? AND user_id = ?""",
352
+ (session_id, user_id),
353
+ )
354
+ if row and row["earliest"]:
355
+ snapshot.earliest_request_created_at = _iso_to_epoch(row["earliest"])
356
+ row = self._fetchone(
357
+ """SELECT agent_version FROM requests
358
+ WHERE session_id = ? AND user_id = ?
359
+ ORDER BY created_at DESC LIMIT 1""",
360
+ (session_id, user_id),
361
+ )
362
+ if row:
363
+ snapshot.agent_version = row["agent_version"] or ""
364
+ cursor = self.conn.execute(
365
+ # ``fp_content`` is truncated to the fixed fingerprint limit,
366
+ # independent of the caller's ``transcript_char_limit`` snapshot
367
+ # budget, so the commit-side recompute (which has no such budget)
368
+ # produces an identical digest.
369
+ """SELECT i.interaction_id, i.role,
370
+ substr(i.content, 1, ?) AS content,
371
+ substr(i.content, 1, ?) AS fp_content,
372
+ i.created_at, i.retrieved_learnings
373
+ FROM interactions i JOIN requests r ON i.request_id = r.request_id
374
+ WHERE r.session_id = ? AND i.user_id = ?
375
+ ORDER BY i.created_at ASC, i.interaction_id ASC""",
376
+ (transcript_char_limit, DEFAULT_TRANSCRIPT_CHAR_LIMIT, session_id, user_id),
377
+ )
378
+ builder = SessionFingerprintBuilder()
379
+ transcript_chars_remaining = transcript_char_limit
380
+ for r in cursor:
381
+ refs = _parse_attachment_refs(r["retrieved_learnings"])
382
+ snapshot.raw_attachment_count += len(refs)
383
+ builder.add(
384
+ int(r["interaction_id"]),
385
+ refs,
386
+ r["role"] or "User",
387
+ r["fp_content"] or "",
388
+ )
389
+ if snapshot.raw_attachment_count > raw_ref_limit:
390
+ snapshot.attachment_limit_exceeded = True
391
+ snapshot.interactions.clear()
392
+ transcript_chars_remaining = 0
393
+ continue
394
+ transcript_chars_remaining = append_bounded_snapshot_interaction(
395
+ snapshot,
396
+ interaction_id=int(r["interaction_id"]),
397
+ role=r["role"] or "User",
398
+ content=r["content"] or "",
399
+ created_at=_iso_to_epoch(r["created_at"]),
400
+ refs=refs,
401
+ transcript_chars_remaining=transcript_chars_remaining,
402
+ )
403
+ snapshot.precomputed_fingerprint = builder.hexdigest()
404
+ return snapshot
405
+
406
+ @SQLiteStorageBase.handle_exceptions
407
+ def get_matching_retrieved_learning_terminal_state(
408
+ self, user_id: str, session_id: str, session_fingerprint: str
409
+ ) -> dict[str, Any] | None:
410
+ state_key = build_retrieved_learning_state_key(user_id, session_id)
411
+ with self._lock:
412
+ try:
413
+ self.conn.execute("BEGIN IMMEDIATE")
414
+ state = self._rle_state_row(state_key)
415
+ live_fingerprint = self._rle_fingerprint_now(user_id, session_id)
416
+ matched = (
417
+ state.get("status") in TERMINAL_RETRIEVED_STATUSES
418
+ and state.get("session_fingerprint") == session_fingerprint
419
+ and live_fingerprint == session_fingerprint
420
+ )
421
+ self.conn.commit()
422
+ except Exception:
423
+ self.conn.rollback()
424
+ raise
425
+ return state if matched else None
426
+
427
+ @SQLiteStorageBase.handle_exceptions
428
+ def replace_retrieved_learning_evaluation_results(
429
+ self,
430
+ user_id: str,
431
+ session_id: str,
432
+ generation: int,
433
+ session_fingerprint: str,
434
+ proposed_status: str,
435
+ diagnostics: dict[str, Any],
436
+ results: list[RetrievedLearningEvaluationResult],
437
+ ) -> RetrievedLearningCommitResult:
438
+ if proposed_status not in ("complete", "degraded"):
439
+ raise ValueError(f"invalid proposed_status: {proposed_status}")
440
+ state_key = build_retrieved_learning_state_key(user_id, session_id)
441
+ subject_ref = self._subject_ref_for_user_id(user_id)
442
+ with self._lock:
443
+ try:
444
+ self.conn.execute("BEGIN IMMEDIATE")
445
+ self._assert_subject_writable_locked(subject_ref)
446
+ state = self._rle_state_row(state_key)
447
+ if int(state.get("generation") or 0) != generation:
448
+ self.conn.rollback()
449
+ return RetrievedLearningCommitResult(disposition="superseded")
450
+ if self._rle_fingerprint_now(user_id, session_id) != (
451
+ session_fingerprint
452
+ ):
453
+ self.conn.rollback()
454
+ return RetrievedLearningCommitResult(disposition="stale")
455
+ # Persist only records that are still attached to the live
456
+ # session AND whose original row still exists. A duplicate identity is left
457
+ # to trip the UNIQUE index and roll back the commit — caller
458
+ # bugs fail loud rather than silently dropping data.
459
+ attached = self._rle_attached_refs(user_id, session_id)
460
+ resolvable = self._rle_resolvable_refs(user_id, results)
461
+ kept = [
462
+ r
463
+ for r in results
464
+ if (r.kind, r.learning_id) in attached
465
+ and (r.kind, r.learning_id) in resolvable
466
+ ]
467
+ final_status = proposed_status if kept else "not_applicable"
468
+ self.conn.execute(
469
+ """DELETE FROM retrieved_learning_evaluation
470
+ WHERE user_id = ? AND session_id = ?""",
471
+ (user_id, session_id),
472
+ )
473
+ for r in kept:
474
+ self.conn.execute(
475
+ """INSERT INTO retrieved_learning_evaluation
476
+ (user_id, session_id, agent_version, kind,
477
+ learning_id, is_relevant, relevance_reason, impact,
478
+ impact_reason, created_at, governance_subject_ref)
479
+ VALUES (?,?,?,?,?,?,?,?,?,?,?)""",
480
+ (
481
+ user_id,
482
+ session_id,
483
+ r.agent_version,
484
+ r.kind,
485
+ r.learning_id,
486
+ None if r.is_relevant is None else int(r.is_relevant),
487
+ r.relevance_reason,
488
+ r.impact,
489
+ r.impact_reason,
490
+ r.created_at,
491
+ subject_ref,
492
+ ),
493
+ )
494
+ state.update(diagnostics)
495
+ state.update(
496
+ {
497
+ "user_id": user_id,
498
+ "session_id": session_id,
499
+ "generation": generation,
500
+ "status": final_status,
501
+ "session_fingerprint": session_fingerprint,
502
+ "resolvable_count": len(resolvable),
503
+ "committed_count": len(kept),
504
+ "completed_at": int(self._current_epoch()),
505
+ }
506
+ )
507
+ self._rle_upsert_state(state_key, state)
508
+ self.conn.commit()
509
+ except Exception:
510
+ self.conn.rollback()
511
+ raise
512
+ return RetrievedLearningCommitResult(
513
+ disposition="applied", status=final_status, committed_count=len(kept)
514
+ )
515
+
516
+ @SQLiteStorageBase.handle_exceptions
517
+ def finish_retrieved_learning_evaluation_run(
518
+ self,
519
+ user_id: str,
520
+ session_id: str,
521
+ generation: int,
522
+ status: str,
523
+ diagnostics: dict[str, Any],
524
+ ) -> None:
525
+ if status not in ("failed", "pending"):
526
+ raise ValueError(f"invalid finish status: {status}")
527
+ state_key = build_retrieved_learning_state_key(user_id, session_id)
528
+ with self._lock:
529
+ try:
530
+ self.conn.execute("BEGIN IMMEDIATE")
531
+ state = self._rle_state_row(state_key)
532
+ if int(state.get("generation") or 0) != generation:
533
+ self.conn.rollback()
534
+ return
535
+ state.update(diagnostics)
536
+ state["status"] = status
537
+ self._rle_upsert_state(state_key, state)
538
+ self.conn.commit()
539
+ except Exception:
540
+ self.conn.rollback()
541
+ raise
542
+
543
+ @SQLiteStorageBase.handle_exceptions
544
+ def get_retrieved_learning_evaluation_results(
545
+ self,
546
+ user_id: str | None = None,
547
+ session_id: str | None = None,
548
+ limit: int = 100,
549
+ ) -> list[RetrievedLearningEvaluationResult]:
550
+ sql = "SELECT * FROM retrieved_learning_evaluation"
551
+ clauses: list[str] = []
552
+ params: list[Any] = []
553
+ if user_id is not None:
554
+ clauses.append("user_id = ?")
555
+ params.append(user_id)
556
+ if session_id is not None:
557
+ clauses.append("session_id = ?")
558
+ params.append(session_id)
559
+ if clauses:
560
+ sql += " WHERE " + " AND ".join(clauses)
561
+ sql += " ORDER BY created_at DESC, result_id DESC LIMIT ?"
562
+ params.append(limit)
563
+ rows = self._fetchall(sql, params)
564
+ return [_row_to_retrieved_learning_result(r) for r in rows]
565
+
566
+ def _current_epoch(self) -> int:
567
+ return int(datetime.now(UTC).timestamp())
568
+
569
+
570
+ def _parse_attachment_refs(raw: Any) -> list[tuple[str, str]]:
571
+ """Parse a stored retrieved_learnings JSON blob into (kind, learning_id) tuples."""
572
+ parsed = _json_loads(raw) if isinstance(raw, str) else raw
573
+ if not parsed or not isinstance(parsed, list):
574
+ return []
575
+ refs: list[tuple[str, str]] = []
576
+ for item in parsed:
577
+ if not isinstance(item, dict):
578
+ continue
579
+ kind = str(item.get("kind") or "")
580
+ learning_id = str(item.get("learning_id") or "")
581
+ if kind in CANONICAL_RETRIEVED_KINDS and learning_id:
582
+ refs.append((kind, learning_id))
583
+ return refs
584
+
585
+
586
+ def _row_to_retrieved_learning_result(
587
+ row: sqlite3.Row,
588
+ ) -> RetrievedLearningEvaluationResult:
589
+ d = dict(row)
590
+ return RetrievedLearningEvaluationResult(
591
+ result_id=int(d["result_id"]),
592
+ user_id=d["user_id"],
593
+ session_id=d["session_id"],
594
+ agent_version=d.get("agent_version") or "",
595
+ kind=d["kind"],
596
+ learning_id=d["learning_id"],
597
+ is_relevant=None if d.get("is_relevant") is None else bool(d["is_relevant"]),
598
+ relevance_reason=d.get("relevance_reason") or "",
599
+ impact=d.get("impact"),
600
+ impact_reason=d.get("impact_reason") or "",
601
+ created_at=int(d["created_at"]),
602
+ )
603
+
604
+
605
+ def _as_int(value: str) -> int | None:
606
+ try:
607
+ parsed = int(value)
608
+ except (TypeError, ValueError):
609
+ return None
610
+ return parsed if parsed > 0 else None
611
+
612
+
613
+ def _chunked(items: list, size: int = 500) -> list[list]:
614
+ return [items[i : i + size] for i in range(0, len(items), size)]
@@ -10,6 +10,9 @@ from reflexio.models.api_schema.common import BlockingIssue
10
10
  from reflexio.models.api_schema.retriever_schema import SearchUserPlaybookRequest
11
11
  from reflexio.models.api_schema.service_schemas import Status, UserPlaybook
12
12
  from reflexio.models.config_schema import SearchMode, SearchOptions
13
+ from reflexio.server.services.storage.lifecycle_filters import (
14
+ validate_include_inactive,
15
+ )
13
16
 
14
17
  from .._base import (
15
18
  _TOMBSTONE_STATUS_VALUES,
@@ -97,12 +100,17 @@ class UserPlaybookStoreMixin:
97
100
  )
98
101
  return row
99
102
 
100
- @SQLiteStorageBase.handle_exceptions
101
- def save_user_playbooks(self, user_playbooks: list[UserPlaybook]) -> None:
102
- for up in user_playbooks:
103
- subject_ref = self._subject_ref_for_user_id(up.user_id)
104
- with self._lock:
105
- self._assert_subject_writable_locked(subject_ref)
103
+ def precompute_user_playbook_embeddings(
104
+ self, playbooks: list[UserPlaybook]
105
+ ) -> None:
106
+ """Populate ``.embedding`` / ``.expanded_terms`` in place; no DB write.
107
+
108
+ Extracted verbatim from the former ``save_user_playbooks`` prelude
109
+ (including the ``if embedding_text:`` guard) so the durable
110
+ compute/persist split can embed outside the writer transaction and then
111
+ persist with ``skip_embedding=True``.
112
+ """
113
+ for up in playbooks:
106
114
  embedding_text = up.trigger or up.content
107
115
  if embedding_text:
108
116
  if self._should_expand_documents():
@@ -118,6 +126,24 @@ class UserPlaybookStoreMixin:
118
126
  else:
119
127
  up.embedding = self._get_embedding(embedding_text)
120
128
 
129
+ @SQLiteStorageBase.handle_exceptions
130
+ def save_user_playbooks(
131
+ self,
132
+ user_playbooks: list[UserPlaybook],
133
+ *,
134
+ skip_embedding: bool = False,
135
+ ) -> None:
136
+ for up in user_playbooks:
137
+ subject_ref = self._subject_ref_for_user_id(up.user_id)
138
+ with self._lock:
139
+ self._assert_subject_writable_locked(subject_ref)
140
+ # Default (skip_embedding=False) recomputes unconditionally, exactly
141
+ # as before — model_copy callers that change content while keeping
142
+ # the old embedding depend on this. The durable persist path opts
143
+ # out (embedding already set by precompute_user_playbook_embeddings).
144
+ if not skip_embedding:
145
+ self.precompute_user_playbook_embeddings([up])
146
+
121
147
  created_at_iso = _epoch_to_iso(up.created_at)
122
148
  with self._lock:
123
149
  own_txn = self._own_transaction()
@@ -521,13 +547,25 @@ class UserPlaybookStoreMixin:
521
547
  user_id: str,
522
548
  user_playbook_ids: list[int],
523
549
  status_filter: list[Status | None] | None = None,
550
+ *,
551
+ include_inactive: bool = False,
524
552
  ) -> list[UserPlaybook]:
553
+ validate_include_inactive(
554
+ include_inactive=include_inactive, status_filter=status_filter
555
+ )
525
556
  if not user_playbook_ids:
526
557
  return []
558
+ ph = ",".join("?" for _ in user_playbook_ids)
559
+ if include_inactive:
560
+ rows = self._fetchall(
561
+ "SELECT * FROM user_playbooks "
562
+ f"WHERE user_id = ? AND user_playbook_id IN ({ph})",
563
+ (user_id, *user_playbook_ids),
564
+ )
565
+ return [_row_to_user_playbook(r) for r in rows]
527
566
  if status_filter is None:
528
567
  status_filter = [None]
529
568
  frag, sparams = _build_status_sql(status_filter)
530
- ph = ",".join("?" for _ in user_playbook_ids)
531
569
  sql = (
532
570
  f"SELECT * FROM user_playbooks "
533
571
  f"WHERE user_id = ? AND user_playbook_id IN ({ph}) AND {frag}"