claude-smart 0.2.47 → 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 (139) 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/dashboard/app/sessions/[sessionId]/page.tsx +36 -2
  8. package/plugin/dashboard/package-lock.json +61 -390
  9. package/plugin/dashboard/package.json +2 -2
  10. package/plugin/opencode/dist/server.mjs +60 -2
  11. package/plugin/opencode/server.mts +64 -2
  12. package/plugin/pyproject.toml +2 -2
  13. package/plugin/scripts/_lib.sh +58 -6
  14. package/plugin/scripts/backend-service.sh +15 -10
  15. package/plugin/scripts/codex-hook.js +16 -4
  16. package/plugin/scripts/dashboard-service.sh +1 -1
  17. package/plugin/scripts/ensure-plugin-root.sh +106 -8
  18. package/plugin/scripts/opencode-claude-compat.js +8 -1
  19. package/plugin/scripts/smart-install.sh +101 -20
  20. package/plugin/src/README.md +1 -1
  21. package/plugin/src/claude_smart/cli.py +79 -29
  22. package/plugin/src/claude_smart/env_config.py +53 -11
  23. package/plugin/uv.lock +5 -5
  24. package/plugin/vendor/reflexio/.env.example +7 -0
  25. package/plugin/vendor/reflexio/pyproject.toml +2 -1
  26. package/plugin/vendor/reflexio/reflexio/README.md +8 -5
  27. package/plugin/vendor/reflexio/reflexio/cli/README.md +3 -0
  28. package/plugin/vendor/reflexio/reflexio/client/client.py +40 -6
  29. package/plugin/vendor/reflexio/reflexio/lib/_config.py +23 -18
  30. package/plugin/vendor/reflexio/reflexio/lib/_interactions.py +16 -1
  31. package/plugin/vendor/reflexio/reflexio/lib/_search.py +15 -11
  32. package/plugin/vendor/reflexio/reflexio/models/api_schema/domain/entities.py +18 -0
  33. package/plugin/vendor/reflexio/reflexio/models/api_schema/domain/enums.py +1 -0
  34. package/plugin/vendor/reflexio/reflexio/models/config_schema.py +24 -3
  35. package/plugin/vendor/reflexio/reflexio/server/README.md +25 -8
  36. package/plugin/vendor/reflexio/reflexio/server/__init__.py +21 -2
  37. package/plugin/vendor/reflexio/reflexio/server/api.py +148 -3277
  38. package/plugin/vendor/reflexio/reflexio/server/api_endpoints/README.md +4 -3
  39. package/plugin/vendor/reflexio/reflexio/server/api_endpoints/publisher_api.py +16 -1
  40. package/plugin/vendor/reflexio/reflexio/server/cache/reflexio_cache.py +62 -36
  41. package/plugin/vendor/reflexio/reflexio/server/deployment_profile.py +69 -0
  42. package/plugin/vendor/reflexio/reflexio/server/llm/_litellm_embedding.py +424 -0
  43. package/plugin/vendor/reflexio/reflexio/server/llm/_litellm_json_extraction.py +249 -0
  44. package/plugin/vendor/reflexio/reflexio/server/llm/_litellm_structured_output.py +195 -0
  45. package/plugin/vendor/reflexio/reflexio/server/llm/_litellm_subprocess.py +152 -0
  46. package/plugin/vendor/reflexio/reflexio/server/llm/_litellm_text_generation.py +980 -0
  47. package/plugin/vendor/reflexio/reflexio/server/llm/_litellm_types.py +110 -0
  48. package/plugin/vendor/reflexio/reflexio/server/llm/litellm_client.py +72 -1817
  49. package/plugin/vendor/reflexio/reflexio/server/llm/providers/claude_code_provider.py +56 -4
  50. package/plugin/vendor/reflexio/reflexio/server/llm/providers/local_embedding_provider.py +183 -1
  51. package/plugin/vendor/reflexio/reflexio/server/middleware.py +244 -0
  52. package/plugin/vendor/reflexio/reflexio/server/operation_limiter.py +9 -1
  53. package/plugin/vendor/reflexio/reflexio/server/rate_limit.py +79 -0
  54. package/plugin/vendor/reflexio/reflexio/server/routes/__init__.py +6 -0
  55. package/plugin/vendor/reflexio/reflexio/server/routes/_common.py +25 -0
  56. package/plugin/vendor/reflexio/reflexio/server/routes/_metering.py +98 -0
  57. package/plugin/vendor/reflexio/reflexio/server/routes/braintrust.py +129 -0
  58. package/plugin/vendor/reflexio/reflexio/server/routes/config.py +210 -0
  59. package/plugin/vendor/reflexio/reflexio/server/routes/evaluation.py +549 -0
  60. package/plugin/vendor/reflexio/reflexio/server/routes/interactions.py +320 -0
  61. package/plugin/vendor/reflexio/reflexio/server/routes/playbooks.py +578 -0
  62. package/plugin/vendor/reflexio/reflexio/server/routes/profiles.py +423 -0
  63. package/plugin/vendor/reflexio/reflexio/server/routes/provenance.py +345 -0
  64. package/plugin/vendor/reflexio/reflexio/server/routes/search.py +349 -0
  65. package/plugin/vendor/reflexio/reflexio/server/routes/system.py +261 -0
  66. package/plugin/vendor/reflexio/reflexio/server/scheduling.py +132 -0
  67. package/plugin/vendor/reflexio/reflexio/server/services/README.md +3 -2
  68. package/plugin/vendor/reflexio/reflexio/server/services/base_generation/__init__.py +38 -0
  69. package/plugin/vendor/reflexio/reflexio/server/services/base_generation/_batch_progress.py +298 -0
  70. package/plugin/vendor/reflexio/reflexio/server/services/base_generation/_config_filter.py +152 -0
  71. package/plugin/vendor/reflexio/reflexio/server/services/base_generation/_extraction_lifecycle.py +243 -0
  72. package/plugin/vendor/reflexio/reflexio/server/services/base_generation/_should_run.py +299 -0
  73. package/plugin/vendor/reflexio/reflexio/server/services/base_generation/_status_change.py +273 -0
  74. package/plugin/vendor/reflexio/reflexio/server/services/base_generation/_usage_billing.py +258 -0
  75. package/plugin/vendor/reflexio/reflexio/server/services/base_generation_service.py +17 -1183
  76. package/plugin/vendor/reflexio/reflexio/server/services/deduplication_utils.py +8 -1
  77. package/plugin/vendor/reflexio/reflexio/server/services/durable_learning/__init__.py +13 -0
  78. package/plugin/vendor/reflexio/reflexio/server/services/durable_learning/scheduler.py +142 -0
  79. package/plugin/vendor/reflexio/reflexio/server/services/durable_learning/worker.py +193 -0
  80. package/plugin/vendor/reflexio/reflexio/server/services/extraction/resume_scheduler.py +24 -41
  81. package/plugin/vendor/reflexio/reflexio/server/services/generation_service.py +335 -113
  82. package/plugin/vendor/reflexio/reflexio/server/services/lineage/gc_scheduler.py +362 -81
  83. package/plugin/vendor/reflexio/reflexio/server/services/playbook/components/aggregator.py +68 -490
  84. package/plugin/vendor/reflexio/reflexio/server/services/playbook/components/aggregator_clustering.py +184 -0
  85. package/plugin/vendor/reflexio/reflexio/server/services/playbook/components/aggregator_postprocessing.py +130 -0
  86. package/plugin/vendor/reflexio/reflexio/server/services/playbook/components/aggregator_prompt_formatting.py +212 -0
  87. package/plugin/vendor/reflexio/reflexio/server/services/playbook/components/consolidator.py +258 -69
  88. package/plugin/vendor/reflexio/reflexio/server/services/profile/service.py +44 -22
  89. package/plugin/vendor/reflexio/reflexio/server/services/publish_learning_worker.py +288 -0
  90. package/plugin/vendor/reflexio/reflexio/server/services/storage/sqlite_storage/__init__.py +31 -4
  91. package/plugin/vendor/reflexio/reflexio/server/services/storage/sqlite_storage/_agent_run.py +10 -1167
  92. package/plugin/vendor/reflexio/reflexio/server/services/storage/sqlite_storage/_base.py +196 -353
  93. package/plugin/vendor/reflexio/reflexio/server/services/storage/sqlite_storage/_governance.py +0 -1513
  94. package/plugin/vendor/reflexio/reflexio/server/services/storage/sqlite_storage/_learning_jobs.py +379 -0
  95. package/plugin/vendor/reflexio/reflexio/server/services/storage/sqlite_storage/_lineage.py +17 -6
  96. package/plugin/vendor/reflexio/reflexio/server/services/storage/sqlite_storage/_profiles.py +7 -1281
  97. package/plugin/vendor/reflexio/reflexio/server/services/storage/sqlite_storage/_requests.py +8 -3
  98. package/plugin/vendor/reflexio/reflexio/server/services/storage/sqlite_storage/_share_links.py +30 -0
  99. package/plugin/vendor/reflexio/reflexio/server/services/storage/sqlite_storage/agent_run/__init__.py +9 -0
  100. package/plugin/vendor/reflexio/reflexio/server/services/storage/sqlite_storage/agent_run/_agent_run_store.py +506 -0
  101. package/plugin/vendor/reflexio/reflexio/server/services/storage/sqlite_storage/agent_run/_pending_tool_call_store.py +704 -0
  102. package/plugin/vendor/reflexio/reflexio/server/services/storage/sqlite_storage/agent_run/_run_tool_dependency_store.py +123 -0
  103. package/plugin/vendor/reflexio/reflexio/server/services/storage/sqlite_storage/base/__init__.py +6 -0
  104. package/plugin/vendor/reflexio/reflexio/server/services/storage/sqlite_storage/base/_deletion.py +263 -0
  105. package/plugin/vendor/reflexio/reflexio/server/services/storage/sqlite_storage/base/_fts_vec.py +216 -0
  106. package/plugin/vendor/reflexio/reflexio/server/services/storage/sqlite_storage/governance/__init__.py +13 -0
  107. package/plugin/vendor/reflexio/reflexio/server/services/storage/sqlite_storage/governance/_audit.py +122 -0
  108. package/plugin/vendor/reflexio/reflexio/server/services/storage/sqlite_storage/governance/_erase_execution.py +465 -0
  109. package/plugin/vendor/reflexio/reflexio/server/services/storage/sqlite_storage/governance/_purge.py +387 -0
  110. package/plugin/vendor/reflexio/reflexio/server/services/storage/sqlite_storage/governance/_rebuild_hide.py +332 -0
  111. package/plugin/vendor/reflexio/reflexio/server/services/storage/sqlite_storage/governance/_subject_barrier.py +511 -0
  112. package/plugin/vendor/reflexio/reflexio/server/services/storage/sqlite_storage/playbook/_agent.py +22 -10
  113. package/plugin/vendor/reflexio/reflexio/server/services/storage/sqlite_storage/playbook/_source_linkage.py +8 -3
  114. package/plugin/vendor/reflexio/reflexio/server/services/storage/sqlite_storage/playbook/_user.py +25 -12
  115. package/plugin/vendor/reflexio/reflexio/server/services/storage/sqlite_storage/profiles/__init__.py +9 -0
  116. package/plugin/vendor/reflexio/reflexio/server/services/storage/sqlite_storage/profiles/_interaction_store.py +308 -0
  117. package/plugin/vendor/reflexio/reflexio/server/services/storage/sqlite_storage/profiles/_profile_store.py +920 -0
  118. package/plugin/vendor/reflexio/reflexio/server/services/storage/sqlite_storage/profiles/_search.py +270 -0
  119. package/plugin/vendor/reflexio/reflexio/server/services/storage/storage_base/__init__.py +50 -8
  120. package/plugin/vendor/reflexio/reflexio/server/services/storage/storage_base/_agent_run.py +64 -370
  121. package/plugin/vendor/reflexio/reflexio/server/services/storage/storage_base/_commit_scope.py +9 -0
  122. package/plugin/vendor/reflexio/reflexio/server/services/storage/storage_base/_learning_jobs.py +219 -0
  123. package/plugin/vendor/reflexio/reflexio/server/services/storage/storage_base/_share_links.py +20 -0
  124. package/plugin/vendor/reflexio/reflexio/server/services/storage/storage_base/agent_run/__init__.py +9 -0
  125. package/plugin/vendor/reflexio/reflexio/server/services/storage/storage_base/agent_run/_agent_run_store.py +86 -0
  126. package/plugin/vendor/reflexio/reflexio/server/services/storage/storage_base/agent_run/_models.py +195 -0
  127. package/plugin/vendor/reflexio/reflexio/server/services/storage/storage_base/agent_run/_pending_tool_call_store.py +148 -0
  128. package/plugin/vendor/reflexio/reflexio/server/services/storage/storage_base/agent_run/_run_tool_dependency_store.py +38 -0
  129. package/plugin/vendor/reflexio/reflexio/server/services/storage/storage_base/governance/__init__.py +13 -0
  130. package/plugin/vendor/reflexio/reflexio/server/services/storage/storage_base/governance/_audit.py +29 -0
  131. package/plugin/vendor/reflexio/reflexio/server/services/storage/storage_base/governance/_erase_execution.py +30 -0
  132. package/plugin/vendor/reflexio/reflexio/server/services/storage/storage_base/governance/_purge.py +74 -0
  133. package/plugin/vendor/reflexio/reflexio/server/services/storage/storage_base/governance/_rebuild_hide.py +32 -0
  134. package/plugin/vendor/reflexio/reflexio/server/services/storage/storage_base/governance/_subject_barrier.py +49 -0
  135. package/plugin/vendor/reflexio/reflexio/server/services/storage/storage_base/profiles/__init__.py +9 -0
  136. package/plugin/vendor/reflexio/reflexio/server/services/storage/storage_base/profiles/_interaction_store.py +95 -0
  137. package/plugin/vendor/reflexio/reflexio/server/services/storage/storage_base/{_profiles.py → profiles/_profile_store.py} +60 -80
  138. package/plugin/vendor/reflexio/reflexio/server/services/storage/storage_base/profiles/_search.py +32 -0
  139. package/plugin/vendor/reflexio/reflexio/server/services/storage/storage_base/_governance.py +0 -148
@@ -7,6 +7,7 @@ are available.
7
7
 
8
8
  """
9
9
 
10
+ import contextlib
10
11
  import functools
11
12
  import json
12
13
  import logging
@@ -14,7 +15,7 @@ import math
14
15
  import re
15
16
  import sqlite3
16
17
  import threading
17
- from collections.abc import Callable, Sequence
18
+ from collections.abc import Callable, Generator, Sequence
18
19
  from datetime import UTC, datetime
19
20
  from pathlib import Path
20
21
  from typing import Any, ClassVar, Literal
@@ -53,12 +54,7 @@ from reflexio.server.services.storage.error import (
53
54
  StorageError,
54
55
  require_non_empty_session_id,
55
56
  )
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
- )
57
+ from reflexio.server.services.storage.retention_mixin import RetentionMixin
62
58
  from reflexio.server.services.storage.storage_base import BaseStorage
63
59
  from reflexio.server.site_var.site_var_manager import SiteVarManager
64
60
 
@@ -288,6 +284,42 @@ def _true_rrf_merge(
288
284
  # explicit status_filter on list/count methods.
289
285
  _TOMBSTONE_STATUS_VALUES = (Status.MERGED.value, Status.SUPERSEDED.value)
290
286
 
287
+ # Profile reads also treat EXPIRED (TTL-expired tombstone) as non-current. Kept
288
+ # separate from _TOMBSTONE_STATUS_VALUES because that tuple is shared with playbook
289
+ # queries (which never carry EXPIRED) via hardcoded placeholders.
290
+ _PROFILE_TOMBSTONE_STATUS_VALUES = (
291
+ Status.MERGED.value,
292
+ Status.SUPERSEDED.value,
293
+ Status.EXPIRED.value,
294
+ )
295
+
296
+
297
+ def parse_status(value: str | None) -> Status | None:
298
+ """Parse a stored status string into a Status, tolerating unknown values.
299
+
300
+ Unknown non-null values (e.g. a status written by a newer build after a
301
+ rollback) map to a non-current tombstone sentinel and log an anomaly, rather
302
+ than raising ValueError. Never maps to None (which models CURRENT/live).
303
+
304
+ Args:
305
+ value: Raw status string from the database, or None/empty for CURRENT.
306
+
307
+ Returns:
308
+ The matching Status enum member, Status.SUPERSEDED for unknown values,
309
+ or None for falsy input (representing the CURRENT/live state).
310
+ """
311
+ if not value:
312
+ return None
313
+ try:
314
+ return Status(value)
315
+ except ValueError:
316
+ from reflexio.server.tracing import capture_anomaly
317
+
318
+ capture_anomaly(
319
+ "storage.status.unknown_value", level="warning", status_value=value
320
+ )
321
+ return Status.SUPERSEDED
322
+
291
323
 
292
324
  def _status_value(status: Status | None) -> str | None:
293
325
  """Convert a Status enum (or None) to its DB string value."""
@@ -363,14 +395,19 @@ def _iso_to_epoch(iso_str: str | None) -> int:
363
395
  # stored row) with a valid value.
364
396
  _MAX_SAFE_EPOCH_TS = 253_402_300_799 # 9999-12-31T23:59:59Z
365
397
  _MIN_SAFE_EPOCH_TS = 0 # 1970-01-01T00:00:00Z
398
+ _MIN_CONTEMPORARY_MILLISECOND_EPOCH_TS = 1_500_000_000_000
366
399
 
367
400
 
368
401
  def _epoch_to_iso(ts: int) -> str:
369
402
  """Convert a Unix timestamp (seconds) to an ISO 8601 string.
370
403
 
371
- Out-of-range sentinel bounds are clamped to the representable range so that
372
- callers passing "open" window bounds never trigger a ``ValueError``.
404
+ Plausible contemporary millisecond timestamps are normalized to seconds.
405
+ Lower values stay in seconds-space so documented sentinel bounds such as
406
+ ``to_ts=10**12`` still clamp to the representable range instead of being
407
+ treated as a 2001 millisecond timestamp.
373
408
  """
409
+ if _MIN_CONTEMPORARY_MILLISECOND_EPOCH_TS <= ts <= _MAX_SAFE_EPOCH_TS * 1000:
410
+ ts = ts // 1000
374
411
  clamped = max(_MIN_SAFE_EPOCH_TS, min(ts, _MAX_SAFE_EPOCH_TS))
375
412
  return datetime.fromtimestamp(clamped, tz=UTC).isoformat()
376
413
 
@@ -392,7 +429,7 @@ def _row_to_profile(row: sqlite3.Row) -> UserProfile:
392
429
  expiration_timestamp=d["expiration_timestamp"],
393
430
  custom_features=_json_loads(d.get("custom_features")),
394
431
  source=d.get("source") or "",
395
- status=Status(d["status"]) if d.get("status") else None,
432
+ status=parse_status(d.get("status")),
396
433
  extractor_names=_json_loads(d.get("extractor_names")),
397
434
  expanded_terms=d.get("expanded_terms"),
398
435
  source_span=d.get("source_span"),
@@ -472,7 +509,7 @@ def _row_to_user_playbook(
472
509
  blocking_issue=BlockingIssue(**json.loads(d["blocking_issue"]))
473
510
  if d.get("blocking_issue")
474
511
  else None,
475
- status=Status(d["status"]) if d.get("status") else None,
512
+ status=parse_status(d.get("status")),
476
513
  source=d.get("source"),
477
514
  source_interaction_ids=_json_loads(d.get("source_interaction_ids")) or [],
478
515
  tags=_json_loads(d.get("tags")),
@@ -505,7 +542,7 @@ def _row_to_agent_playbook(row: sqlite3.Row) -> AgentPlaybook:
505
542
  playbook_metadata=d.get("playbook_metadata") or "",
506
543
  tags=_json_loads(d.get("tags")),
507
544
  embedding=[],
508
- status=Status(d["status"]) if d.get("status") else None,
545
+ status=parse_status(d.get("status")),
509
546
  expanded_terms=d.get("expanded_terms"),
510
547
  merged_into=d.get("merged_into"),
511
548
  superseded_by=d.get("superseded_by"),
@@ -546,6 +583,16 @@ class SQLiteStorageBase(RetentionMixin, BaseStorage):
546
583
 
547
584
  supports_embedding: ClassVar[bool] = True
548
585
 
586
+ # Chunked bulk-delete helpers provided by SQLiteDeletionMixin via the
587
+ # composed SQLiteStorage MRO; declared here for clear_user_data's benefit.
588
+ _delete_in_chunks: Any
589
+ _delete_source_windows_for_user_playbook_ids: Any
590
+
591
+ # FTS/vec index helpers provided by SQLiteFtsVecMixin via the composed
592
+ # SQLiteStorage MRO; declared here for _migrate_vec_tables's benefit.
593
+ _vec_upsert: Any
594
+ _flush_index_op: Any
595
+
549
596
  @staticmethod
550
597
  def handle_exceptions(func: Callable[..., Any]) -> Callable[..., Any]:
551
598
  @functools.wraps(func)
@@ -588,6 +635,10 @@ class SQLiteStorageBase(RetentionMixin, BaseStorage):
588
635
 
589
636
  self.db_path = db_path
590
637
  self._lock = threading.RLock()
638
+ self._scope_depth = 0
639
+ self._deferred_index_ops: list[
640
+ tuple[str, Any]
641
+ ] = [] # (kind, args) flushed post-commit
591
642
 
592
643
  logger.info("SQLite Storage for org %s using db_path: %s", org_id, db_path)
593
644
 
@@ -625,6 +676,45 @@ class SQLiteStorageBase(RetentionMixin, BaseStorage):
625
676
  # Create tables
626
677
  self.migrate()
627
678
 
679
+ # ------------------------------------------------------------------
680
+ # Transaction scope
681
+ # ------------------------------------------------------------------
682
+
683
+ def _own_transaction(self) -> bool:
684
+ """True when the caller is NOT inside a commit_scope (owns its BEGIN/commit)."""
685
+ return self._scope_depth == 0
686
+
687
+ @contextlib.contextmanager
688
+ def commit_scope(self) -> Generator[None, None, None]:
689
+ """Group writes into one atomic commit; defer FTS/vec index ops.
690
+
691
+ Nested scopes join the outermost: only the outer scope commits. On
692
+ exception the whole transaction rolls back and deferred index ops
693
+ are discarded.
694
+ """
695
+ with self._lock:
696
+ if self._scope_depth > 0:
697
+ self._scope_depth += 1
698
+ try:
699
+ yield
700
+ finally:
701
+ self._scope_depth -= 1
702
+ return
703
+ self.conn.execute("BEGIN IMMEDIATE")
704
+ self._scope_depth = 1
705
+ try:
706
+ yield
707
+ self.conn.commit()
708
+ ops, self._deferred_index_ops = self._deferred_index_ops, []
709
+ for kind, args in ops:
710
+ self._flush_index_op(kind, args) # self-commits — fine post-commit
711
+ except Exception:
712
+ self.conn.rollback()
713
+ self._deferred_index_ops = []
714
+ raise
715
+ finally:
716
+ self._scope_depth = 0
717
+
628
718
  # ------------------------------------------------------------------
629
719
  # DDL / migration
630
720
  # ------------------------------------------------------------------
@@ -669,241 +759,9 @@ class SQLiteStorageBase(RetentionMixin, BaseStorage):
669
759
  self._migrate_retire_profile_change_logs()
670
760
  self._migrate_retire_playbook_aggregation_change_logs()
671
761
  init_stall_state_table(self.conn)
762
+ self._migrate_learning_jobs()
672
763
  return True
673
764
 
674
- # -- Retention hooks (see RetentionMixin) --
675
-
676
- @handle_exceptions
677
- def _retention_table_exists(self, table_name: str) -> bool:
678
- row = self._fetchone(
679
- "SELECT 1 FROM sqlite_master WHERE type = 'table' AND name = ?",
680
- (table_name,),
681
- )
682
- return row is not None
683
-
684
- @handle_exceptions
685
- def _retention_count_rows(self, target: RetentionTarget) -> int:
686
- row = self._fetchone(f"SELECT COUNT(*) as cnt FROM {target.table_name}") # noqa: S608
687
- return int(row["cnt"]) if row else 0
688
-
689
- @handle_exceptions
690
- def _retention_select_oldest_keys(
691
- self, target: RetentionTarget, count: int
692
- ) -> list[tuple[Any, ...]]:
693
- id_sql = ", ".join(target.id_columns)
694
- tiebreak_sql = id_sql
695
- rows = self._fetchall(
696
- f"SELECT {id_sql} FROM {target.table_name} " # noqa: S608
697
- f"ORDER BY {target.order_column} ASC, {tiebreak_sql} ASC LIMIT ?",
698
- (count,),
699
- )
700
- return [tuple(row[col] for col in target.id_columns) for row in rows]
701
-
702
- @handle_exceptions
703
- def _retention_perform_delete(
704
- self, target: RetentionTarget, keys: list[tuple[Any, ...]]
705
- ) -> None:
706
- # Wrap dependency + target deletes in a single critical section so
707
- # concurrent writers see either both or neither.
708
- with self._lock:
709
- try:
710
- self._retention_delete_dependencies(target, keys)
711
- self._retention_delete_target_rows(target, keys)
712
- self.conn.commit()
713
- except Exception:
714
- self.conn.rollback()
715
- raise
716
-
717
- def _retention_delete_dependencies(
718
- self, target: RetentionTarget, keys: list[tuple[Any, ...]]
719
- ) -> None:
720
- ids = [key[0] for key in keys]
721
- target_name = target.name
722
- if target_name == "requests":
723
- self._delete_interactions_for_request_ids([str(v) for v in ids])
724
- elif target_name == "interactions":
725
- self._delete_interaction_search_rows([int(v) for v in ids])
726
- elif target_name == "profiles":
727
- self._delete_profile_search_rows([str(v) for v in ids])
728
- elif target_name == "user_playbooks":
729
- self._delete_source_windows_for_user_playbook_ids([int(v) for v in ids])
730
- self._delete_playbook_search_rows(
731
- "user", [int(v) for v in ids], commit=False
732
- )
733
- elif target_name == "agent_playbooks":
734
- self._delete_source_windows_for_agent_playbook_ids([int(v) for v in ids])
735
- self._delete_playbook_search_rows(
736
- "agent", [int(v) for v in ids], commit=False
737
- )
738
- elif target_name == "playbook_optimization_jobs":
739
- self._delete_optimizer_rows_for_job_ids([int(v) for v in ids])
740
- elif target_name == "playbook_optimization_candidates":
741
- self._delete_optimizer_evaluations_for_candidate_ids([int(v) for v in ids])
742
-
743
- def _retention_delete_target_rows(
744
- self, target: RetentionTarget, keys: list[tuple[Any, ...]]
745
- ) -> None:
746
- if len(target.id_columns) == 1:
747
- self._delete_in_chunks(
748
- target.table_name,
749
- target.id_columns[0],
750
- [key[0] for key in keys],
751
- )
752
- return
753
- # Composite-key delete: chunk by row to bound parameter count.
754
- params_per_key = len(target.id_columns)
755
- rows_per_chunk = max(1, RETENTION_DELETE_CHUNK // params_per_key)
756
- for chunk in chunked(keys, rows_per_chunk):
757
- where = " OR ".join(
758
- "("
759
- + " AND ".join(f"{column} = ?" for column in target.id_columns)
760
- + ")"
761
- for _ in chunk
762
- )
763
- params = [value for key in chunk for value in key]
764
- self.conn.execute(
765
- f"DELETE FROM {target.table_name} WHERE {where}", # noqa: S608
766
- params,
767
- )
768
-
769
- # -- Chunked-delete primitives shared by the cascade helpers --
770
-
771
- def _delete_in_chunks(
772
- self, table_name: str, column_name: str, values: list[Any]
773
- ) -> None:
774
- """Chunked ``DELETE FROM table WHERE col IN (...)``.
775
-
776
- Chunking keeps parameter count under ``SQLITE_MAX_VARIABLE_NUMBER``
777
- on older sqlite builds (default 999) and avoids degenerate plans
778
- on very large IN lists.
779
- """
780
- if not values:
781
- return
782
- for chunk in chunked(values):
783
- placeholders = ",".join("?" for _ in chunk)
784
- self.conn.execute(
785
- f"DELETE FROM {table_name} WHERE {column_name} IN ({placeholders})", # noqa: S608
786
- chunk,
787
- )
788
-
789
- def _select_in_chunks(self, sql_template: str, values: list[Any]) -> list[Any]:
790
- """Run ``sql_template`` (containing ``{placeholders}``) over chunks of
791
- ``values`` and aggregate the result rows."""
792
- results: list[Any] = []
793
- for chunk in chunked(values):
794
- placeholders = ",".join("?" for _ in chunk)
795
- stmt = sql_template.format(placeholders=placeholders)
796
- results.extend(self.conn.execute(stmt, chunk).fetchall())
797
- return results
798
-
799
- def _delete_interactions_for_request_ids(self, request_ids: list[str]) -> None:
800
- if not request_ids:
801
- return
802
- rows = self._select_in_chunks(
803
- "SELECT interaction_id FROM interactions WHERE request_id IN ({placeholders})",
804
- request_ids,
805
- )
806
- self._delete_interaction_search_rows(
807
- [int(row["interaction_id"]) for row in rows]
808
- )
809
- self._delete_in_chunks("interactions", "request_id", request_ids)
810
-
811
- def _delete_interaction_search_rows(self, interaction_ids: list[int]) -> None:
812
- """Remove fts + vec index rows for the given interaction IDs.
813
-
814
- Non-committing: participates in the caller's transaction. Only called
815
- from inside the retention atomic block (_retention_perform_delete).
816
- """
817
- if not interaction_ids:
818
- return
819
- self._delete_in_chunks("interactions_fts", "rowid", interaction_ids)
820
- if self._has_sqlite_vec:
821
- self._delete_in_chunks("interactions_vec", "rowid", interaction_ids)
822
-
823
- def _delete_profile_search_rows(self, profile_ids: list[str]) -> None:
824
- """Remove fts + vec index rows for the given profile IDs.
825
-
826
- Non-committing: participates in the caller's transaction. Only called
827
- from inside the retention atomic block (_retention_perform_delete).
828
- profiles_fts is keyed by profile_id (TEXT); profiles_vec by rowid (INT).
829
- """
830
- if not profile_ids:
831
- return
832
- self._delete_in_chunks("profiles_fts", "profile_id", profile_ids)
833
- if self._has_sqlite_vec:
834
- rows = self._select_in_chunks(
835
- "SELECT rowid FROM profiles WHERE profile_id IN ({placeholders})",
836
- profile_ids,
837
- )
838
- rowids = [row["rowid"] for row in rows]
839
- if rowids:
840
- self._delete_in_chunks("profiles_vec", "rowid", rowids)
841
-
842
- def _delete_playbook_search_rows(
843
- self, kind: str, ids: list[int], *, commit: bool = True
844
- ) -> None:
845
- """Remove fts + vec index rows for the given playbook IDs.
846
-
847
- Args:
848
- kind: ``"user"`` or ``"agent"``.
849
- ids: Playbook row IDs to remove from the search indexes.
850
- commit: When ``True`` (default) commits after the deletes so the
851
- after-commit callers in ``_playbook.py`` get a clean, durable
852
- cleanup. Pass ``commit=False`` from inside the retention atomic
853
- block so the deletes participate in the single block-level commit
854
- (``_retention_perform_delete``).
855
-
856
- Note: callers may already hold ``self._lock`` when calling this (the
857
- ``commit=False`` retention/atomic-delete call sites do). The internal
858
- ``with self._lock:`` re-acquire is safe ONLY because ``self._lock`` is a
859
- reentrant ``threading.RLock``; a non-reentrant lock would deadlock here.
860
- """
861
- if not ids:
862
- return
863
- with self._lock:
864
- self._delete_in_chunks(f"{kind}_playbooks_fts", "rowid", ids)
865
- if self._has_sqlite_vec:
866
- self._delete_in_chunks(f"{kind}_playbooks_vec", "rowid", ids)
867
- if commit:
868
- self.conn.commit()
869
-
870
- def _delete_source_windows_for_agent_playbook_ids(
871
- self, agent_playbook_ids: list[int]
872
- ) -> None:
873
- self._delete_in_chunks(
874
- "agent_playbook_source_user_playbooks",
875
- "agent_playbook_id",
876
- agent_playbook_ids,
877
- )
878
-
879
- def _delete_source_windows_for_user_playbook_ids(
880
- self, user_playbook_ids: list[int]
881
- ) -> None:
882
- self._delete_in_chunks(
883
- "agent_playbook_source_user_playbooks",
884
- "user_playbook_id",
885
- user_playbook_ids,
886
- )
887
-
888
- def _delete_optimizer_rows_for_job_ids(self, job_ids: list[int]) -> None:
889
- if not job_ids:
890
- return
891
- for table in (
892
- "playbook_optimization_evaluations",
893
- "playbook_optimization_events",
894
- "playbook_optimization_candidates",
895
- ):
896
- self._delete_in_chunks(table, "job_id", job_ids)
897
-
898
- def _delete_optimizer_evaluations_for_candidate_ids(
899
- self, candidate_ids: list[int]
900
- ) -> None:
901
- self._delete_in_chunks(
902
- "playbook_optimization_evaluations",
903
- "candidate_id",
904
- candidate_ids,
905
- )
906
-
907
765
  def _try_load_sqlite_vec(self) -> bool:
908
766
  """Attempt to load the sqlite-vec extension for native KNN search.
909
767
 
@@ -1449,6 +1307,70 @@ class SQLiteStorageBase(RetentionMixin, BaseStorage):
1449
1307
  )
1450
1308
  self.conn.commit()
1451
1309
 
1310
+ def _migrate_learning_jobs(self) -> None:
1311
+ """Create the learning_jobs table + partial indexes if missing (idempotent).
1312
+
1313
+ Runs ``CREATE TABLE IF NOT EXISTS`` and ``CREATE INDEX IF NOT EXISTS`` only —
1314
+ both are no-ops when the table / indexes already exist. New columns added in
1315
+ subsequent tasks are backfilled via ``PRAGMA table_info`` + ``ALTER TABLE … ADD
1316
+ COLUMN`` (mirroring ``_migrate_lineage_event_table``), because
1317
+ ``CREATE TABLE IF NOT EXISTS`` silently skips the DDL on existing databases.
1318
+
1319
+ Called at the end of migrate() so the table is always present on startup.
1320
+ """
1321
+ with self._lock:
1322
+ self.conn.executescript("""
1323
+ CREATE TABLE IF NOT EXISTS learning_jobs (
1324
+ job_id TEXT PRIMARY KEY,
1325
+ org_id TEXT NOT NULL,
1326
+ user_id TEXT NOT NULL,
1327
+ job_type TEXT NOT NULL DEFAULT 'learning',
1328
+ latest_request_id TEXT,
1329
+ status TEXT NOT NULL DEFAULT 'pending',
1330
+ attempts INTEGER NOT NULL DEFAULT 0,
1331
+ max_attempts INTEGER NOT NULL DEFAULT 3,
1332
+ claimed_by TEXT,
1333
+ claim_token TEXT,
1334
+ claim_expires_at TEXT,
1335
+ covers_through TEXT,
1336
+ force_extraction INTEGER NOT NULL DEFAULT 0,
1337
+ skip_aggregation INTEGER NOT NULL DEFAULT 0,
1338
+ created_at TEXT NOT NULL DEFAULT (strftime('%Y-%m-%dT%H:%M:%fZ','now')),
1339
+ updated_at TEXT NOT NULL DEFAULT (strftime('%Y-%m-%dT%H:%M:%fZ','now'))
1340
+ );
1341
+ CREATE UNIQUE INDEX IF NOT EXISTS learning_jobs_coalesce
1342
+ ON learning_jobs (org_id, user_id, job_type) WHERE status = 'pending';
1343
+ -- Recreate the poll index with 'failed' in the predicate. CREATE
1344
+ -- INDEX IF NOT EXISTS is a no-op on an existing index with the old
1345
+ -- ('pending','claimed') predicate, so DROP first to migrate it.
1346
+ DROP INDEX IF EXISTS learning_jobs_poll;
1347
+ CREATE INDEX IF NOT EXISTS learning_jobs_poll
1348
+ ON learning_jobs (created_at) WHERE status IN ('pending','failed','claimed');
1349
+ """)
1350
+ # Backfill columns added after the initial release (existing DBs skip
1351
+ # CREATE TABLE IF NOT EXISTS so these must be applied separately).
1352
+ existing_cols = {
1353
+ row["name"]
1354
+ for row in self.conn.execute(
1355
+ "PRAGMA table_info(learning_jobs)"
1356
+ ).fetchall()
1357
+ }
1358
+ if "force_extraction" not in existing_cols:
1359
+ self.conn.execute(
1360
+ "ALTER TABLE learning_jobs ADD COLUMN force_extraction INTEGER NOT NULL DEFAULT 0"
1361
+ )
1362
+ if "skip_aggregation" not in existing_cols:
1363
+ self.conn.execute(
1364
+ "ALTER TABLE learning_jobs ADD COLUMN skip_aggregation INTEGER NOT NULL DEFAULT 0"
1365
+ )
1366
+ self.conn.commit()
1367
+
1368
+ def learning_jobs_columns(self) -> list[str]:
1369
+ """Return the column names of the learning_jobs table."""
1370
+ with self._lock:
1371
+ rows = self.conn.execute("PRAGMA table_info(learning_jobs)").fetchall()
1372
+ return [row["name"] for row in rows]
1373
+
1452
1374
  def _migrate_agent_playbook_source_windows(self) -> None:
1453
1375
  """Add source window snapshots to existing agent source mappings."""
1454
1376
  cols = {
@@ -1639,7 +1561,8 @@ class SQLiteStorageBase(RetentionMixin, BaseStorage):
1639
1561
  ) -> sqlite3.Cursor:
1640
1562
  with self._lock:
1641
1563
  cur = self.conn.execute(sql, params)
1642
- self.conn.commit()
1564
+ if self._own_transaction():
1565
+ self.conn.commit()
1643
1566
  return cur
1644
1567
 
1645
1568
  def _fetchone(
@@ -1717,113 +1640,6 @@ class SQLiteStorageBase(RetentionMixin, BaseStorage):
1717
1640
  def _current_timestamp(self) -> str:
1718
1641
  return datetime.now(UTC).isoformat()
1719
1642
 
1720
- # FTS helpers
1721
- def _fts_upsert(self, table: str, rowid: int, **text_fields: str | None) -> None:
1722
- """Insert or update an FTS row. Deletes old entry first to avoid duplicates."""
1723
- with self._lock:
1724
- self.conn.execute(f"DELETE FROM {table} WHERE rowid = ?", (rowid,))
1725
- cols = list(text_fields.keys())
1726
- vals = [text_fields[c] or "" for c in cols]
1727
- placeholders = ",".join("?" for _ in cols)
1728
- col_str = ",".join(cols)
1729
- self.conn.execute(
1730
- f"INSERT INTO {table}(rowid, {col_str}) VALUES (?, {placeholders})",
1731
- [rowid, *vals],
1732
- )
1733
- self.conn.commit()
1734
-
1735
- def _fts_delete(self, table: str, rowid: int) -> None:
1736
- with self._lock:
1737
- self.conn.execute(f"DELETE FROM {table} WHERE rowid = ?", (rowid,))
1738
- self.conn.commit()
1739
-
1740
- def _fts_upsert_profile(self, profile_id: str, content: str) -> None:
1741
- """FTS for profiles uses profile_id TEXT as key column."""
1742
- with self._lock:
1743
- self.conn.execute(
1744
- "DELETE FROM profiles_fts WHERE profile_id = ?", (profile_id,)
1745
- )
1746
- self.conn.execute(
1747
- "INSERT INTO profiles_fts(profile_id, content) VALUES (?, ?)",
1748
- (profile_id, content),
1749
- )
1750
- self.conn.commit()
1751
-
1752
- def _fts_delete_profile(self, profile_id: str) -> None:
1753
- with self._lock:
1754
- self.conn.execute(
1755
- "DELETE FROM profiles_fts WHERE profile_id = ?", (profile_id,)
1756
- )
1757
- self.conn.commit()
1758
-
1759
- # Vec helpers (sqlite-vec)
1760
- def _vec_upsert(self, table: str, rowid: int, embedding: list[float]) -> None:
1761
- """Insert or update a vec table row. No-op when sqlite-vec is unavailable."""
1762
- if not self._has_sqlite_vec:
1763
- return
1764
- with self._lock:
1765
- self.conn.execute(f"DELETE FROM {table} WHERE rowid = ?", (rowid,))
1766
- self.conn.execute(
1767
- f"INSERT INTO {table}(rowid, embedding) VALUES (?, ?)",
1768
- (rowid, json.dumps(embedding)),
1769
- )
1770
- self.conn.commit()
1771
-
1772
- def _vec_delete(self, table: str, rowid: int) -> None:
1773
- """Delete a vec table row. No-op when sqlite-vec is unavailable."""
1774
- if not self._has_sqlite_vec:
1775
- return
1776
- with self._lock:
1777
- self.conn.execute(f"DELETE FROM {table} WHERE rowid = ?", (rowid,))
1778
- self.conn.commit()
1779
-
1780
- def _vec_knn_search(
1781
- self,
1782
- vec_table: str,
1783
- main_table: str,
1784
- query_embedding: list[float],
1785
- match_count: int,
1786
- conditions: list[str] | None = None,
1787
- params: list[Any] | None = None,
1788
- ) -> list[sqlite3.Row]:
1789
- """Run a native KNN search via sqlite-vec and join back to the main table.
1790
-
1791
- Over-fetches from the KNN index (5x ``match_count``) so that post-filter
1792
- WHERE conditions (org, user, status, etc.) don't silently reduce the
1793
- result set below the requested count.
1794
-
1795
- Args:
1796
- vec_table: Name of the vec0 virtual table.
1797
- main_table: Name of the main data table.
1798
- query_embedding: Query embedding vector.
1799
- match_count: Number of results to return.
1800
- conditions: Optional WHERE conditions for the main table.
1801
- params: Parameters for the conditions.
1802
-
1803
- Returns:
1804
- Up to ``match_count`` rows from the main table, ordered by vector
1805
- distance (ascending).
1806
- """
1807
- knn_overfetch = match_count * 5
1808
- where_clause = " AND ".join(conditions) if conditions else "1=1"
1809
- sql = f"""SELECT m.* FROM {main_table} m
1810
- JOIN (
1811
- SELECT rowid, distance FROM {vec_table}
1812
- WHERE embedding MATCH ?
1813
- ORDER BY distance
1814
- LIMIT ?
1815
- ) v ON m.rowid = v.rowid
1816
- WHERE {where_clause}
1817
- ORDER BY v.distance
1818
- LIMIT ?"""
1819
- all_params = [
1820
- json.dumps(query_embedding),
1821
- knn_overfetch,
1822
- *(params or []),
1823
- match_count,
1824
- ]
1825
- return self._fetchall(sql, all_params)
1826
-
1827
1643
  # ------------------------------------------------------------------
1828
1644
  # Per-user data clear
1829
1645
  # ------------------------------------------------------------------
@@ -2389,4 +2205,31 @@ CREATE TABLE IF NOT EXISTS lineage_event (
2389
2205
  );
2390
2206
  CREATE INDEX IF NOT EXISTS idx_lineage_entity ON lineage_event (entity_type, entity_id);
2391
2207
 
2208
+ -- ============================================================================
2209
+ -- Durable learning pipeline — cross-org job queue
2210
+ -- ============================================================================
2211
+
2212
+ CREATE TABLE IF NOT EXISTS learning_jobs (
2213
+ job_id TEXT PRIMARY KEY,
2214
+ org_id TEXT NOT NULL,
2215
+ user_id TEXT NOT NULL,
2216
+ job_type TEXT NOT NULL DEFAULT 'learning',
2217
+ latest_request_id TEXT,
2218
+ status TEXT NOT NULL DEFAULT 'pending',
2219
+ attempts INTEGER NOT NULL DEFAULT 0,
2220
+ max_attempts INTEGER NOT NULL DEFAULT 3,
2221
+ claimed_by TEXT,
2222
+ claim_token TEXT,
2223
+ claim_expires_at TEXT,
2224
+ covers_through TEXT,
2225
+ force_extraction INTEGER NOT NULL DEFAULT 0,
2226
+ skip_aggregation INTEGER NOT NULL DEFAULT 0,
2227
+ created_at TEXT NOT NULL DEFAULT (strftime('%Y-%m-%dT%H:%M:%fZ','now')),
2228
+ updated_at TEXT NOT NULL DEFAULT (strftime('%Y-%m-%dT%H:%M:%fZ','now'))
2229
+ );
2230
+ CREATE UNIQUE INDEX IF NOT EXISTS learning_jobs_coalesce
2231
+ ON learning_jobs (org_id, user_id, job_type) WHERE status = 'pending';
2232
+ CREATE INDEX IF NOT EXISTS learning_jobs_poll
2233
+ ON learning_jobs (created_at) WHERE status IN ('pending','failed','claimed');
2234
+
2392
2235
  """