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
@@ -0,0 +1,920 @@
1
+ """Profile store CRUD methods for SQLite storage.
2
+
3
+ Extracted verbatim from ``_profiles.py`` (the ProfileStore bucket). Interaction
4
+ methods live in ``profiles._interaction_store`` (``InteractionStoreMixin``);
5
+ search methods live in ``profiles._search`` (``ProfileSearchMixin``).
6
+ """
7
+
8
+ import logging
9
+ import sqlite3
10
+ import uuid
11
+ from concurrent.futures import ThreadPoolExecutor
12
+ from typing import Any
13
+
14
+ from reflexio.models.api_schema.service_schemas import (
15
+ DeleteUserProfileRequest,
16
+ Status,
17
+ UserProfile,
18
+ )
19
+
20
+ from .._base import (
21
+ _PROFILE_TOMBSTONE_STATUS_VALUES,
22
+ SQLiteStorageBase,
23
+ _build_status_sql,
24
+ _epoch_now,
25
+ _iso_now,
26
+ _json_dumps,
27
+ _row_to_profile,
28
+ )
29
+ from .._lineage import _GC_ELIGIBLE_STATUSES, _append_event_stmt
30
+ from .._profiles import _build_tags_sql
31
+
32
+ logger = logging.getLogger(__name__)
33
+
34
+
35
+ def _emit_hard_delete_profile(
36
+ conn: sqlite3.Connection,
37
+ *,
38
+ org_id: str,
39
+ entity_id: str,
40
+ request_id: str,
41
+ actor: str = "api",
42
+ ) -> None:
43
+ """Emit a single hard_delete lineage event for a profile entity."""
44
+ _append_event_stmt(
45
+ conn,
46
+ org_id=org_id,
47
+ entity_type="profile",
48
+ entity_id=entity_id,
49
+ op="hard_delete",
50
+ prov="wasInvalidatedBy",
51
+ source_ids=[],
52
+ actor=actor,
53
+ request_id=request_id,
54
+ reason="erasure",
55
+ )
56
+
57
+
58
+ def _escape_like_pattern(value: str) -> str:
59
+ return value.replace("\\", "\\\\").replace("%", "\\%").replace("_", "\\_")
60
+
61
+
62
+ class ProfileStoreMixin:
63
+ """Mixin providing profile-store CRUD for SQLite storage."""
64
+
65
+ # Type hints for instance attributes/methods provided by SQLiteStorageBase via MRO
66
+ _lock: Any
67
+ conn: sqlite3.Connection
68
+ org_id: str
69
+ _fetchone: Any
70
+ _fetchall: Any
71
+ _get_embedding: Any
72
+ _should_expand_documents: Any
73
+ _expand_document: Any
74
+ _fts_upsert_profile: Any
75
+ _vec_upsert: Any
76
+ _delete_in_chunks: Any
77
+ _has_sqlite_vec: bool
78
+ _subject_ref_for_user_id: Any
79
+ _assert_subject_writable_locked: Any
80
+ _own_transaction: Any
81
+
82
+ def _subject_ref_from_profile_row(self, row: sqlite3.Row) -> str:
83
+ subject_ref = row["governance_subject_ref"]
84
+ return (
85
+ str(subject_ref)
86
+ if subject_ref
87
+ else self._subject_ref_for_user_id(row["user_id"])
88
+ )
89
+
90
+ def _assert_profile_writable_locked(
91
+ self,
92
+ profile_id: str,
93
+ *,
94
+ user_id: str | None = None,
95
+ ) -> sqlite3.Row | None:
96
+ if user_id is None:
97
+ row = self.conn.execute(
98
+ "SELECT user_id, governance_subject_ref FROM profiles WHERE profile_id = ?",
99
+ (profile_id,),
100
+ ).fetchone()
101
+ else:
102
+ row = self.conn.execute(
103
+ "SELECT user_id, governance_subject_ref FROM profiles WHERE profile_id = ? AND user_id = ?",
104
+ (profile_id, user_id),
105
+ ).fetchone()
106
+ if row is None:
107
+ return None
108
+ self._assert_subject_writable_locked(self._subject_ref_from_profile_row(row))
109
+ return row
110
+
111
+ @SQLiteStorageBase.handle_exceptions
112
+ def get_all_profiles(
113
+ self,
114
+ limit: int = 100,
115
+ status_filter: list[Status | None] | None = None,
116
+ user_id: str | None = None,
117
+ profile_id: str | None = None,
118
+ query: str | None = None,
119
+ source: str | None = None,
120
+ profile_time_to_live: str | None = None,
121
+ start_time: int | None = None,
122
+ end_time: int | None = None,
123
+ ) -> list[UserProfile]:
124
+ if status_filter is None:
125
+ status_filter = [None]
126
+ frag, params = _build_status_sql(status_filter)
127
+ conditions = [frag]
128
+ if user_id:
129
+ conditions.append("user_id = ?")
130
+ params.append(user_id)
131
+ if profile_id:
132
+ conditions.append("LOWER(profile_id) = LOWER(?)")
133
+ params.append(profile_id)
134
+ if query:
135
+ like = f"%{_escape_like_pattern(query.lower())}%"
136
+ conditions.append(
137
+ "(LOWER(content) LIKE ? ESCAPE '\\' OR LOWER(profile_id) LIKE ? ESCAPE '\\' OR LOWER(user_id) LIKE ? ESCAPE '\\')"
138
+ )
139
+ params.extend([like, like, like])
140
+ if source is not None:
141
+ conditions.append("source = ?")
142
+ params.append(source)
143
+ if profile_time_to_live:
144
+ conditions.append("profile_time_to_live = ?")
145
+ params.append(profile_time_to_live)
146
+ if start_time is not None:
147
+ conditions.append("last_modified_timestamp >= ?")
148
+ params.append(start_time)
149
+ if end_time is not None:
150
+ conditions.append("last_modified_timestamp <= ?")
151
+ params.append(end_time)
152
+ sql = (
153
+ f"SELECT * FROM profiles WHERE {' AND '.join(conditions)} "
154
+ "ORDER BY last_modified_timestamp DESC LIMIT ?"
155
+ )
156
+ params.append(limit)
157
+ return [_row_to_profile(r) for r in self._fetchall(sql, params)]
158
+
159
+ @SQLiteStorageBase.handle_exceptions
160
+ def get_user_profile(
161
+ self,
162
+ user_id: str,
163
+ status_filter: list[Status | None] | None = None,
164
+ tags: list[str] | None = None,
165
+ profile_id: str | None = None,
166
+ query: str | None = None,
167
+ source: str | None = None,
168
+ profile_time_to_live: str | None = None,
169
+ start_time: int | None = None,
170
+ end_time: int | None = None,
171
+ include_expired: bool = False,
172
+ ) -> list[UserProfile]:
173
+ if status_filter is None:
174
+ status_filter = [None]
175
+ frag, params = _build_status_sql(status_filter)
176
+ conditions: list[str] = ["user_id = ?"]
177
+ all_params: list[Any] = [user_id]
178
+ if not include_expired:
179
+ conditions.append("expiration_timestamp >= ?")
180
+ all_params.append(_epoch_now())
181
+ conditions.append(frag)
182
+ all_params.extend(params)
183
+ if profile_id:
184
+ conditions.append("LOWER(profile_id) = LOWER(?)")
185
+ all_params.append(profile_id)
186
+ if query:
187
+ like = f"%{_escape_like_pattern(query.lower())}%"
188
+ conditions.append(
189
+ "(LOWER(content) LIKE ? ESCAPE '\\' OR LOWER(profile_id) LIKE ? ESCAPE '\\' OR LOWER(user_id) LIKE ? ESCAPE '\\')"
190
+ )
191
+ all_params.extend([like, like, like])
192
+ if source is not None:
193
+ conditions.append("source = ?")
194
+ all_params.append(source)
195
+ if profile_time_to_live:
196
+ conditions.append("profile_time_to_live = ?")
197
+ all_params.append(profile_time_to_live)
198
+ if start_time is not None:
199
+ conditions.append("last_modified_timestamp >= ?")
200
+ all_params.append(start_time)
201
+ if end_time is not None:
202
+ conditions.append("last_modified_timestamp <= ?")
203
+ all_params.append(end_time)
204
+ tag_frag, tag_params = _build_tags_sql("profiles", tags)
205
+ if tag_frag:
206
+ conditions.append(tag_frag)
207
+ all_params.extend(tag_params)
208
+ sql = f"SELECT * FROM profiles WHERE {' AND '.join(conditions)}"
209
+ return [_row_to_profile(r) for r in self._fetchall(sql, all_params)]
210
+
211
+ @SQLiteStorageBase.handle_exceptions
212
+ def add_user_profile(self, user_id: str, user_profiles: list[UserProfile]) -> None: # noqa: ARG002
213
+ for profile in user_profiles:
214
+ subject_ref = self._subject_ref_for_user_id(profile.user_id)
215
+ with self._lock:
216
+ self._assert_subject_writable_locked(subject_ref)
217
+ embedding_text = "\n".join([profile.content, str(profile.custom_features)])
218
+ if self._should_expand_documents():
219
+ with ThreadPoolExecutor(max_workers=2) as executor:
220
+ emb_future = executor.submit(self._get_embedding, embedding_text)
221
+ exp_future = executor.submit(self._expand_document, profile.content)
222
+ profile.embedding = emb_future.result(timeout=15)
223
+ profile.expanded_terms = exp_future.result(timeout=15)
224
+ else:
225
+ profile.embedding = self._get_embedding(embedding_text)
226
+ embedding = profile.embedding
227
+ with self._lock:
228
+ own_txn = self._own_transaction()
229
+ try:
230
+ if own_txn:
231
+ self.conn.execute("BEGIN IMMEDIATE")
232
+ self._assert_subject_writable_locked(subject_ref)
233
+ self.conn.execute(
234
+ """INSERT OR REPLACE INTO profiles
235
+ (profile_id, user_id, content, last_modified_timestamp,
236
+ generated_from_request_id, profile_time_to_live,
237
+ expiration_timestamp, custom_features, embedding, source,
238
+ status, extractor_names, expanded_terms,
239
+ source_span, notes, reader_angle, tags, source_interaction_ids, created_at,
240
+ merged_into, superseded_by, governance_subject_ref)
241
+ VALUES (?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?)""",
242
+ (
243
+ profile.profile_id,
244
+ profile.user_id,
245
+ profile.content,
246
+ profile.last_modified_timestamp,
247
+ profile.generated_from_request_id,
248
+ profile.profile_time_to_live.value,
249
+ profile.expiration_timestamp,
250
+ _json_dumps(profile.custom_features),
251
+ _json_dumps(profile.embedding),
252
+ profile.source,
253
+ profile.status.value if profile.status else None,
254
+ _json_dumps(profile.extractor_names),
255
+ profile.expanded_terms,
256
+ profile.source_span,
257
+ profile.notes,
258
+ profile.reader_angle,
259
+ _json_dumps(profile.tags),
260
+ _json_dumps(profile.source_interaction_ids),
261
+ _iso_now(),
262
+ profile.merged_into,
263
+ profile.superseded_by,
264
+ subject_ref,
265
+ ),
266
+ )
267
+ if own_txn:
268
+ self.conn.commit()
269
+ except Exception:
270
+ if own_txn:
271
+ self.conn.rollback()
272
+ raise
273
+ fts_parts = [profile.content or ""]
274
+ if profile.custom_features:
275
+ fts_parts.extend(str(v) for v in profile.custom_features.values() if v)
276
+ if profile.expanded_terms:
277
+ fts_parts.append(profile.expanded_terms)
278
+ self._fts_upsert_profile(profile.profile_id, " ".join(fts_parts))
279
+ # Sync vec table — look up implicit rowid via primary key
280
+ row = self._fetchone(
281
+ "SELECT rowid FROM profiles WHERE profile_id = ?",
282
+ (profile.profile_id,),
283
+ )
284
+ if row and embedding:
285
+ self._vec_upsert("profiles_vec", row["rowid"], embedding)
286
+
287
+ @SQLiteStorageBase.handle_exceptions
288
+ def update_user_profile_by_id(
289
+ self, user_id: str, profile_id: str, new_profile: UserProfile
290
+ ) -> None:
291
+ """Replace a profile's content in-place and emit a revise lineage event.
292
+
293
+ Each call generates a fresh request_id so every edit is a distinct audit
294
+ event (not collapsed by the idempotency key). The UPDATE, lineage event,
295
+ FTS sync, and vec sync are all executed inside a single lock acquisition;
296
+ self._lock is an RLock so the inner _fts_upsert_profile/_vec_upsert calls
297
+ that re-acquire it are safe.
298
+ """
299
+ current_ts = _epoch_now()
300
+ with self._lock:
301
+ row = self.conn.execute(
302
+ "SELECT user_id, governance_subject_ref FROM profiles WHERE user_id = ? AND profile_id = ? AND expiration_timestamp >= ?",
303
+ (user_id, profile_id, current_ts),
304
+ ).fetchone()
305
+ if not row:
306
+ logger.warning("User profile not found for user id: %s", user_id)
307
+ return
308
+ self._assert_subject_writable_locked(
309
+ self._subject_ref_from_profile_row(row)
310
+ )
311
+ embedding = self._get_embedding(
312
+ "\n".join([new_profile.content, str(new_profile.custom_features)])
313
+ )
314
+ new_profile.embedding = embedding
315
+ with self._lock:
316
+ if (
317
+ self._assert_profile_writable_locked(profile_id, user_id=user_id)
318
+ is None
319
+ ):
320
+ logger.warning("User profile not found for user id: %s", user_id)
321
+ return
322
+ cur = self.conn.execute(
323
+ """UPDATE profiles SET content=?, last_modified_timestamp=?,
324
+ generated_from_request_id=?, profile_time_to_live=?,
325
+ expiration_timestamp=?, custom_features=?, embedding=?,
326
+ source=?, status=?, extractor_names=?, expanded_terms=?,
327
+ source_span=?, notes=?, reader_angle=?, tags=?, source_interaction_ids=?
328
+ WHERE profile_id=?""",
329
+ (
330
+ new_profile.content,
331
+ new_profile.last_modified_timestamp,
332
+ new_profile.generated_from_request_id,
333
+ new_profile.profile_time_to_live.value,
334
+ new_profile.expiration_timestamp,
335
+ _json_dumps(new_profile.custom_features),
336
+ _json_dumps(new_profile.embedding),
337
+ new_profile.source,
338
+ new_profile.status.value if new_profile.status else None,
339
+ _json_dumps(new_profile.extractor_names),
340
+ new_profile.expanded_terms,
341
+ new_profile.source_span,
342
+ new_profile.notes,
343
+ new_profile.reader_angle,
344
+ _json_dumps(new_profile.tags),
345
+ _json_dumps(new_profile.source_interaction_ids),
346
+ profile_id,
347
+ ),
348
+ )
349
+ if cur.rowcount > 0:
350
+ _append_event_stmt(
351
+ self.conn,
352
+ org_id=self.org_id,
353
+ entity_type="profile",
354
+ entity_id=str(profile_id),
355
+ op="revise",
356
+ prov="wasRevisionOf",
357
+ source_ids=[],
358
+ actor="api",
359
+ request_id=uuid.uuid4().hex,
360
+ reason="in-place update",
361
+ )
362
+ self.conn.commit()
363
+ fts_parts = [new_profile.content or ""]
364
+ if new_profile.custom_features:
365
+ fts_parts.extend(
366
+ str(v) for v in new_profile.custom_features.values() if v
367
+ )
368
+ if new_profile.expanded_terms:
369
+ fts_parts.append(new_profile.expanded_terms)
370
+ self._fts_upsert_profile(profile_id, " ".join(fts_parts))
371
+ rowid_row = self._fetchone(
372
+ "SELECT rowid FROM profiles WHERE profile_id = ?", (profile_id,)
373
+ )
374
+ if rowid_row and embedding:
375
+ self._vec_upsert("profiles_vec", rowid_row["rowid"], embedding)
376
+
377
+ @SQLiteStorageBase.handle_exceptions
378
+ def update_user_profile_tags(
379
+ self, user_id: str, profile_id: str, tags: list[str]
380
+ ) -> None:
381
+ with self._lock:
382
+ if (
383
+ self._assert_profile_writable_locked(profile_id, user_id=user_id)
384
+ is None
385
+ ):
386
+ return
387
+ self.conn.execute(
388
+ "UPDATE profiles SET tags=? WHERE user_id=? AND profile_id=?",
389
+ (_json_dumps(tags), user_id, profile_id),
390
+ )
391
+ self.conn.commit()
392
+
393
+ @SQLiteStorageBase.handle_exceptions
394
+ def delete_user_profile(self, request: DeleteUserProfileRequest) -> None:
395
+ # Atomic: fts + vec + row + lineage in ONE lock/commit to prevent rowid reuse
396
+ # race. profiles uses implicit (reusable) rowid keyed by TEXT PK — a cleanup
397
+ # running after commit could race with a concurrent INSERT reusing the freed
398
+ # rowid and delete the NEW profile's vec row. (#196)
399
+ with self._lock:
400
+ rowid_row = self.conn.execute(
401
+ "SELECT rowid FROM profiles WHERE user_id = ? AND profile_id = ?",
402
+ (request.user_id, request.profile_id),
403
+ ).fetchone()
404
+ if rowid_row is None:
405
+ return
406
+ self.conn.execute(
407
+ "DELETE FROM profiles_fts WHERE profile_id = ?",
408
+ (request.profile_id,),
409
+ )
410
+ if self._has_sqlite_vec and rowid_row:
411
+ self.conn.execute(
412
+ "DELETE FROM profiles_vec WHERE rowid = ?",
413
+ (rowid_row["rowid"],),
414
+ )
415
+ cur = self.conn.execute(
416
+ "DELETE FROM profiles WHERE user_id = ? AND profile_id = ?",
417
+ (request.user_id, request.profile_id),
418
+ )
419
+ if cur.rowcount > 0:
420
+ _emit_hard_delete_profile(
421
+ self.conn,
422
+ org_id=self.org_id,
423
+ entity_id=str(request.profile_id),
424
+ request_id=uuid.uuid4().hex,
425
+ )
426
+ self.conn.commit()
427
+
428
+ @SQLiteStorageBase.handle_exceptions
429
+ def delete_all_profiles_for_user(self, user_id: str) -> None:
430
+ # Atomic: fts + vec + row + lineage in ONE lock/commit — rowid reuse race
431
+ # prevention (see delete_user_profile comment, #196).
432
+ batch_request_id = uuid.uuid4().hex
433
+ with self._lock:
434
+ rows = self.conn.execute(
435
+ "SELECT rowid, profile_id FROM profiles WHERE user_id = ?", (user_id,)
436
+ ).fetchall()
437
+ if not rows:
438
+ return
439
+ pids = [r["profile_id"] for r in rows]
440
+ rowids = [r["rowid"] for r in rows]
441
+ self._delete_in_chunks("profiles_fts", "profile_id", pids)
442
+ if self._has_sqlite_vec and rowids:
443
+ self._delete_in_chunks("profiles_vec", "rowid", rowids)
444
+ self.conn.execute("DELETE FROM profiles WHERE user_id = ?", (user_id,))
445
+ for pid in pids:
446
+ _emit_hard_delete_profile(
447
+ self.conn,
448
+ org_id=self.org_id,
449
+ entity_id=str(pid),
450
+ request_id=batch_request_id,
451
+ )
452
+ self.conn.commit()
453
+
454
+ @SQLiteStorageBase.handle_exceptions
455
+ def delete_all_profiles(self) -> None:
456
+ # Also wipe profiles_vec (full-wipe variant of the rowid-race fix, #196).
457
+ batch_request_id = uuid.uuid4().hex
458
+ with self._lock:
459
+ pids = [
460
+ r["profile_id"]
461
+ for r in self.conn.execute("SELECT profile_id FROM profiles").fetchall()
462
+ ]
463
+ for pid in pids:
464
+ _emit_hard_delete_profile(
465
+ self.conn,
466
+ org_id=self.org_id,
467
+ entity_id=str(pid),
468
+ request_id=batch_request_id,
469
+ )
470
+ self.conn.execute("DELETE FROM profiles_fts")
471
+ if self._has_sqlite_vec:
472
+ self.conn.execute("DELETE FROM profiles_vec")
473
+ self.conn.execute("DELETE FROM profiles")
474
+ self.conn.commit()
475
+
476
+ @SQLiteStorageBase.handle_exceptions
477
+ def count_all_profiles(self) -> int:
478
+ row = self._fetchone("SELECT COUNT(*) as cnt FROM profiles")
479
+ return row["cnt"] if row else 0
480
+
481
+ @SQLiteStorageBase.handle_exceptions
482
+ def count_user_profiles_by_status(
483
+ self, user_ids: list[str], status: Status | None
484
+ ) -> int:
485
+ if not user_ids:
486
+ return 0
487
+
488
+ current_ts = _epoch_now()
489
+ status_frag, status_params = _build_status_sql([status])
490
+ ph = ",".join("?" for _ in user_ids)
491
+ sql = (
492
+ f"SELECT COUNT(*) as cnt FROM profiles "
493
+ f"WHERE user_id IN ({ph}) "
494
+ f"AND expiration_timestamp >= ? AND {status_frag}"
495
+ )
496
+ row = self._fetchone(sql, [*user_ids, current_ts, *status_params])
497
+ return row["cnt"] if row else 0
498
+
499
+ @SQLiteStorageBase.handle_exceptions
500
+ def update_all_profiles_status(
501
+ self,
502
+ old_status: Status | None,
503
+ new_status: Status | None,
504
+ user_ids: list[str] | None = None,
505
+ ) -> int:
506
+ new_val = new_status.value if new_status else None
507
+ now_ts = _epoch_now()
508
+ old_val_str = old_status.value if old_status else "None"
509
+ new_val_str = new_status.value if new_status else "None"
510
+ reason = f"{old_val_str}->{new_val_str}"
511
+
512
+ if old_status is None or (
513
+ hasattr(old_status, "value") and old_status.value is None
514
+ ):
515
+ where = "status IS NULL"
516
+ select_params: list[Any] = []
517
+ else:
518
+ where = "status = ?"
519
+ select_params = [old_status.value]
520
+
521
+ extra_params: list[Any] = []
522
+ if user_ids is not None:
523
+ placeholders = ",".join("?" for _ in user_ids)
524
+ where += f" AND user_id IN ({placeholders})"
525
+ extra_params.extend(user_ids)
526
+
527
+ # Set retired_at = now when transitioning to a GC-eligible status; clear to NULL otherwise.
528
+ retired_at_val = now_ts if new_val in _GC_ELIGIBLE_STATUSES else None
529
+
530
+ batch_request_id = uuid.uuid4().hex
531
+ with self._lock:
532
+ affected = list(
533
+ self.conn.execute(
534
+ f"SELECT profile_id, user_id, governance_subject_ref FROM profiles WHERE {where}",
535
+ select_params + extra_params,
536
+ ).fetchall()
537
+ )
538
+ for row in affected:
539
+ self._assert_subject_writable_locked(
540
+ self._subject_ref_from_profile_row(row)
541
+ )
542
+ cur = self.conn.execute(
543
+ f"UPDATE profiles SET status = ?, last_modified_timestamp = ?, retired_at = ? WHERE {where}",
544
+ [new_val, now_ts, retired_at_val] + select_params + extra_params,
545
+ )
546
+ from_val = old_status.value if old_status else None
547
+ to_val = new_status.value if new_status else None
548
+ for row in affected:
549
+ pid = row["profile_id"]
550
+ _append_event_stmt(
551
+ self.conn,
552
+ org_id=self.org_id,
553
+ entity_type="profile",
554
+ entity_id=str(pid),
555
+ op="status_change",
556
+ prov="wasInvalidatedBy",
557
+ source_ids=[],
558
+ actor="api",
559
+ request_id=batch_request_id,
560
+ reason=reason,
561
+ from_status=from_val,
562
+ to_status=to_val,
563
+ status_namespace="lifecycle_status",
564
+ )
565
+ self.conn.commit()
566
+ return cur.rowcount
567
+
568
+ @SQLiteStorageBase.handle_exceptions
569
+ def expire_active_profiles(self, *, now: int, limit: int = 1000) -> int:
570
+ """Tombstone active profiles whose TTL has elapsed.
571
+
572
+ Args:
573
+ now: Current epoch timestamp (seconds). Profiles with
574
+ ``expiration_timestamp < now`` are tombstoned.
575
+ limit: Maximum number of profiles to tombstone in one call (default 1000).
576
+
577
+ Returns:
578
+ int: Number of profiles tombstoned.
579
+ """
580
+ if limit <= 0:
581
+ return 0
582
+ batch_request_id = uuid.uuid4().hex
583
+ with self._lock:
584
+ # BEGIN IMMEDIATE acquires a write lock immediately so no other
585
+ # connection can write between the SELECT and the UPDATE. This
586
+ # ensures the affected-id list == the actually-updated set, making
587
+ # the emitted status_change events correct. Matches the pattern
588
+ # used by expire_pending_tool_calls in sqlite_storage/_agent_run.py.
589
+ self.conn.execute("BEGIN IMMEDIATE")
590
+ try:
591
+ affected = list(
592
+ self.conn.execute(
593
+ "SELECT profile_id, user_id, governance_subject_ref FROM profiles "
594
+ "WHERE status IS NULL AND expiration_timestamp < ? "
595
+ "ORDER BY expiration_timestamp ASC LIMIT ?",
596
+ (now, limit),
597
+ ).fetchall()
598
+ )
599
+ if not affected:
600
+ self.conn.commit()
601
+ return 0
602
+ for row in affected:
603
+ self._assert_subject_writable_locked(
604
+ self._subject_ref_from_profile_row(row)
605
+ )
606
+ ids = [r["profile_id"] for r in affected]
607
+ ph = ",".join("?" * len(ids))
608
+ cur = self.conn.execute(
609
+ f"UPDATE profiles SET status = ?, retired_at = ?, last_modified_timestamp = ? " # noqa: S608
610
+ f"WHERE profile_id IN ({ph}) AND status IS NULL",
611
+ [Status.EXPIRED.value, now, now, *ids],
612
+ )
613
+ for row in affected:
614
+ _append_event_stmt(
615
+ self.conn,
616
+ org_id=self.org_id,
617
+ entity_type="profile",
618
+ entity_id=str(row["profile_id"]),
619
+ op="status_change",
620
+ prov="wasInvalidatedBy",
621
+ source_ids=[],
622
+ actor="system",
623
+ request_id=batch_request_id,
624
+ reason="ttl-expired",
625
+ from_status=None,
626
+ to_status=Status.EXPIRED.value,
627
+ status_namespace="lifecycle_status",
628
+ )
629
+ except Exception:
630
+ self.conn.rollback()
631
+ raise
632
+ else:
633
+ self.conn.commit()
634
+ return cur.rowcount
635
+
636
+ @SQLiteStorageBase.handle_exceptions
637
+ def get_profiles_by_ids(
638
+ self,
639
+ user_id: str,
640
+ profile_ids: list[str],
641
+ status_filter: list[Status | None] | None = None,
642
+ ) -> list[UserProfile]:
643
+ if not profile_ids:
644
+ return []
645
+ if status_filter is None:
646
+ status_filter = [None]
647
+ current_ts = _epoch_now()
648
+ frag, sparams = _build_status_sql(status_filter)
649
+ ph = ",".join("?" for _ in profile_ids)
650
+ sql = (
651
+ f"SELECT * FROM profiles "
652
+ f"WHERE user_id = ? AND profile_id IN ({ph}) "
653
+ f"AND expiration_timestamp >= ? AND {frag}"
654
+ )
655
+ params: list[Any] = [user_id, *profile_ids, current_ts, *sparams]
656
+ return [_row_to_profile(r) for r in self._fetchall(sql, params)]
657
+
658
+ @SQLiteStorageBase.handle_exceptions
659
+ def get_profile_by_id(
660
+ self, profile_id: str, *, include_tombstones: bool = False
661
+ ) -> UserProfile | None:
662
+ """Fetch a single profile by primary key.
663
+
664
+ Args:
665
+ profile_id: The profile's primary key.
666
+ include_tombstones: When False (default), MERGED/SUPERSEDED/EXPIRED profiles
667
+ return None. Set to True for lineage resolution (resolve_current).
668
+
669
+ Returns:
670
+ The UserProfile if found and not filtered, otherwise None.
671
+ """
672
+ sql = "SELECT * FROM profiles WHERE profile_id = ?"
673
+ if not include_tombstones:
674
+ ph = ",".join("?" * len(_PROFILE_TOMBSTONE_STATUS_VALUES))
675
+ sql += f" AND (status IS NULL OR status NOT IN ({ph}))"
676
+ row = self._fetchone(sql, (profile_id, *_PROFILE_TOMBSTONE_STATUS_VALUES))
677
+ else:
678
+ row = self._fetchone(sql, (profile_id,))
679
+ return _row_to_profile(row) if row else None
680
+
681
+ @SQLiteStorageBase.handle_exceptions
682
+ def get_distinct_generated_from_request_ids(self) -> list[str]:
683
+ """Return DISTINCT non-empty generated_from_request_id values, including tombstones.
684
+
685
+ Returns:
686
+ list[str]: Distinct non-empty ``generated_from_request_id`` values.
687
+ """
688
+ rows = self._fetchall(
689
+ "SELECT DISTINCT generated_from_request_id FROM profiles"
690
+ " WHERE generated_from_request_id IS NOT NULL"
691
+ " AND generated_from_request_id != ''",
692
+ (),
693
+ )
694
+ return [row[0] for row in rows]
695
+
696
+ @SQLiteStorageBase.handle_exceptions
697
+ def get_profiles_by_generated_from_request_id(
698
+ self,
699
+ request_id: str,
700
+ ) -> list[UserProfile]:
701
+ """Return all profiles for a generated_from_request_id, including tombstones.
702
+
703
+ Args:
704
+ request_id (str): The generated_from_request_id to filter on.
705
+
706
+ Returns:
707
+ list[UserProfile]: All matching profiles (any status).
708
+ """
709
+ rows = self._fetchall(
710
+ "SELECT * FROM profiles WHERE generated_from_request_id = ?",
711
+ (request_id,),
712
+ )
713
+ return [_row_to_profile(r) for r in rows]
714
+
715
+ def get_all_generated_profiles(self) -> list[UserProfile]:
716
+ """All profiles (any status) with a non-empty generated_from_request_id."""
717
+ rows = self._fetchall(
718
+ "SELECT * FROM profiles "
719
+ "WHERE generated_from_request_id IS NOT NULL "
720
+ "AND generated_from_request_id <> ''",
721
+ )
722
+ return [_row_to_profile(r) for r in rows]
723
+
724
+ @SQLiteStorageBase.handle_exceptions
725
+ def archive_profile_by_id(self, user_id: str, profile_id: str) -> bool:
726
+ with self._lock:
727
+ if (
728
+ self._assert_profile_writable_locked(profile_id, user_id=user_id)
729
+ is None
730
+ ):
731
+ return False
732
+ now_ts = _epoch_now()
733
+ cur = self.conn.execute(
734
+ "UPDATE profiles SET status = ?, last_modified_timestamp = ?, retired_at = ? "
735
+ "WHERE profile_id = ? AND user_id = ? AND status IS NULL",
736
+ (Status.ARCHIVED.value, now_ts, now_ts, profile_id, user_id),
737
+ )
738
+ if cur.rowcount > 0:
739
+ _append_event_stmt(
740
+ self.conn,
741
+ org_id=self.org_id,
742
+ entity_type="profile",
743
+ entity_id=str(profile_id),
744
+ op="status_change",
745
+ prov="wasInvalidatedBy",
746
+ source_ids=[],
747
+ actor="api",
748
+ request_id=uuid.uuid4().hex,
749
+ reason="None->archived",
750
+ from_status=None,
751
+ to_status="archived",
752
+ status_namespace="lifecycle_status",
753
+ )
754
+ self.conn.commit()
755
+ return cur.rowcount > 0
756
+
757
+ @SQLiteStorageBase.handle_exceptions
758
+ def supersede_profiles_by_ids(
759
+ self,
760
+ user_id: str,
761
+ profile_ids: list[str],
762
+ request_id: str,
763
+ ) -> list[str]:
764
+ """Soft-delete profiles by setting status to SUPERSEDED, emitting set-based lineage.
765
+
766
+ For each matching id (user_id scoped, currently CURRENT), updates status to
767
+ SUPERSEDED and emits one ``status_change`` event under the shared ``request_id``.
768
+ Atomic: one ``conn.commit()`` at the end, guarded on rowcount per id.
769
+ FTS/vec rows are NOT removed — reads exclude tombstones by status filter.
770
+
771
+ Args:
772
+ user_id (str): Owning user id.
773
+ profile_ids (list[str]): Profile ids to supersede.
774
+ request_id (str): Shared request id for all emitted lineage events.
775
+
776
+ Returns:
777
+ list[str]: The profile ids actually superseded by this call, in input order
778
+ (already-superseded or absent ids are omitted).
779
+ """
780
+ if not profile_ids:
781
+ return []
782
+ if not request_id:
783
+ raise ValueError("request_id must be non-empty for supersede")
784
+ now_ts = _epoch_now()
785
+ # Eligibility: CURRENT (NULL) or PENDING — the two live statuses dedup can target.
786
+ eligible = (None, Status.PENDING.value)
787
+ committed_ids: list[str] = []
788
+ with self._lock:
789
+ for pid in profile_ids:
790
+ # Read current status for from_status derivation (user_id scoped)
791
+ row = self.conn.execute(
792
+ "SELECT status, user_id, governance_subject_ref FROM profiles WHERE profile_id = ? AND user_id = ?",
793
+ (pid, user_id),
794
+ ).fetchone()
795
+ if row is None:
796
+ continue
797
+ self._assert_subject_writable_locked(
798
+ self._subject_ref_from_profile_row(row)
799
+ )
800
+ old_status_val = (
801
+ row[0] if isinstance(row, (tuple, list)) else row["status"]
802
+ )
803
+ if old_status_val not in eligible:
804
+ continue
805
+ cur = self.conn.execute(
806
+ "UPDATE profiles SET status = ?, last_modified_timestamp = ?, retired_at = ? "
807
+ "WHERE profile_id = ? AND user_id = ? "
808
+ "AND (status IS NULL OR status = ?)",
809
+ (
810
+ Status.SUPERSEDED.value,
811
+ now_ts,
812
+ now_ts,
813
+ pid,
814
+ user_id,
815
+ Status.PENDING.value,
816
+ ),
817
+ )
818
+ if cur.rowcount > 0:
819
+ _append_event_stmt(
820
+ self.conn,
821
+ org_id=self.org_id,
822
+ entity_type="profile",
823
+ entity_id=str(pid),
824
+ op="status_change",
825
+ prov="wasInvalidatedBy",
826
+ source_ids=[],
827
+ actor="dedup",
828
+ request_id=request_id,
829
+ reason=f"{old_status_val}->superseded",
830
+ from_status=old_status_val,
831
+ to_status=Status.SUPERSEDED.value,
832
+ status_namespace="lifecycle_status",
833
+ )
834
+ committed_ids.append(pid)
835
+ if self._own_transaction():
836
+ self.conn.commit()
837
+ return committed_ids
838
+
839
+ @SQLiteStorageBase.handle_exceptions
840
+ def delete_all_profiles_by_status(self, status: Status) -> int:
841
+ # Atomic: fts + vec + row + lineage in ONE lock/commit — rowid reuse race
842
+ # prevention (see delete_user_profile comment, #196).
843
+ batch_request_id = uuid.uuid4().hex
844
+ with self._lock:
845
+ rows = self.conn.execute(
846
+ "SELECT rowid, profile_id FROM profiles WHERE status = ?",
847
+ (status.value,),
848
+ ).fetchall()
849
+ if not rows:
850
+ return 0
851
+ pids = [r["profile_id"] for r in rows]
852
+ rowids = [r["rowid"] for r in rows]
853
+ self._delete_in_chunks("profiles_fts", "profile_id", pids)
854
+ if self._has_sqlite_vec and rowids:
855
+ self._delete_in_chunks("profiles_vec", "rowid", rowids)
856
+ ph = ",".join("?" for _ in pids)
857
+ cur = self.conn.execute(
858
+ f"DELETE FROM profiles WHERE profile_id IN ({ph})",
859
+ pids, # noqa: S608
860
+ )
861
+ for pid in pids:
862
+ _emit_hard_delete_profile(
863
+ self.conn,
864
+ org_id=self.org_id,
865
+ entity_id=str(pid),
866
+ request_id=batch_request_id,
867
+ )
868
+ self.conn.commit()
869
+ return cur.rowcount
870
+
871
+ @SQLiteStorageBase.handle_exceptions
872
+ def get_user_ids_with_status(self, status: Status | None) -> list[str]:
873
+ if status is None or (hasattr(status, "value") and status.value is None):
874
+ rows = self._fetchall(
875
+ "SELECT DISTINCT user_id FROM profiles WHERE status IS NULL"
876
+ )
877
+ else:
878
+ rows = self._fetchall(
879
+ "SELECT DISTINCT user_id FROM profiles WHERE status = ?",
880
+ (status.value,),
881
+ )
882
+ return [r["user_id"] for r in rows]
883
+
884
+ @SQLiteStorageBase.handle_exceptions
885
+ def delete_profiles_by_ids(
886
+ self, profile_ids: list[str], *, emit_hard_delete: bool = True
887
+ ) -> int:
888
+ if not profile_ids:
889
+ return 0
890
+ # Atomic: fts + vec + row + lineage in ONE lock/commit — rowid reuse race
891
+ # prevention (see delete_user_profile comment, #196).
892
+ ph = ",".join("?" for _ in profile_ids)
893
+ batch_request_id = uuid.uuid4().hex
894
+ with self._lock:
895
+ pre_rows = self.conn.execute(
896
+ f"SELECT rowid, profile_id FROM profiles WHERE profile_id IN ({ph})",
897
+ profile_ids,
898
+ ).fetchall()
899
+ if not pre_rows:
900
+ return 0
901
+ existing = [r["profile_id"] for r in pre_rows]
902
+ rowids = [r["rowid"] for r in pre_rows]
903
+ self._delete_in_chunks("profiles_fts", "profile_id", existing)
904
+ if self._has_sqlite_vec and rowids:
905
+ self._delete_in_chunks("profiles_vec", "rowid", rowids)
906
+ cur = self.conn.execute(
907
+ f"DELETE FROM profiles WHERE profile_id IN ({ph})",
908
+ profile_ids, # noqa: S608
909
+ )
910
+ if emit_hard_delete:
911
+ for pid in existing:
912
+ _emit_hard_delete_profile(
913
+ self.conn,
914
+ org_id=self.org_id,
915
+ entity_id=str(pid),
916
+ request_id=batch_request_id,
917
+ actor="system",
918
+ )
919
+ self.conn.commit()
920
+ return cur.rowcount