claude-smart 0.2.46 → 0.2.48

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 (170) hide show
  1. package/.claude-plugin/marketplace.json +1 -1
  2. package/README.md +19 -11
  3. package/bin/claude-smart.js +290 -68
  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/README.md +11 -10
  8. package/plugin/dashboard/app/layout.tsx +20 -0
  9. package/plugin/dashboard/app/sessions/[sessionId]/page.tsx +36 -2
  10. package/plugin/dashboard/package-lock.json +61 -390
  11. package/plugin/dashboard/package.json +2 -2
  12. package/plugin/opencode/dist/server.mjs +76 -2
  13. package/plugin/opencode/server.mts +79 -2
  14. package/plugin/pyproject.toml +6 -2
  15. package/plugin/scripts/smart-install.sh +7 -1
  16. package/plugin/src/claude_smart/cli.py +210 -22
  17. package/plugin/src/claude_smart/context_format.py +9 -9
  18. package/plugin/src/claude_smart/cs_cite.py +66 -19
  19. package/plugin/uv.lock +5 -5
  20. package/plugin/vendor/reflexio/.env.example +7 -0
  21. package/plugin/vendor/reflexio/README.md +3 -3
  22. package/plugin/vendor/reflexio/pyproject.toml +2 -1
  23. package/plugin/vendor/reflexio/reflexio/README.md +11 -6
  24. package/plugin/vendor/reflexio/reflexio/cli/bootstrap_config.py +1 -1
  25. package/plugin/vendor/reflexio/reflexio/cli/commands/setup_cmd.py +2 -2
  26. package/plugin/vendor/reflexio/reflexio/cli/utils.py +44 -1
  27. package/plugin/vendor/reflexio/reflexio/client/client.py +97 -0
  28. package/plugin/vendor/reflexio/reflexio/lib/_agent_playbook.py +8 -0
  29. package/plugin/vendor/reflexio/reflexio/lib/_base.py +15 -0
  30. package/plugin/vendor/reflexio/reflexio/lib/_config.py +23 -18
  31. package/plugin/vendor/reflexio/reflexio/lib/_generation.py +9 -8
  32. package/plugin/vendor/reflexio/reflexio/lib/_interactions.py +16 -1
  33. package/plugin/vendor/reflexio/reflexio/lib/_profiles.py +27 -16
  34. package/plugin/vendor/reflexio/reflexio/lib/_search.py +27 -5
  35. package/plugin/vendor/reflexio/reflexio/lib/_user_playbook.py +9 -0
  36. package/plugin/vendor/reflexio/reflexio/models/api_schema/domain/__init__.py +1 -0
  37. package/plugin/vendor/reflexio/reflexio/models/api_schema/domain/enums.py +1 -0
  38. package/plugin/vendor/reflexio/reflexio/models/api_schema/domain/governance.py +117 -0
  39. package/plugin/vendor/reflexio/reflexio/models/api_schema/retriever_schema.py +45 -3
  40. package/plugin/vendor/reflexio/reflexio/models/config_schema.py +37 -0
  41. package/plugin/vendor/reflexio/reflexio/server/README.md +38 -9
  42. package/plugin/vendor/reflexio/reflexio/server/__init__.py +21 -2
  43. package/plugin/vendor/reflexio/reflexio/server/api.py +274 -3267
  44. package/plugin/vendor/reflexio/reflexio/server/api_endpoints/README.md +4 -3
  45. package/plugin/vendor/reflexio/reflexio/server/api_endpoints/publisher_api.py +16 -1
  46. package/plugin/vendor/reflexio/reflexio/server/api_endpoints/request_context.py +1 -1
  47. package/plugin/vendor/reflexio/reflexio/server/{_auth.py → auth.py} +2 -0
  48. package/plugin/vendor/reflexio/reflexio/server/cache/reflexio_cache.py +62 -36
  49. package/plugin/vendor/reflexio/reflexio/server/deployment_profile.py +69 -0
  50. package/plugin/vendor/reflexio/reflexio/server/extensions.py +213 -0
  51. package/plugin/vendor/reflexio/reflexio/server/llm/_litellm_embedding.py +424 -0
  52. package/plugin/vendor/reflexio/reflexio/server/llm/_litellm_json_extraction.py +249 -0
  53. package/plugin/vendor/reflexio/reflexio/server/llm/_litellm_structured_output.py +195 -0
  54. package/plugin/vendor/reflexio/reflexio/server/llm/_litellm_subprocess.py +152 -0
  55. package/plugin/vendor/reflexio/reflexio/server/llm/_litellm_text_generation.py +980 -0
  56. package/plugin/vendor/reflexio/reflexio/server/llm/_litellm_types.py +110 -0
  57. package/plugin/vendor/reflexio/reflexio/server/llm/litellm_client.py +73 -1819
  58. package/plugin/vendor/reflexio/reflexio/server/llm/model_defaults.py +4 -4
  59. package/plugin/vendor/reflexio/reflexio/server/llm/providers/claude_code_provider.py +57 -5
  60. package/plugin/vendor/reflexio/reflexio/server/llm/rerank/cross_encoder_reranker.py +12 -1
  61. package/plugin/vendor/reflexio/reflexio/server/middleware.py +244 -0
  62. package/plugin/vendor/reflexio/reflexio/server/operation_limiter.py +9 -1
  63. package/plugin/vendor/reflexio/reflexio/server/prompt/prompt_bank/playbook_extraction_context/v4.4.0.prompt.md +14 -2
  64. package/plugin/vendor/reflexio/reflexio/server/rate_limit.py +79 -0
  65. package/plugin/vendor/reflexio/reflexio/server/routes/__init__.py +6 -0
  66. package/plugin/vendor/reflexio/reflexio/server/routes/_common.py +25 -0
  67. package/plugin/vendor/reflexio/reflexio/server/routes/_metering.py +98 -0
  68. package/plugin/vendor/reflexio/reflexio/server/routes/braintrust.py +129 -0
  69. package/plugin/vendor/reflexio/reflexio/server/routes/config.py +210 -0
  70. package/plugin/vendor/reflexio/reflexio/server/routes/evaluation.py +549 -0
  71. package/plugin/vendor/reflexio/reflexio/server/routes/interactions.py +259 -0
  72. package/plugin/vendor/reflexio/reflexio/server/routes/playbooks.py +578 -0
  73. package/plugin/vendor/reflexio/reflexio/server/routes/profiles.py +423 -0
  74. package/plugin/vendor/reflexio/reflexio/server/routes/provenance.py +345 -0
  75. package/plugin/vendor/reflexio/reflexio/server/routes/search.py +349 -0
  76. package/plugin/vendor/reflexio/reflexio/server/routes/system.py +261 -0
  77. package/plugin/vendor/reflexio/reflexio/server/scheduling.py +132 -0
  78. package/plugin/vendor/reflexio/reflexio/server/services/README.md +5 -2
  79. package/plugin/vendor/reflexio/reflexio/server/services/base_generation/__init__.py +38 -0
  80. package/plugin/vendor/reflexio/reflexio/server/services/base_generation/_batch_progress.py +298 -0
  81. package/plugin/vendor/reflexio/reflexio/server/services/base_generation/_config_filter.py +152 -0
  82. package/plugin/vendor/reflexio/reflexio/server/services/base_generation/_extraction_lifecycle.py +243 -0
  83. package/plugin/vendor/reflexio/reflexio/server/services/base_generation/_should_run.py +299 -0
  84. package/plugin/vendor/reflexio/reflexio/server/services/base_generation/_status_change.py +273 -0
  85. package/plugin/vendor/reflexio/reflexio/server/services/base_generation/_usage_billing.py +258 -0
  86. package/plugin/vendor/reflexio/reflexio/server/services/base_generation_service.py +17 -1183
  87. package/plugin/vendor/reflexio/reflexio/server/services/deduplication_utils.py +8 -1
  88. package/plugin/vendor/reflexio/reflexio/server/services/extraction/resume_scheduler.py +26 -41
  89. package/plugin/vendor/reflexio/reflexio/server/services/extraction/resume_worker.py +8 -0
  90. package/plugin/vendor/reflexio/reflexio/server/services/generation_service.py +232 -123
  91. package/plugin/vendor/reflexio/reflexio/server/services/governance/config.py +52 -0
  92. package/plugin/vendor/reflexio/reflexio/server/services/governance/service.py +378 -0
  93. package/plugin/vendor/reflexio/reflexio/server/services/governance/subject_refs.py +34 -0
  94. package/plugin/vendor/reflexio/reflexio/server/services/lineage/gc_scheduler.py +385 -78
  95. package/plugin/vendor/reflexio/reflexio/server/services/playbook/README.md +9 -1
  96. package/plugin/vendor/reflexio/reflexio/server/services/playbook/aggregation_prompt_processing.py +100 -0
  97. package/plugin/vendor/reflexio/reflexio/server/services/playbook/components/aggregator.py +121 -525
  98. package/plugin/vendor/reflexio/reflexio/server/services/playbook/components/aggregator_clustering.py +184 -0
  99. package/plugin/vendor/reflexio/reflexio/server/services/playbook/components/aggregator_postprocessing.py +130 -0
  100. package/plugin/vendor/reflexio/reflexio/server/services/playbook/components/aggregator_prompt_formatting.py +212 -0
  101. package/plugin/vendor/reflexio/reflexio/server/services/playbook/components/consolidator.py +258 -69
  102. package/plugin/vendor/reflexio/reflexio/server/services/playbook/service.py +9 -9
  103. package/plugin/vendor/reflexio/reflexio/server/services/profile/components/extractor.py +3 -2
  104. package/plugin/vendor/reflexio/reflexio/server/services/publish_learning_worker.py +288 -0
  105. package/plugin/vendor/reflexio/reflexio/server/services/retrieval/recency.py +211 -0
  106. package/plugin/vendor/reflexio/reflexio/server/services/retrieval/relevance_floor.py +29 -13
  107. package/plugin/vendor/reflexio/reflexio/server/services/storage/error.py +4 -0
  108. package/plugin/vendor/reflexio/reflexio/server/services/storage/governance_validation.py +681 -0
  109. package/plugin/vendor/reflexio/reflexio/server/services/storage/sqlite_storage/__init__.py +43 -6
  110. package/plugin/vendor/reflexio/reflexio/server/services/storage/sqlite_storage/_agent_run.py +10 -1167
  111. package/plugin/vendor/reflexio/reflexio/server/services/storage/sqlite_storage/_base.py +58 -351
  112. package/plugin/vendor/reflexio/reflexio/server/services/storage/sqlite_storage/_extras.py +49 -19
  113. package/plugin/vendor/reflexio/reflexio/server/services/storage/sqlite_storage/_governance.py +452 -0
  114. package/plugin/vendor/reflexio/reflexio/server/services/storage/sqlite_storage/_lineage.py +11 -4
  115. package/plugin/vendor/reflexio/reflexio/server/services/storage/sqlite_storage/_playbook.py +1 -2133
  116. package/plugin/vendor/reflexio/reflexio/server/services/storage/sqlite_storage/_profiles.py +7 -1126
  117. package/plugin/vendor/reflexio/reflexio/server/services/storage/sqlite_storage/_requests.py +73 -33
  118. package/plugin/vendor/reflexio/reflexio/server/services/storage/sqlite_storage/_share_links.py +30 -0
  119. package/plugin/vendor/reflexio/reflexio/server/services/storage/sqlite_storage/agent_run/__init__.py +9 -0
  120. package/plugin/vendor/reflexio/reflexio/server/services/storage/sqlite_storage/agent_run/_agent_run_store.py +506 -0
  121. package/plugin/vendor/reflexio/reflexio/server/services/storage/sqlite_storage/agent_run/_pending_tool_call_store.py +704 -0
  122. package/plugin/vendor/reflexio/reflexio/server/services/storage/sqlite_storage/agent_run/_run_tool_dependency_store.py +123 -0
  123. package/plugin/vendor/reflexio/reflexio/server/services/storage/sqlite_storage/base/__init__.py +6 -0
  124. package/plugin/vendor/reflexio/reflexio/server/services/storage/sqlite_storage/base/_deletion.py +263 -0
  125. package/plugin/vendor/reflexio/reflexio/server/services/storage/sqlite_storage/base/_fts_vec.py +132 -0
  126. package/plugin/vendor/reflexio/reflexio/server/services/storage/sqlite_storage/governance/__init__.py +13 -0
  127. package/plugin/vendor/reflexio/reflexio/server/services/storage/sqlite_storage/governance/_audit.py +122 -0
  128. package/plugin/vendor/reflexio/reflexio/server/services/storage/sqlite_storage/governance/_erase_execution.py +465 -0
  129. package/plugin/vendor/reflexio/reflexio/server/services/storage/sqlite_storage/governance/_purge.py +387 -0
  130. package/plugin/vendor/reflexio/reflexio/server/services/storage/sqlite_storage/governance/_rebuild_hide.py +332 -0
  131. package/plugin/vendor/reflexio/reflexio/server/services/storage/sqlite_storage/governance/_subject_barrier.py +511 -0
  132. package/plugin/vendor/reflexio/reflexio/server/services/storage/sqlite_storage/playbook/__init__.py +13 -0
  133. package/plugin/vendor/reflexio/reflexio/server/services/storage/sqlite_storage/playbook/_agent.py +955 -0
  134. package/plugin/vendor/reflexio/reflexio/server/services/storage/sqlite_storage/playbook/_eval_results.py +189 -0
  135. package/plugin/vendor/reflexio/reflexio/server/services/storage/sqlite_storage/playbook/_optimization.py +247 -0
  136. package/plugin/vendor/reflexio/reflexio/server/services/storage/sqlite_storage/playbook/_source_linkage.py +145 -0
  137. package/plugin/vendor/reflexio/reflexio/server/services/storage/sqlite_storage/playbook/_user.py +844 -0
  138. package/plugin/vendor/reflexio/reflexio/server/services/storage/sqlite_storage/profiles/__init__.py +9 -0
  139. package/plugin/vendor/reflexio/reflexio/server/services/storage/sqlite_storage/profiles/_interaction_store.py +263 -0
  140. package/plugin/vendor/reflexio/reflexio/server/services/storage/sqlite_storage/profiles/_profile_store.py +896 -0
  141. package/plugin/vendor/reflexio/reflexio/server/services/storage/sqlite_storage/profiles/_search.py +270 -0
  142. package/plugin/vendor/reflexio/reflexio/server/services/storage/storage_base/__init__.py +50 -9
  143. package/plugin/vendor/reflexio/reflexio/server/services/storage/storage_base/_agent_run.py +64 -370
  144. package/plugin/vendor/reflexio/reflexio/server/services/storage/storage_base/_extras.py +4 -4
  145. package/plugin/vendor/reflexio/reflexio/server/services/storage/storage_base/_playbook.py +0 -909
  146. package/plugin/vendor/reflexio/reflexio/server/services/storage/storage_base/_requests.py +2 -0
  147. package/plugin/vendor/reflexio/reflexio/server/services/storage/storage_base/_share_links.py +20 -0
  148. package/plugin/vendor/reflexio/reflexio/server/services/storage/storage_base/agent_run/__init__.py +9 -0
  149. package/plugin/vendor/reflexio/reflexio/server/services/storage/storage_base/agent_run/_agent_run_store.py +86 -0
  150. package/plugin/vendor/reflexio/reflexio/server/services/storage/storage_base/agent_run/_models.py +195 -0
  151. package/plugin/vendor/reflexio/reflexio/server/services/storage/storage_base/agent_run/_pending_tool_call_store.py +148 -0
  152. package/plugin/vendor/reflexio/reflexio/server/services/storage/storage_base/agent_run/_run_tool_dependency_store.py +38 -0
  153. package/plugin/vendor/reflexio/reflexio/server/services/storage/storage_base/governance/__init__.py +13 -0
  154. package/plugin/vendor/reflexio/reflexio/server/services/storage/storage_base/governance/_audit.py +29 -0
  155. package/plugin/vendor/reflexio/reflexio/server/services/storage/storage_base/governance/_erase_execution.py +30 -0
  156. package/plugin/vendor/reflexio/reflexio/server/services/storage/storage_base/governance/_purge.py +74 -0
  157. package/plugin/vendor/reflexio/reflexio/server/services/storage/storage_base/governance/_rebuild_hide.py +32 -0
  158. package/plugin/vendor/reflexio/reflexio/server/services/storage/storage_base/governance/_subject_barrier.py +49 -0
  159. package/plugin/vendor/reflexio/reflexio/server/services/storage/storage_base/playbook/__init__.py +13 -0
  160. package/plugin/vendor/reflexio/reflexio/server/services/storage/storage_base/playbook/_agent.py +365 -0
  161. package/plugin/vendor/reflexio/reflexio/server/services/storage/storage_base/playbook/_eval_results.py +124 -0
  162. package/plugin/vendor/reflexio/reflexio/server/services/storage/storage_base/playbook/_optimization.py +85 -0
  163. package/plugin/vendor/reflexio/reflexio/server/services/storage/storage_base/playbook/_source_linkage.py +47 -0
  164. package/plugin/vendor/reflexio/reflexio/server/services/storage/storage_base/playbook/_user.py +333 -0
  165. package/plugin/vendor/reflexio/reflexio/server/services/storage/storage_base/profiles/__init__.py +9 -0
  166. package/plugin/vendor/reflexio/reflexio/server/services/storage/storage_base/profiles/_interaction_store.py +73 -0
  167. package/plugin/vendor/reflexio/reflexio/server/services/storage/storage_base/{_profiles.py → profiles/_profile_store.py} +57 -86
  168. package/plugin/vendor/reflexio/reflexio/server/services/storage/storage_base/profiles/_search.py +32 -0
  169. package/plugin/vendor/reflexio/reflexio/server/services/unified_search_service.py +153 -12
  170. package/plugin/vendor/reflexio/reflexio/server/services/playbook/user_detail_stripping.py +0 -84
@@ -53,15 +53,11 @@ from reflexio.server.services.storage.error import (
53
53
  StorageError,
54
54
  require_non_empty_session_id,
55
55
  )
56
- from reflexio.server.services.storage.retention import RetentionTarget
57
- from reflexio.server.services.storage.retention_mixin import (
58
- RETENTION_DELETE_CHUNK,
59
- RetentionMixin,
60
- chunked,
61
- )
56
+ from reflexio.server.services.storage.retention_mixin import RetentionMixin
62
57
  from reflexio.server.services.storage.storage_base import BaseStorage
63
58
  from reflexio.server.site_var.site_var_manager import SiteVarManager
64
59
 
60
+ from ._governance import init_governance_tables
65
61
  from ._stall_state import init_stall_state_table
66
62
 
67
63
  logger = logging.getLogger(__name__)
@@ -287,6 +283,42 @@ def _true_rrf_merge(
287
283
  # explicit status_filter on list/count methods.
288
284
  _TOMBSTONE_STATUS_VALUES = (Status.MERGED.value, Status.SUPERSEDED.value)
289
285
 
286
+ # Profile reads also treat EXPIRED (TTL-expired tombstone) as non-current. Kept
287
+ # separate from _TOMBSTONE_STATUS_VALUES because that tuple is shared with playbook
288
+ # queries (which never carry EXPIRED) via hardcoded placeholders.
289
+ _PROFILE_TOMBSTONE_STATUS_VALUES = (
290
+ Status.MERGED.value,
291
+ Status.SUPERSEDED.value,
292
+ Status.EXPIRED.value,
293
+ )
294
+
295
+
296
+ def parse_status(value: str | None) -> Status | None:
297
+ """Parse a stored status string into a Status, tolerating unknown values.
298
+
299
+ Unknown non-null values (e.g. a status written by a newer build after a
300
+ rollback) map to a non-current tombstone sentinel and log an anomaly, rather
301
+ than raising ValueError. Never maps to None (which models CURRENT/live).
302
+
303
+ Args:
304
+ value: Raw status string from the database, or None/empty for CURRENT.
305
+
306
+ Returns:
307
+ The matching Status enum member, Status.SUPERSEDED for unknown values,
308
+ or None for falsy input (representing the CURRENT/live state).
309
+ """
310
+ if not value:
311
+ return None
312
+ try:
313
+ return Status(value)
314
+ except ValueError:
315
+ from reflexio.server.tracing import capture_anomaly
316
+
317
+ capture_anomaly(
318
+ "storage.status.unknown_value", level="warning", status_value=value
319
+ )
320
+ return Status.SUPERSEDED
321
+
290
322
 
291
323
  def _status_value(status: Status | None) -> str | None:
292
324
  """Convert a Status enum (or None) to its DB string value."""
@@ -362,14 +394,19 @@ def _iso_to_epoch(iso_str: str | None) -> int:
362
394
  # stored row) with a valid value.
363
395
  _MAX_SAFE_EPOCH_TS = 253_402_300_799 # 9999-12-31T23:59:59Z
364
396
  _MIN_SAFE_EPOCH_TS = 0 # 1970-01-01T00:00:00Z
397
+ _MIN_CONTEMPORARY_MILLISECOND_EPOCH_TS = 1_500_000_000_000
365
398
 
366
399
 
367
400
  def _epoch_to_iso(ts: int) -> str:
368
401
  """Convert a Unix timestamp (seconds) to an ISO 8601 string.
369
402
 
370
- Out-of-range sentinel bounds are clamped to the representable range so that
371
- callers passing "open" window bounds never trigger a ``ValueError``.
403
+ Plausible contemporary millisecond timestamps are normalized to seconds.
404
+ Lower values stay in seconds-space so documented sentinel bounds such as
405
+ ``to_ts=10**12`` still clamp to the representable range instead of being
406
+ treated as a 2001 millisecond timestamp.
372
407
  """
408
+ if _MIN_CONTEMPORARY_MILLISECOND_EPOCH_TS <= ts <= _MAX_SAFE_EPOCH_TS * 1000:
409
+ ts = ts // 1000
373
410
  clamped = max(_MIN_SAFE_EPOCH_TS, min(ts, _MAX_SAFE_EPOCH_TS))
374
411
  return datetime.fromtimestamp(clamped, tz=UTC).isoformat()
375
412
 
@@ -391,7 +428,7 @@ def _row_to_profile(row: sqlite3.Row) -> UserProfile:
391
428
  expiration_timestamp=d["expiration_timestamp"],
392
429
  custom_features=_json_loads(d.get("custom_features")),
393
430
  source=d.get("source") or "",
394
- status=Status(d["status"]) if d.get("status") else None,
431
+ status=parse_status(d.get("status")),
395
432
  extractor_names=_json_loads(d.get("extractor_names")),
396
433
  expanded_terms=d.get("expanded_terms"),
397
434
  source_span=d.get("source_span"),
@@ -471,7 +508,7 @@ def _row_to_user_playbook(
471
508
  blocking_issue=BlockingIssue(**json.loads(d["blocking_issue"]))
472
509
  if d.get("blocking_issue")
473
510
  else None,
474
- status=Status(d["status"]) if d.get("status") else None,
511
+ status=parse_status(d.get("status")),
475
512
  source=d.get("source"),
476
513
  source_interaction_ids=_json_loads(d.get("source_interaction_ids")) or [],
477
514
  tags=_json_loads(d.get("tags")),
@@ -504,7 +541,7 @@ def _row_to_agent_playbook(row: sqlite3.Row) -> AgentPlaybook:
504
541
  playbook_metadata=d.get("playbook_metadata") or "",
505
542
  tags=_json_loads(d.get("tags")),
506
543
  embedding=[],
507
- status=Status(d["status"]) if d.get("status") else None,
544
+ status=parse_status(d.get("status")),
508
545
  expanded_terms=d.get("expanded_terms"),
509
546
  merged_into=d.get("merged_into"),
510
547
  superseded_by=d.get("superseded_by"),
@@ -545,6 +582,15 @@ class SQLiteStorageBase(RetentionMixin, BaseStorage):
545
582
 
546
583
  supports_embedding: ClassVar[bool] = True
547
584
 
585
+ # Chunked bulk-delete helpers provided by SQLiteDeletionMixin via the
586
+ # composed SQLiteStorage MRO; declared here for clear_user_data's benefit.
587
+ _delete_in_chunks: Any
588
+ _delete_source_windows_for_user_playbook_ids: Any
589
+
590
+ # FTS/vec index helpers provided by SQLiteFtsVecMixin via the composed
591
+ # SQLiteStorage MRO; declared here for _migrate_vec_tables's benefit.
592
+ _vec_upsert: Any
593
+
548
594
  @staticmethod
549
595
  def handle_exceptions(func: Callable[..., Any]) -> Callable[..., Any]:
550
596
  @functools.wraps(func)
@@ -642,6 +688,7 @@ class SQLiteStorageBase(RetentionMixin, BaseStorage):
642
688
  with self._lock:
643
689
  cur = self.conn.cursor()
644
690
  cur.executescript(_DDL)
691
+ init_governance_tables(self.conn)
645
692
  self.conn.commit()
646
693
  if self._has_sqlite_vec:
647
694
  self._create_vec_tables()
@@ -669,239 +716,6 @@ class SQLiteStorageBase(RetentionMixin, BaseStorage):
669
716
  init_stall_state_table(self.conn)
670
717
  return True
671
718
 
672
- # -- Retention hooks (see RetentionMixin) --
673
-
674
- @handle_exceptions
675
- def _retention_table_exists(self, table_name: str) -> bool:
676
- row = self._fetchone(
677
- "SELECT 1 FROM sqlite_master WHERE type = 'table' AND name = ?",
678
- (table_name,),
679
- )
680
- return row is not None
681
-
682
- @handle_exceptions
683
- def _retention_count_rows(self, target: RetentionTarget) -> int:
684
- row = self._fetchone(f"SELECT COUNT(*) as cnt FROM {target.table_name}") # noqa: S608
685
- return int(row["cnt"]) if row else 0
686
-
687
- @handle_exceptions
688
- def _retention_select_oldest_keys(
689
- self, target: RetentionTarget, count: int
690
- ) -> list[tuple[Any, ...]]:
691
- id_sql = ", ".join(target.id_columns)
692
- tiebreak_sql = id_sql
693
- rows = self._fetchall(
694
- f"SELECT {id_sql} FROM {target.table_name} " # noqa: S608
695
- f"ORDER BY {target.order_column} ASC, {tiebreak_sql} ASC LIMIT ?",
696
- (count,),
697
- )
698
- return [tuple(row[col] for col in target.id_columns) for row in rows]
699
-
700
- @handle_exceptions
701
- def _retention_perform_delete(
702
- self, target: RetentionTarget, keys: list[tuple[Any, ...]]
703
- ) -> None:
704
- # Wrap dependency + target deletes in a single critical section so
705
- # concurrent writers see either both or neither.
706
- with self._lock:
707
- try:
708
- self._retention_delete_dependencies(target, keys)
709
- self._retention_delete_target_rows(target, keys)
710
- self.conn.commit()
711
- except Exception:
712
- self.conn.rollback()
713
- raise
714
-
715
- def _retention_delete_dependencies(
716
- self, target: RetentionTarget, keys: list[tuple[Any, ...]]
717
- ) -> None:
718
- ids = [key[0] for key in keys]
719
- target_name = target.name
720
- if target_name == "requests":
721
- self._delete_interactions_for_request_ids([str(v) for v in ids])
722
- elif target_name == "interactions":
723
- self._delete_interaction_search_rows([int(v) for v in ids])
724
- elif target_name == "profiles":
725
- self._delete_profile_search_rows([str(v) for v in ids])
726
- elif target_name == "user_playbooks":
727
- self._delete_source_windows_for_user_playbook_ids([int(v) for v in ids])
728
- self._delete_playbook_search_rows(
729
- "user", [int(v) for v in ids], commit=False
730
- )
731
- elif target_name == "agent_playbooks":
732
- self._delete_source_windows_for_agent_playbook_ids([int(v) for v in ids])
733
- self._delete_playbook_search_rows(
734
- "agent", [int(v) for v in ids], commit=False
735
- )
736
- elif target_name == "playbook_optimization_jobs":
737
- self._delete_optimizer_rows_for_job_ids([int(v) for v in ids])
738
- elif target_name == "playbook_optimization_candidates":
739
- self._delete_optimizer_evaluations_for_candidate_ids([int(v) for v in ids])
740
-
741
- def _retention_delete_target_rows(
742
- self, target: RetentionTarget, keys: list[tuple[Any, ...]]
743
- ) -> None:
744
- if len(target.id_columns) == 1:
745
- self._delete_in_chunks(
746
- target.table_name,
747
- target.id_columns[0],
748
- [key[0] for key in keys],
749
- )
750
- return
751
- # Composite-key delete: chunk by row to bound parameter count.
752
- params_per_key = len(target.id_columns)
753
- rows_per_chunk = max(1, RETENTION_DELETE_CHUNK // params_per_key)
754
- for chunk in chunked(keys, rows_per_chunk):
755
- where = " OR ".join(
756
- "("
757
- + " AND ".join(f"{column} = ?" for column in target.id_columns)
758
- + ")"
759
- for _ in chunk
760
- )
761
- params = [value for key in chunk for value in key]
762
- self.conn.execute(
763
- f"DELETE FROM {target.table_name} WHERE {where}", # noqa: S608
764
- params,
765
- )
766
-
767
- # -- Chunked-delete primitives shared by the cascade helpers --
768
-
769
- def _delete_in_chunks(
770
- self, table_name: str, column_name: str, values: list[Any]
771
- ) -> None:
772
- """Chunked ``DELETE FROM table WHERE col IN (...)``.
773
-
774
- Chunking keeps parameter count under ``SQLITE_MAX_VARIABLE_NUMBER``
775
- on older sqlite builds (default 999) and avoids degenerate plans
776
- on very large IN lists.
777
- """
778
- if not values:
779
- return
780
- for chunk in chunked(values):
781
- placeholders = ",".join("?" for _ in chunk)
782
- self.conn.execute(
783
- f"DELETE FROM {table_name} WHERE {column_name} IN ({placeholders})", # noqa: S608
784
- chunk,
785
- )
786
-
787
- def _select_in_chunks(self, sql_template: str, values: list[Any]) -> list[Any]:
788
- """Run ``sql_template`` (containing ``{placeholders}``) over chunks of
789
- ``values`` and aggregate the result rows."""
790
- results: list[Any] = []
791
- for chunk in chunked(values):
792
- placeholders = ",".join("?" for _ in chunk)
793
- stmt = sql_template.format(placeholders=placeholders)
794
- results.extend(self.conn.execute(stmt, chunk).fetchall())
795
- return results
796
-
797
- def _delete_interactions_for_request_ids(self, request_ids: list[str]) -> None:
798
- if not request_ids:
799
- return
800
- rows = self._select_in_chunks(
801
- "SELECT interaction_id FROM interactions WHERE request_id IN ({placeholders})",
802
- request_ids,
803
- )
804
- self._delete_interaction_search_rows(
805
- [int(row["interaction_id"]) for row in rows]
806
- )
807
- self._delete_in_chunks("interactions", "request_id", request_ids)
808
-
809
- def _delete_interaction_search_rows(self, interaction_ids: list[int]) -> None:
810
- """Remove fts + vec index rows for the given interaction IDs.
811
-
812
- Non-committing: participates in the caller's transaction. Only called
813
- from inside the retention atomic block (_retention_perform_delete).
814
- """
815
- if not interaction_ids:
816
- return
817
- self._delete_in_chunks("interactions_fts", "rowid", interaction_ids)
818
- if self._has_sqlite_vec:
819
- self._delete_in_chunks("interactions_vec", "rowid", interaction_ids)
820
-
821
- def _delete_profile_search_rows(self, profile_ids: list[str]) -> None:
822
- """Remove fts + vec index rows for the given profile IDs.
823
-
824
- Non-committing: participates in the caller's transaction. Only called
825
- from inside the retention atomic block (_retention_perform_delete).
826
- profiles_fts is keyed by profile_id (TEXT); profiles_vec by rowid (INT).
827
- """
828
- if not profile_ids:
829
- return
830
- self._delete_in_chunks("profiles_fts", "profile_id", profile_ids)
831
- if self._has_sqlite_vec:
832
- rows = self._select_in_chunks(
833
- "SELECT rowid FROM profiles WHERE profile_id IN ({placeholders})",
834
- profile_ids,
835
- )
836
- rowids = [row["rowid"] for row in rows]
837
- if rowids:
838
- self._delete_in_chunks("profiles_vec", "rowid", rowids)
839
-
840
- def _delete_playbook_search_rows(
841
- self, kind: str, ids: list[int], *, commit: bool = True
842
- ) -> None:
843
- """Remove fts + vec index rows for the given playbook IDs.
844
-
845
- Args:
846
- kind: ``"user"`` or ``"agent"``.
847
- ids: Playbook row IDs to remove from the search indexes.
848
- commit: When ``True`` (default) commits after the deletes so the
849
- after-commit callers in ``_playbook.py`` get a clean, durable
850
- cleanup. Pass ``commit=False`` from inside the retention atomic
851
- block so the deletes participate in the single block-level commit
852
- (``_retention_perform_delete``).
853
-
854
- Note: callers may already hold ``self._lock`` when calling this (the
855
- ``commit=False`` retention/atomic-delete call sites do). The internal
856
- ``with self._lock:`` re-acquire is safe ONLY because ``self._lock`` is a
857
- reentrant ``threading.RLock``; a non-reentrant lock would deadlock here.
858
- """
859
- if not ids:
860
- return
861
- with self._lock:
862
- self._delete_in_chunks(f"{kind}_playbooks_fts", "rowid", ids)
863
- if self._has_sqlite_vec:
864
- self._delete_in_chunks(f"{kind}_playbooks_vec", "rowid", ids)
865
- if commit:
866
- self.conn.commit()
867
-
868
- def _delete_source_windows_for_agent_playbook_ids(
869
- self, agent_playbook_ids: list[int]
870
- ) -> None:
871
- self._delete_in_chunks(
872
- "agent_playbook_source_user_playbooks",
873
- "agent_playbook_id",
874
- agent_playbook_ids,
875
- )
876
-
877
- def _delete_source_windows_for_user_playbook_ids(
878
- self, user_playbook_ids: list[int]
879
- ) -> None:
880
- self._delete_in_chunks(
881
- "agent_playbook_source_user_playbooks",
882
- "user_playbook_id",
883
- user_playbook_ids,
884
- )
885
-
886
- def _delete_optimizer_rows_for_job_ids(self, job_ids: list[int]) -> None:
887
- if not job_ids:
888
- return
889
- for table in (
890
- "playbook_optimization_evaluations",
891
- "playbook_optimization_events",
892
- "playbook_optimization_candidates",
893
- ):
894
- self._delete_in_chunks(table, "job_id", job_ids)
895
-
896
- def _delete_optimizer_evaluations_for_candidate_ids(
897
- self, candidate_ids: list[int]
898
- ) -> None:
899
- self._delete_in_chunks(
900
- "playbook_optimization_evaluations",
901
- "candidate_id",
902
- candidate_ids,
903
- )
904
-
905
719
  def _try_load_sqlite_vec(self) -> bool:
906
720
  """Attempt to load the sqlite-vec extension for native KNN search.
907
721
 
@@ -1715,113 +1529,6 @@ class SQLiteStorageBase(RetentionMixin, BaseStorage):
1715
1529
  def _current_timestamp(self) -> str:
1716
1530
  return datetime.now(UTC).isoformat()
1717
1531
 
1718
- # FTS helpers
1719
- def _fts_upsert(self, table: str, rowid: int, **text_fields: str | None) -> None:
1720
- """Insert or update an FTS row. Deletes old entry first to avoid duplicates."""
1721
- with self._lock:
1722
- self.conn.execute(f"DELETE FROM {table} WHERE rowid = ?", (rowid,))
1723
- cols = list(text_fields.keys())
1724
- vals = [text_fields[c] or "" for c in cols]
1725
- placeholders = ",".join("?" for _ in cols)
1726
- col_str = ",".join(cols)
1727
- self.conn.execute(
1728
- f"INSERT INTO {table}(rowid, {col_str}) VALUES (?, {placeholders})",
1729
- [rowid, *vals],
1730
- )
1731
- self.conn.commit()
1732
-
1733
- def _fts_delete(self, table: str, rowid: int) -> None:
1734
- with self._lock:
1735
- self.conn.execute(f"DELETE FROM {table} WHERE rowid = ?", (rowid,))
1736
- self.conn.commit()
1737
-
1738
- def _fts_upsert_profile(self, profile_id: str, content: str) -> None:
1739
- """FTS for profiles uses profile_id TEXT as key column."""
1740
- with self._lock:
1741
- self.conn.execute(
1742
- "DELETE FROM profiles_fts WHERE profile_id = ?", (profile_id,)
1743
- )
1744
- self.conn.execute(
1745
- "INSERT INTO profiles_fts(profile_id, content) VALUES (?, ?)",
1746
- (profile_id, content),
1747
- )
1748
- self.conn.commit()
1749
-
1750
- def _fts_delete_profile(self, profile_id: str) -> None:
1751
- with self._lock:
1752
- self.conn.execute(
1753
- "DELETE FROM profiles_fts WHERE profile_id = ?", (profile_id,)
1754
- )
1755
- self.conn.commit()
1756
-
1757
- # Vec helpers (sqlite-vec)
1758
- def _vec_upsert(self, table: str, rowid: int, embedding: list[float]) -> None:
1759
- """Insert or update a vec table row. No-op when sqlite-vec is unavailable."""
1760
- if not self._has_sqlite_vec:
1761
- return
1762
- with self._lock:
1763
- self.conn.execute(f"DELETE FROM {table} WHERE rowid = ?", (rowid,))
1764
- self.conn.execute(
1765
- f"INSERT INTO {table}(rowid, embedding) VALUES (?, ?)",
1766
- (rowid, json.dumps(embedding)),
1767
- )
1768
- self.conn.commit()
1769
-
1770
- def _vec_delete(self, table: str, rowid: int) -> None:
1771
- """Delete a vec table row. No-op when sqlite-vec is unavailable."""
1772
- if not self._has_sqlite_vec:
1773
- return
1774
- with self._lock:
1775
- self.conn.execute(f"DELETE FROM {table} WHERE rowid = ?", (rowid,))
1776
- self.conn.commit()
1777
-
1778
- def _vec_knn_search(
1779
- self,
1780
- vec_table: str,
1781
- main_table: str,
1782
- query_embedding: list[float],
1783
- match_count: int,
1784
- conditions: list[str] | None = None,
1785
- params: list[Any] | None = None,
1786
- ) -> list[sqlite3.Row]:
1787
- """Run a native KNN search via sqlite-vec and join back to the main table.
1788
-
1789
- Over-fetches from the KNN index (5x ``match_count``) so that post-filter
1790
- WHERE conditions (org, user, status, etc.) don't silently reduce the
1791
- result set below the requested count.
1792
-
1793
- Args:
1794
- vec_table: Name of the vec0 virtual table.
1795
- main_table: Name of the main data table.
1796
- query_embedding: Query embedding vector.
1797
- match_count: Number of results to return.
1798
- conditions: Optional WHERE conditions for the main table.
1799
- params: Parameters for the conditions.
1800
-
1801
- Returns:
1802
- Up to ``match_count`` rows from the main table, ordered by vector
1803
- distance (ascending).
1804
- """
1805
- knn_overfetch = match_count * 5
1806
- where_clause = " AND ".join(conditions) if conditions else "1=1"
1807
- sql = f"""SELECT m.* FROM {main_table} m
1808
- JOIN (
1809
- SELECT rowid, distance FROM {vec_table}
1810
- WHERE embedding MATCH ?
1811
- ORDER BY distance
1812
- LIMIT ?
1813
- ) v ON m.rowid = v.rowid
1814
- WHERE {where_clause}
1815
- ORDER BY v.distance
1816
- LIMIT ?"""
1817
- all_params = [
1818
- json.dumps(query_embedding),
1819
- knn_overfetch,
1820
- *(params or []),
1821
- match_count,
1822
- ]
1823
- return self._fetchall(sql, all_params)
1824
-
1825
1532
  # ------------------------------------------------------------------
1826
1533
  # Per-user data clear
1827
1534
  # ------------------------------------------------------------------
@@ -2,6 +2,7 @@
2
2
 
3
3
  import sqlite3
4
4
  from collections import defaultdict
5
+ from datetime import UTC, datetime
5
6
  from typing import Any, Literal, cast
6
7
 
7
8
  from reflexio.models.api_schema.braintrust_schema import (
@@ -179,31 +180,60 @@ class ExtrasMixin:
179
180
  (current_start_iso, current_time_iso),
180
181
  )
181
182
 
183
+ def day_bucket(timestamp: int) -> int:
184
+ return int(
185
+ datetime.fromtimestamp(timestamp, tz=UTC)
186
+ .replace(hour=0, minute=0, second=0, microsecond=0)
187
+ .timestamp()
188
+ )
189
+
190
+ def count_series(timestamps: list[int]) -> list[dict[str, int]]:
191
+ buckets: dict[int, int] = defaultdict(int)
192
+ for timestamp in timestamps:
193
+ buckets[day_bucket(timestamp)] += 1
194
+ return [
195
+ {"timestamp": timestamp, "value": value}
196
+ for timestamp, value in sorted(buckets.items())
197
+ ]
198
+
199
+ def rate_series(rows: list[sqlite3.Row]) -> list[dict[str, int | float]]:
200
+ buckets: dict[int, dict[str, int]] = defaultdict(
201
+ lambda: {"success": 0, "count": 0}
202
+ )
203
+ for row in rows:
204
+ bucket = day_bucket(_iso_to_epoch(row["created_at"]))
205
+ buckets[bucket]["count"] += 1
206
+ if row["is_success"]:
207
+ buckets[bucket]["success"] += 1
208
+ return [
209
+ {
210
+ "timestamp": timestamp,
211
+ "value": (
212
+ 100.0 * aggregate["success"] / aggregate["count"]
213
+ if aggregate["count"]
214
+ else 0.0
215
+ ),
216
+ "count": aggregate["count"],
217
+ }
218
+ for timestamp, aggregate in sorted(buckets.items())
219
+ ]
220
+
182
221
  return {
183
222
  "current_period": current_stats,
184
223
  "previous_period": previous_stats,
185
- "interactions_time_series": [
186
- {"timestamp": _iso_to_epoch(r["created_at"]), "value": 1}
187
- for r in interactions_ts
188
- ],
189
- "profiles_time_series": [
190
- {"timestamp": r["last_modified_timestamp"], "value": 1}
191
- for r in profiles_ts
192
- ],
193
- "playbooks_time_series": sorted(
224
+ "interactions_time_series": count_series(
225
+ [_iso_to_epoch(r["created_at"]) for r in interactions_ts]
226
+ ),
227
+ "profiles_time_series": count_series(
228
+ [r["last_modified_timestamp"] for r in profiles_ts]
229
+ ),
230
+ "playbooks_time_series": count_series(
194
231
  [
195
- {"timestamp": _iso_to_epoch(r["created_at"]), "value": 1}
232
+ _iso_to_epoch(r["created_at"])
196
233
  for r in (*user_playbooks_ts, *agent_playbooks_ts)
197
- ],
198
- key=lambda x: x["timestamp"],
234
+ ]
199
235
  ),
200
- "evaluations_time_series": [
201
- {
202
- "timestamp": _iso_to_epoch(r["created_at"]),
203
- "value": 100 if r["is_success"] else 0,
204
- }
205
- for r in evals_ts
206
- ],
236
+ "evaluations_time_series": rate_series(evals_ts),
207
237
  }
208
238
 
209
239
  @SQLiteStorageBase.handle_exceptions