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
@@ -71,6 +71,7 @@ class UserPlaybookStoreMixin:
71
71
  _delete_playbook_search_rows: Any
72
72
  _subject_ref_for_user_id: Any
73
73
  _assert_subject_writable_locked: Any
74
+ _own_transaction: Any
74
75
 
75
76
  def _subject_ref_from_user_playbook_row(self, row: sqlite3.Row) -> str:
76
77
  subject_ref = row["governance_subject_ref"]
@@ -119,8 +120,10 @@ class UserPlaybookStoreMixin:
119
120
 
120
121
  created_at_iso = _epoch_to_iso(up.created_at)
121
122
  with self._lock:
123
+ own_txn = self._own_transaction()
122
124
  try:
123
- self.conn.execute("BEGIN IMMEDIATE")
125
+ if own_txn:
126
+ self.conn.execute("BEGIN IMMEDIATE")
124
127
  self._assert_subject_writable_locked(subject_ref)
125
128
  cur = self.conn.execute(
126
129
  """INSERT INTO user_playbooks
@@ -159,9 +162,11 @@ class UserPlaybookStoreMixin:
159
162
  )
160
163
  upid = cur.lastrowid or 0
161
164
  up.user_playbook_id = upid
162
- self.conn.commit()
165
+ if own_txn:
166
+ self.conn.commit()
163
167
  except Exception:
164
- self.conn.rollback()
168
+ if own_txn:
169
+ self.conn.rollback()
165
170
  raise
166
171
 
167
172
  fts_parts = [up.trigger or "", up.content or ""]
@@ -230,7 +235,8 @@ class UserPlaybookStoreMixin:
230
235
  params.extend(sparams)
231
236
  else:
232
237
  # Default: exclude tombstone statuses (MERGED/SUPERSEDED)
233
- sql += " AND (status IS NULL OR status NOT IN (?, ?))"
238
+ _ph = ",".join("?" * len(_TOMBSTONE_STATUS_VALUES))
239
+ sql += f" AND (status IS NULL OR status NOT IN ({_ph}))"
234
240
  params.extend(_TOMBSTONE_STATUS_VALUES)
235
241
  tag_frag, tag_params = _build_tags_sql("user_playbooks", tags)
236
242
  if tag_frag:
@@ -274,7 +280,8 @@ class UserPlaybookStoreMixin:
274
280
  params.extend(sparams)
275
281
  else:
276
282
  # Default: exclude tombstone statuses (MERGED/SUPERSEDED)
277
- sql += " AND (status IS NULL OR status NOT IN (?, ?))"
283
+ _ph = ",".join("?" * len(_TOMBSTONE_STATUS_VALUES))
284
+ sql += f" AND (status IS NULL OR status NOT IN ({_ph}))"
278
285
  params.extend(_TOMBSTONE_STATUS_VALUES)
279
286
 
280
287
  row = self._fetchone(sql, params)
@@ -282,11 +289,12 @@ class UserPlaybookStoreMixin:
282
289
 
283
290
  @SQLiteStorageBase.handle_exceptions
284
291
  def count_user_playbooks_by_session(self, session_id: str) -> int:
292
+ _ph = ",".join("?" * len(_TOMBSTONE_STATUS_VALUES))
285
293
  row = self._fetchone(
286
- """SELECT COUNT(*) as cnt FROM user_playbooks up
294
+ f"""SELECT COUNT(*) as cnt FROM user_playbooks up
287
295
  JOIN requests r ON up.request_id = r.request_id
288
296
  WHERE r.session_id = ?
289
- AND (up.status IS NULL OR up.status NOT IN (?, ?))""",
297
+ AND (up.status IS NULL OR up.status NOT IN ({_ph}))""", # noqa: S608
290
298
  (session_id, *_TOMBSTONE_STATUS_VALUES),
291
299
  )
292
300
  return row["cnt"] if row else 0
@@ -620,7 +628,8 @@ class UserPlaybookStoreMixin:
620
628
  params.extend(sparams)
621
629
  else:
622
630
  # Default: exclude tombstone statuses (MERGED/SUPERSEDED)
623
- conditions.append("(up.status IS NULL OR up.status NOT IN (?, ?))")
631
+ _ph = ",".join("?" * len(_TOMBSTONE_STATUS_VALUES))
632
+ conditions.append(f"(up.status IS NULL OR up.status NOT IN ({_ph}))")
624
633
  params.extend(_TOMBSTONE_STATUS_VALUES)
625
634
  tag_frag, tag_params = _build_tags_sql("up", request.tags)
626
635
  if tag_frag:
@@ -695,7 +704,8 @@ class UserPlaybookStoreMixin:
695
704
  ) -> UserPlaybook | None:
696
705
  sql = "SELECT * FROM user_playbooks WHERE user_playbook_id = ?"
697
706
  if not include_tombstones:
698
- sql += " AND (status IS NULL OR status NOT IN (?, ?))"
707
+ _ph = ",".join("?" * len(_TOMBSTONE_STATUS_VALUES))
708
+ sql += f" AND (status IS NULL OR status NOT IN ({_ph}))"
699
709
  row = self._fetchone(sql, (user_playbook_id, *_TOMBSTONE_STATUS_VALUES))
700
710
  else:
701
711
  row = self._fetchone(sql, (user_playbook_id,))
@@ -784,7 +794,8 @@ class UserPlaybookStoreMixin:
784
794
  to_status=None,
785
795
  status_namespace=None,
786
796
  )
787
- self.conn.commit()
797
+ if self._own_transaction():
798
+ self.conn.commit()
788
799
 
789
800
  @SQLiteStorageBase.handle_exceptions
790
801
  def supersede_user_playbooks_by_ids(
@@ -814,10 +825,11 @@ class UserPlaybookStoreMixin:
814
825
  self._subject_ref_from_user_playbook_row(row)
815
826
  )
816
827
  old_status = row["status"]
828
+ _ph = ",".join("?" * len(_TOMBSTONE_STATUS_VALUES))
817
829
  cur = self.conn.execute(
818
830
  "UPDATE user_playbooks SET status = ?, retired_at = ?"
819
831
  " WHERE user_playbook_id = ?"
820
- " AND (status IS NULL OR status NOT IN (?, ?))",
832
+ f" AND (status IS NULL OR status NOT IN ({_ph}))",
821
833
  (
822
834
  Status.SUPERSEDED.value,
823
835
  now_ts,
@@ -834,5 +846,6 @@ class UserPlaybookStoreMixin:
834
846
  request_id=request_id,
835
847
  )
836
848
  updated += 1
837
- self.conn.commit()
849
+ if self._own_transaction():
850
+ self.conn.commit()
838
851
  return updated
@@ -0,0 +1,9 @@
1
+ from ._interaction_store import InteractionStoreMixin
2
+ from ._profile_store import ProfileStoreMixin
3
+ from ._search import ProfileSearchMixin
4
+
5
+ __all__ = [
6
+ "InteractionStoreMixin",
7
+ "ProfileSearchMixin",
8
+ "ProfileStoreMixin",
9
+ ]
@@ -0,0 +1,308 @@
1
+ """Interaction store CRUD methods for SQLite storage.
2
+
3
+ Extracted verbatim from ``_profiles.py`` (the InteractionStore bucket). Profile
4
+ CRUD lives in ``profiles._profile_store`` (``ProfileStoreMixin``); search methods
5
+ live in ``profiles._search`` (``ProfileSearchMixin``).
6
+ """
7
+
8
+ import logging
9
+ import sqlite3
10
+ from typing import Any
11
+
12
+ from reflexio.models.api_schema.service_schemas import (
13
+ DeleteUserInteractionRequest,
14
+ Interaction,
15
+ )
16
+ from reflexio.server.llm.providers.embedding_service_provider import (
17
+ EmbeddingUnavailableError,
18
+ )
19
+
20
+ from .._base import (
21
+ SQLiteStorageBase,
22
+ _epoch_to_iso,
23
+ _json_dumps,
24
+ _row_to_interaction,
25
+ )
26
+
27
+ logger = logging.getLogger(__name__)
28
+
29
+
30
+ class InteractionStoreMixin:
31
+ """Mixin providing interaction-store CRUD for SQLite storage."""
32
+
33
+ # Type hints for instance attributes/methods provided by SQLiteStorageBase via MRO
34
+ _lock: Any
35
+ conn: sqlite3.Connection
36
+ _fetchone: Any
37
+ _fetchall: Any
38
+ _get_embedding: Any
39
+ _fts_upsert: Any
40
+ _vec_upsert: Any
41
+ _delete_in_chunks: Any
42
+ _has_sqlite_vec: bool
43
+ llm_client: Any
44
+ embedding_model_name: str
45
+ embedding_dimensions: int
46
+ _subject_ref_for_user_id: Any
47
+ _assert_subject_writable_locked: Any
48
+ _own_transaction: Any
49
+
50
+ # ------------------------------------------------------------------
51
+ # CRUD — Interactions
52
+ # ------------------------------------------------------------------
53
+
54
+ @SQLiteStorageBase.handle_exceptions
55
+ def get_all_interactions(self, limit: int = 100) -> list[Interaction]:
56
+ rows = self._fetchall(
57
+ "SELECT * FROM interactions ORDER BY created_at DESC LIMIT ?", (limit,)
58
+ )
59
+ return [_row_to_interaction(r) for r in rows]
60
+
61
+ @SQLiteStorageBase.handle_exceptions
62
+ def get_user_interaction(self, user_id: str) -> list[Interaction]:
63
+ rows = self._fetchall(
64
+ "SELECT * FROM interactions WHERE user_id = ?", (user_id,)
65
+ )
66
+ return [_row_to_interaction(r) for r in rows]
67
+
68
+ @SQLiteStorageBase.handle_exceptions
69
+ def get_all_user_ids(self) -> list[str]:
70
+ rows = self._fetchall("SELECT DISTINCT user_id FROM interactions")
71
+ return sorted(r["user_id"] for r in rows)
72
+
73
+ @SQLiteStorageBase.handle_exceptions
74
+ def add_user_interaction(self, user_id: str, interaction: Interaction) -> None: # noqa: ARG002
75
+ embedding = self._get_embedding(
76
+ f"{interaction.content}\n{interaction.user_action_description}"
77
+ )
78
+ interaction.embedding = embedding
79
+ self._insert_interaction(interaction)
80
+
81
+ def _insert_interaction(self, interaction: Interaction) -> int:
82
+ created_at_iso = _epoch_to_iso(interaction.created_at)
83
+ subject_ref = self._subject_ref_for_user_id(interaction.user_id)
84
+ with self._lock:
85
+ own_txn = self._own_transaction()
86
+ try:
87
+ if own_txn:
88
+ self.conn.execute("BEGIN IMMEDIATE")
89
+ self._assert_subject_writable_locked(subject_ref)
90
+ if interaction.interaction_id:
91
+ self.conn.execute(
92
+ """INSERT OR REPLACE INTO interactions
93
+ (interaction_id, user_id, content, request_id, created_at,
94
+ role, user_action, user_action_description,
95
+ interacted_image_url, image_encoding, shadow_content,
96
+ expert_content, tools_used, citations, embedding, governance_subject_ref)
97
+ VALUES (?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?)""",
98
+ (
99
+ interaction.interaction_id,
100
+ interaction.user_id,
101
+ interaction.content,
102
+ interaction.request_id,
103
+ created_at_iso,
104
+ interaction.role,
105
+ interaction.user_action.value,
106
+ interaction.user_action_description,
107
+ interaction.interacted_image_url,
108
+ interaction.image_encoding,
109
+ interaction.shadow_content,
110
+ interaction.expert_content,
111
+ _json_dumps(
112
+ [t.model_dump() for t in interaction.tools_used]
113
+ ),
114
+ _json_dumps(
115
+ [c.model_dump() for c in interaction.citations]
116
+ ),
117
+ _json_dumps(interaction.embedding),
118
+ subject_ref,
119
+ ),
120
+ )
121
+ iid = interaction.interaction_id
122
+ else:
123
+ cur = self.conn.execute(
124
+ """INSERT INTO interactions
125
+ (user_id, content, request_id, created_at,
126
+ role, user_action, user_action_description,
127
+ interacted_image_url, image_encoding, shadow_content,
128
+ expert_content, tools_used, citations, embedding, governance_subject_ref)
129
+ VALUES (?,?,?,?,?,?,?,?,?,?,?,?,?,?,?)""",
130
+ (
131
+ interaction.user_id,
132
+ interaction.content,
133
+ interaction.request_id,
134
+ created_at_iso,
135
+ interaction.role,
136
+ interaction.user_action.value,
137
+ interaction.user_action_description,
138
+ interaction.interacted_image_url,
139
+ interaction.image_encoding,
140
+ interaction.shadow_content,
141
+ interaction.expert_content,
142
+ _json_dumps(
143
+ [t.model_dump() for t in interaction.tools_used]
144
+ ),
145
+ _json_dumps(
146
+ [c.model_dump() for c in interaction.citations]
147
+ ),
148
+ _json_dumps(interaction.embedding),
149
+ subject_ref,
150
+ ),
151
+ )
152
+ iid = cur.lastrowid or 0
153
+ interaction.interaction_id = iid
154
+ if own_txn:
155
+ self.conn.commit()
156
+ except Exception:
157
+ if own_txn:
158
+ self.conn.rollback()
159
+ raise
160
+ # Update FTS and vec
161
+ self._fts_upsert(
162
+ "interactions_fts",
163
+ iid,
164
+ content=interaction.content,
165
+ user_action_description=interaction.user_action_description,
166
+ )
167
+ if interaction.embedding:
168
+ self._vec_upsert("interactions_vec", iid, interaction.embedding)
169
+ return iid
170
+
171
+ @SQLiteStorageBase.handle_exceptions
172
+ def add_user_interactions_bulk(
173
+ self,
174
+ user_id: str, # noqa: ARG002
175
+ interactions: list[Interaction],
176
+ *,
177
+ embeddings_prepared: bool = False,
178
+ ) -> None:
179
+ if not interactions:
180
+ return
181
+ if not embeddings_prepared:
182
+ # Only generate embeddings for interactions that do not already have them.
183
+ # This allows callers to pre-populate embeddings (e.g. via
184
+ # prepare_interaction_embeddings) before opening a commit_scope so that no
185
+ # network I/O occurs inside the transaction.
186
+ to_embed = [i for i in interactions if not i.embedding]
187
+ if to_embed:
188
+ texts = [
189
+ "\n".join([i.content or "", i.user_action_description or ""])
190
+ for i in to_embed
191
+ ]
192
+ try:
193
+ embeddings = self.llm_client.get_embeddings(
194
+ texts, self.embedding_model_name, self.embedding_dimensions
195
+ )
196
+ except EmbeddingUnavailableError as exc:
197
+ logger.warning(
198
+ "Embedding unavailable for interaction bulk insert; "
199
+ "continuing without vectors: %s",
200
+ exc,
201
+ )
202
+ embeddings = [[] for _ in texts]
203
+ for interaction, embedding in zip(to_embed, embeddings, strict=False):
204
+ interaction.embedding = embedding
205
+ for interaction in interactions:
206
+ self._insert_interaction(interaction)
207
+
208
+ @SQLiteStorageBase.handle_exceptions
209
+ def prepare_interaction_embeddings(self, interactions: list[Interaction]) -> None:
210
+ """Pre-populate interaction.embedding for each interaction (no DB write).
211
+
212
+ Generates embeddings in one batch call so the write path inside a
213
+ commit_scope can skip the network round-trip.
214
+ """
215
+ if not interactions:
216
+ return
217
+ to_embed = [i for i in interactions if not i.embedding]
218
+ if not to_embed:
219
+ return
220
+ texts = [
221
+ "\n".join([i.content or "", i.user_action_description or ""])
222
+ for i in to_embed
223
+ ]
224
+ try:
225
+ embeddings = self.llm_client.get_embeddings(
226
+ texts, self.embedding_model_name, self.embedding_dimensions
227
+ )
228
+ except EmbeddingUnavailableError as exc:
229
+ logger.warning(
230
+ "Embedding unavailable during prepare_interaction_embeddings; "
231
+ "continuing without vectors: %s",
232
+ exc,
233
+ )
234
+ embeddings = [[] for _ in texts]
235
+ for interaction, embedding in zip(to_embed, embeddings, strict=False):
236
+ interaction.embedding = embedding
237
+
238
+ @SQLiteStorageBase.handle_exceptions
239
+ def delete_user_interaction(self, request: DeleteUserInteractionRequest) -> None:
240
+ with self._lock:
241
+ row = self.conn.execute(
242
+ "SELECT interaction_id FROM interactions WHERE user_id = ? AND interaction_id = ?",
243
+ (request.user_id, request.interaction_id),
244
+ ).fetchone()
245
+ if row is None:
246
+ return
247
+ self.conn.execute(
248
+ "DELETE FROM interactions_fts WHERE rowid = ?",
249
+ (request.interaction_id,),
250
+ )
251
+ if self._has_sqlite_vec:
252
+ self.conn.execute(
253
+ "DELETE FROM interactions_vec WHERE rowid = ?",
254
+ (request.interaction_id,),
255
+ )
256
+ self.conn.execute(
257
+ "DELETE FROM interactions WHERE user_id = ? AND interaction_id = ?",
258
+ (request.user_id, request.interaction_id),
259
+ )
260
+ self.conn.commit()
261
+
262
+ @SQLiteStorageBase.handle_exceptions
263
+ def delete_all_interactions_for_user(self, user_id: str) -> None:
264
+ with self._lock:
265
+ rows = self.conn.execute(
266
+ "SELECT interaction_id FROM interactions WHERE user_id = ?", (user_id,)
267
+ ).fetchall()
268
+ if not rows:
269
+ return
270
+ ids = [r["interaction_id"] for r in rows]
271
+ self._delete_in_chunks("interactions_fts", "rowid", ids)
272
+ if self._has_sqlite_vec:
273
+ self._delete_in_chunks("interactions_vec", "rowid", ids)
274
+ self.conn.execute("DELETE FROM interactions WHERE user_id = ?", (user_id,))
275
+ self.conn.commit()
276
+
277
+ @SQLiteStorageBase.handle_exceptions
278
+ def delete_all_interactions(self) -> None:
279
+ with self._lock:
280
+ self.conn.execute("DELETE FROM interactions_fts")
281
+ if self._has_sqlite_vec:
282
+ self.conn.execute("DELETE FROM interactions_vec")
283
+ self.conn.execute("DELETE FROM interactions")
284
+ self.conn.commit()
285
+
286
+ @SQLiteStorageBase.handle_exceptions
287
+ def count_all_interactions(self) -> int:
288
+ row = self._fetchone("SELECT COUNT(*) as cnt FROM interactions")
289
+ return row["cnt"] if row else 0
290
+
291
+ @SQLiteStorageBase.handle_exceptions
292
+ def delete_oldest_interactions(self, count: int) -> int:
293
+ if count <= 0:
294
+ return 0
295
+ with self._lock:
296
+ rows = self.conn.execute(
297
+ "SELECT interaction_id FROM interactions ORDER BY created_at ASC LIMIT ?",
298
+ (count,),
299
+ ).fetchall()
300
+ if not rows:
301
+ return 0
302
+ ids = [r["interaction_id"] for r in rows]
303
+ self._delete_in_chunks("interactions_fts", "rowid", ids)
304
+ if self._has_sqlite_vec:
305
+ self._delete_in_chunks("interactions_vec", "rowid", ids)
306
+ self._delete_in_chunks("interactions", "interaction_id", ids)
307
+ self.conn.commit()
308
+ return len(ids)