claude-smart 0.2.46 → 0.2.47

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 (85) hide show
  1. package/.claude-plugin/marketplace.json +1 -1
  2. package/README.md +19 -11
  3. package/bin/claude-smart.js +290 -68
  4. package/package.json +1 -1
  5. package/plugin/.claude-plugin/plugin.json +1 -1
  6. package/plugin/.codex-plugin/plugin.json +1 -1
  7. package/plugin/README.md +11 -10
  8. package/plugin/dashboard/app/layout.tsx +20 -0
  9. package/plugin/opencode/dist/server.mjs +76 -2
  10. package/plugin/opencode/server.mts +79 -2
  11. package/plugin/pyproject.toml +6 -2
  12. package/plugin/scripts/smart-install.sh +7 -1
  13. package/plugin/src/claude_smart/cli.py +210 -22
  14. package/plugin/src/claude_smart/context_format.py +9 -9
  15. package/plugin/src/claude_smart/cs_cite.py +66 -19
  16. package/plugin/uv.lock +5 -5
  17. package/plugin/vendor/reflexio/README.md +3 -3
  18. package/plugin/vendor/reflexio/pyproject.toml +1 -1
  19. package/plugin/vendor/reflexio/reflexio/README.md +3 -1
  20. package/plugin/vendor/reflexio/reflexio/cli/bootstrap_config.py +1 -1
  21. package/plugin/vendor/reflexio/reflexio/cli/commands/setup_cmd.py +2 -2
  22. package/plugin/vendor/reflexio/reflexio/cli/utils.py +44 -1
  23. package/plugin/vendor/reflexio/reflexio/client/client.py +97 -0
  24. package/plugin/vendor/reflexio/reflexio/lib/_agent_playbook.py +8 -0
  25. package/plugin/vendor/reflexio/reflexio/lib/_base.py +15 -0
  26. package/plugin/vendor/reflexio/reflexio/lib/_generation.py +9 -8
  27. package/plugin/vendor/reflexio/reflexio/lib/_profiles.py +27 -16
  28. package/plugin/vendor/reflexio/reflexio/lib/_search.py +23 -5
  29. package/plugin/vendor/reflexio/reflexio/lib/_user_playbook.py +9 -0
  30. package/plugin/vendor/reflexio/reflexio/models/api_schema/domain/__init__.py +1 -0
  31. package/plugin/vendor/reflexio/reflexio/models/api_schema/domain/governance.py +117 -0
  32. package/plugin/vendor/reflexio/reflexio/models/api_schema/retriever_schema.py +45 -3
  33. package/plugin/vendor/reflexio/reflexio/models/config_schema.py +16 -0
  34. package/plugin/vendor/reflexio/reflexio/server/README.md +14 -2
  35. package/plugin/vendor/reflexio/reflexio/server/api.py +176 -29
  36. package/plugin/vendor/reflexio/reflexio/server/api_endpoints/request_context.py +1 -1
  37. package/plugin/vendor/reflexio/reflexio/server/{_auth.py → auth.py} +2 -0
  38. package/plugin/vendor/reflexio/reflexio/server/extensions.py +213 -0
  39. package/plugin/vendor/reflexio/reflexio/server/llm/model_defaults.py +4 -4
  40. package/plugin/vendor/reflexio/reflexio/server/llm/providers/claude_code_provider.py +1 -1
  41. package/plugin/vendor/reflexio/reflexio/server/llm/rerank/cross_encoder_reranker.py +12 -1
  42. package/plugin/vendor/reflexio/reflexio/server/prompt/prompt_bank/playbook_extraction_context/v4.4.0.prompt.md +14 -2
  43. package/plugin/vendor/reflexio/reflexio/server/services/README.md +3 -1
  44. package/plugin/vendor/reflexio/reflexio/server/services/extraction/resume_worker.py +8 -0
  45. package/plugin/vendor/reflexio/reflexio/server/services/governance/config.py +52 -0
  46. package/plugin/vendor/reflexio/reflexio/server/services/governance/service.py +378 -0
  47. package/plugin/vendor/reflexio/reflexio/server/services/governance/subject_refs.py +34 -0
  48. package/plugin/vendor/reflexio/reflexio/server/services/lineage/gc_scheduler.py +45 -19
  49. package/plugin/vendor/reflexio/reflexio/server/services/playbook/README.md +9 -1
  50. package/plugin/vendor/reflexio/reflexio/server/services/playbook/aggregation_prompt_processing.py +100 -0
  51. package/plugin/vendor/reflexio/reflexio/server/services/playbook/components/aggregator.py +129 -111
  52. package/plugin/vendor/reflexio/reflexio/server/services/playbook/service.py +9 -9
  53. package/plugin/vendor/reflexio/reflexio/server/services/profile/components/extractor.py +3 -2
  54. package/plugin/vendor/reflexio/reflexio/server/services/retrieval/recency.py +211 -0
  55. package/plugin/vendor/reflexio/reflexio/server/services/retrieval/relevance_floor.py +29 -13
  56. package/plugin/vendor/reflexio/reflexio/server/services/storage/error.py +4 -0
  57. package/plugin/vendor/reflexio/reflexio/server/services/storage/governance_validation.py +681 -0
  58. package/plugin/vendor/reflexio/reflexio/server/services/storage/sqlite_storage/__init__.py +14 -2
  59. package/plugin/vendor/reflexio/reflexio/server/services/storage/sqlite_storage/_base.py +2 -0
  60. package/plugin/vendor/reflexio/reflexio/server/services/storage/sqlite_storage/_extras.py +49 -19
  61. package/plugin/vendor/reflexio/reflexio/server/services/storage/sqlite_storage/_governance.py +1965 -0
  62. package/plugin/vendor/reflexio/reflexio/server/services/storage/sqlite_storage/_lineage.py +5 -3
  63. package/plugin/vendor/reflexio/reflexio/server/services/storage/sqlite_storage/_playbook.py +1 -2133
  64. package/plugin/vendor/reflexio/reflexio/server/services/storage/sqlite_storage/_profiles.py +262 -107
  65. package/plugin/vendor/reflexio/reflexio/server/services/storage/sqlite_storage/_requests.py +73 -33
  66. package/plugin/vendor/reflexio/reflexio/server/services/storage/sqlite_storage/playbook/__init__.py +13 -0
  67. package/plugin/vendor/reflexio/reflexio/server/services/storage/sqlite_storage/playbook/_agent.py +952 -0
  68. package/plugin/vendor/reflexio/reflexio/server/services/storage/sqlite_storage/playbook/_eval_results.py +189 -0
  69. package/plugin/vendor/reflexio/reflexio/server/services/storage/sqlite_storage/playbook/_optimization.py +247 -0
  70. package/plugin/vendor/reflexio/reflexio/server/services/storage/sqlite_storage/playbook/_source_linkage.py +145 -0
  71. package/plugin/vendor/reflexio/reflexio/server/services/storage/sqlite_storage/playbook/_user.py +838 -0
  72. package/plugin/vendor/reflexio/reflexio/server/services/storage/storage_base/__init__.py +19 -3
  73. package/plugin/vendor/reflexio/reflexio/server/services/storage/storage_base/_extras.py +4 -4
  74. package/plugin/vendor/reflexio/reflexio/server/services/storage/storage_base/_governance.py +148 -0
  75. package/plugin/vendor/reflexio/reflexio/server/services/storage/storage_base/_playbook.py +0 -909
  76. package/plugin/vendor/reflexio/reflexio/server/services/storage/storage_base/_profiles.py +13 -0
  77. package/plugin/vendor/reflexio/reflexio/server/services/storage/storage_base/_requests.py +2 -0
  78. package/plugin/vendor/reflexio/reflexio/server/services/storage/storage_base/playbook/__init__.py +13 -0
  79. package/plugin/vendor/reflexio/reflexio/server/services/storage/storage_base/playbook/_agent.py +365 -0
  80. package/plugin/vendor/reflexio/reflexio/server/services/storage/storage_base/playbook/_eval_results.py +124 -0
  81. package/plugin/vendor/reflexio/reflexio/server/services/storage/storage_base/playbook/_optimization.py +85 -0
  82. package/plugin/vendor/reflexio/reflexio/server/services/storage/storage_base/playbook/_source_linkage.py +47 -0
  83. package/plugin/vendor/reflexio/reflexio/server/services/storage/storage_base/playbook/_user.py +333 -0
  84. package/plugin/vendor/reflexio/reflexio/server/services/unified_search_service.py +153 -12
  85. package/plugin/vendor/reflexio/reflexio/server/services/playbook/user_detail_stripping.py +0 -84
@@ -75,6 +75,10 @@ def _build_tags_sql(alias: str, tags: list[str] | None) -> tuple[str, list[Any]]
75
75
  )
76
76
 
77
77
 
78
+ def _escape_like_pattern(value: str) -> str:
79
+ return value.replace("\\", "\\\\").replace("%", "\\%").replace("_", "\\_")
80
+
81
+
78
82
  class ProfileMixin:
79
83
  """Mixin providing profile and interaction CRUD + search."""
80
84
 
@@ -100,21 +104,87 @@ class ProfileMixin:
100
104
  llm_client: Any
101
105
  embedding_model_name: str
102
106
  embedding_dimensions: int
107
+ _subject_ref_for_user_id: Any
108
+ _assert_subject_writable_locked: Any
103
109
 
104
110
  # ------------------------------------------------------------------
105
111
  # CRUD — Profiles
106
112
  # ------------------------------------------------------------------
107
113
 
114
+ def _subject_ref_from_profile_row(self, row: sqlite3.Row) -> str:
115
+ subject_ref = row["governance_subject_ref"]
116
+ return (
117
+ str(subject_ref)
118
+ if subject_ref
119
+ else self._subject_ref_for_user_id(row["user_id"])
120
+ )
121
+
122
+ def _assert_profile_writable_locked(
123
+ self,
124
+ profile_id: str,
125
+ *,
126
+ user_id: str | None = None,
127
+ ) -> sqlite3.Row | None:
128
+ if user_id is None:
129
+ row = self.conn.execute(
130
+ "SELECT user_id, governance_subject_ref FROM profiles WHERE profile_id = ?",
131
+ (profile_id,),
132
+ ).fetchone()
133
+ else:
134
+ row = self.conn.execute(
135
+ "SELECT user_id, governance_subject_ref FROM profiles WHERE profile_id = ? AND user_id = ?",
136
+ (profile_id, user_id),
137
+ ).fetchone()
138
+ if row is None:
139
+ return None
140
+ self._assert_subject_writable_locked(self._subject_ref_from_profile_row(row))
141
+ return row
142
+
108
143
  @SQLiteStorageBase.handle_exceptions
109
144
  def get_all_profiles(
110
145
  self,
111
146
  limit: int = 100,
112
147
  status_filter: list[Status | None] | None = None,
148
+ user_id: str | None = None,
149
+ profile_id: str | None = None,
150
+ query: str | None = None,
151
+ source: str | None = None,
152
+ profile_time_to_live: str | None = None,
153
+ start_time: int | None = None,
154
+ end_time: int | None = None,
113
155
  ) -> list[UserProfile]:
114
156
  if status_filter is None:
115
157
  status_filter = [None]
116
158
  frag, params = _build_status_sql(status_filter)
117
- sql = f"SELECT * FROM profiles WHERE {frag} ORDER BY last_modified_timestamp DESC LIMIT ?"
159
+ conditions = [frag]
160
+ if user_id:
161
+ conditions.append("user_id = ?")
162
+ params.append(user_id)
163
+ if profile_id:
164
+ conditions.append("LOWER(profile_id) = LOWER(?)")
165
+ params.append(profile_id)
166
+ if query:
167
+ like = f"%{_escape_like_pattern(query.lower())}%"
168
+ conditions.append(
169
+ "(LOWER(content) LIKE ? ESCAPE '\\' OR LOWER(profile_id) LIKE ? ESCAPE '\\' OR LOWER(user_id) LIKE ? ESCAPE '\\')"
170
+ )
171
+ params.extend([like, like, like])
172
+ if source is not None:
173
+ conditions.append("source = ?")
174
+ params.append(source)
175
+ if profile_time_to_live:
176
+ conditions.append("profile_time_to_live = ?")
177
+ params.append(profile_time_to_live)
178
+ if start_time is not None:
179
+ conditions.append("last_modified_timestamp >= ?")
180
+ params.append(start_time)
181
+ if end_time is not None:
182
+ conditions.append("last_modified_timestamp <= ?")
183
+ params.append(end_time)
184
+ sql = (
185
+ f"SELECT * FROM profiles WHERE {' AND '.join(conditions)} "
186
+ "ORDER BY last_modified_timestamp DESC LIMIT ?"
187
+ )
118
188
  params.append(limit)
119
189
  return [_row_to_profile(r) for r in self._fetchall(sql, params)]
120
190
 
@@ -124,6 +194,12 @@ class ProfileMixin:
124
194
  user_id: str,
125
195
  status_filter: list[Status | None] | None = None,
126
196
  tags: list[str] | None = None,
197
+ profile_id: str | None = None,
198
+ query: str | None = None,
199
+ source: str | None = None,
200
+ profile_time_to_live: str | None = None,
201
+ start_time: int | None = None,
202
+ end_time: int | None = None,
127
203
  ) -> list[UserProfile]:
128
204
  if status_filter is None:
129
205
  status_filter = [None]
@@ -131,6 +207,27 @@ class ProfileMixin:
131
207
  frag, params = _build_status_sql(status_filter)
132
208
  conditions = ["user_id = ?", "expiration_timestamp >= ?", frag]
133
209
  all_params: list[Any] = [user_id, current_ts, *params]
210
+ if profile_id:
211
+ conditions.append("LOWER(profile_id) = LOWER(?)")
212
+ all_params.append(profile_id)
213
+ if query:
214
+ like = f"%{_escape_like_pattern(query.lower())}%"
215
+ conditions.append(
216
+ "(LOWER(content) LIKE ? ESCAPE '\\' OR LOWER(profile_id) LIKE ? ESCAPE '\\' OR LOWER(user_id) LIKE ? ESCAPE '\\')"
217
+ )
218
+ all_params.extend([like, like, like])
219
+ if source is not None:
220
+ conditions.append("source = ?")
221
+ all_params.append(source)
222
+ if profile_time_to_live:
223
+ conditions.append("profile_time_to_live = ?")
224
+ all_params.append(profile_time_to_live)
225
+ if start_time is not None:
226
+ conditions.append("last_modified_timestamp >= ?")
227
+ all_params.append(start_time)
228
+ if end_time is not None:
229
+ conditions.append("last_modified_timestamp <= ?")
230
+ all_params.append(end_time)
134
231
  tag_frag, tag_params = _build_tags_sql("profiles", tags)
135
232
  if tag_frag:
136
233
  conditions.append(tag_frag)
@@ -141,6 +238,9 @@ class ProfileMixin:
141
238
  @SQLiteStorageBase.handle_exceptions
142
239
  def add_user_profile(self, user_id: str, user_profiles: list[UserProfile]) -> None: # noqa: ARG002
143
240
  for profile in user_profiles:
241
+ subject_ref = self._subject_ref_for_user_id(profile.user_id)
242
+ with self._lock:
243
+ self._assert_subject_writable_locked(subject_ref)
144
244
  embedding_text = "\n".join([profile.content, str(profile.custom_features)])
145
245
  if self._should_expand_documents():
146
246
  with ThreadPoolExecutor(max_workers=2) as executor:
@@ -151,39 +251,48 @@ class ProfileMixin:
151
251
  else:
152
252
  profile.embedding = self._get_embedding(embedding_text)
153
253
  embedding = profile.embedding
154
- self._execute(
155
- """INSERT OR REPLACE INTO profiles
156
- (profile_id, user_id, content, last_modified_timestamp,
157
- generated_from_request_id, profile_time_to_live,
158
- expiration_timestamp, custom_features, embedding, source,
159
- status, extractor_names, expanded_terms,
160
- source_span, notes, reader_angle, tags, source_interaction_ids, created_at,
161
- merged_into, superseded_by)
162
- VALUES (?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?)""",
163
- (
164
- profile.profile_id,
165
- profile.user_id,
166
- profile.content,
167
- profile.last_modified_timestamp,
168
- profile.generated_from_request_id,
169
- profile.profile_time_to_live.value,
170
- profile.expiration_timestamp,
171
- _json_dumps(profile.custom_features),
172
- _json_dumps(profile.embedding),
173
- profile.source,
174
- profile.status.value if profile.status else None,
175
- _json_dumps(profile.extractor_names),
176
- profile.expanded_terms,
177
- profile.source_span,
178
- profile.notes,
179
- profile.reader_angle,
180
- _json_dumps(profile.tags),
181
- _json_dumps(profile.source_interaction_ids),
182
- _iso_now(),
183
- profile.merged_into,
184
- profile.superseded_by,
185
- ),
186
- )
254
+ with self._lock:
255
+ try:
256
+ self.conn.execute("BEGIN IMMEDIATE")
257
+ self._assert_subject_writable_locked(subject_ref)
258
+ self.conn.execute(
259
+ """INSERT OR REPLACE INTO profiles
260
+ (profile_id, user_id, content, last_modified_timestamp,
261
+ generated_from_request_id, profile_time_to_live,
262
+ expiration_timestamp, custom_features, embedding, source,
263
+ status, extractor_names, expanded_terms,
264
+ source_span, notes, reader_angle, tags, source_interaction_ids, created_at,
265
+ merged_into, superseded_by, governance_subject_ref)
266
+ VALUES (?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?)""",
267
+ (
268
+ profile.profile_id,
269
+ profile.user_id,
270
+ profile.content,
271
+ profile.last_modified_timestamp,
272
+ profile.generated_from_request_id,
273
+ profile.profile_time_to_live.value,
274
+ profile.expiration_timestamp,
275
+ _json_dumps(profile.custom_features),
276
+ _json_dumps(profile.embedding),
277
+ profile.source,
278
+ profile.status.value if profile.status else None,
279
+ _json_dumps(profile.extractor_names),
280
+ profile.expanded_terms,
281
+ profile.source_span,
282
+ profile.notes,
283
+ profile.reader_angle,
284
+ _json_dumps(profile.tags),
285
+ _json_dumps(profile.source_interaction_ids),
286
+ _iso_now(),
287
+ profile.merged_into,
288
+ profile.superseded_by,
289
+ subject_ref,
290
+ ),
291
+ )
292
+ self.conn.commit()
293
+ except Exception:
294
+ self.conn.rollback()
295
+ raise
187
296
  fts_parts = [profile.content or ""]
188
297
  if profile.custom_features:
189
298
  fts_parts.extend(str(v) for v in profile.custom_features.values() if v)
@@ -211,18 +320,28 @@ class ProfileMixin:
211
320
  that re-acquire it are safe.
212
321
  """
213
322
  current_ts = _epoch_now()
214
- row = self._fetchone(
215
- "SELECT profile_id FROM profiles WHERE user_id = ? AND profile_id = ? AND expiration_timestamp >= ?",
216
- (user_id, profile_id, current_ts),
217
- )
218
- if not row:
219
- logger.warning("User profile not found for user id: %s", user_id)
220
- return
323
+ with self._lock:
324
+ row = self.conn.execute(
325
+ "SELECT user_id, governance_subject_ref FROM profiles WHERE user_id = ? AND profile_id = ? AND expiration_timestamp >= ?",
326
+ (user_id, profile_id, current_ts),
327
+ ).fetchone()
328
+ if not row:
329
+ logger.warning("User profile not found for user id: %s", user_id)
330
+ return
331
+ self._assert_subject_writable_locked(
332
+ self._subject_ref_from_profile_row(row)
333
+ )
221
334
  embedding = self._get_embedding(
222
335
  "\n".join([new_profile.content, str(new_profile.custom_features)])
223
336
  )
224
337
  new_profile.embedding = embedding
225
338
  with self._lock:
339
+ if (
340
+ self._assert_profile_writable_locked(profile_id, user_id=user_id)
341
+ is None
342
+ ):
343
+ logger.warning("User profile not found for user id: %s", user_id)
344
+ return
226
345
  cur = self.conn.execute(
227
346
  """UPDATE profiles SET content=?, last_modified_timestamp=?,
228
347
  generated_from_request_id=?, profile_time_to_live=?,
@@ -282,10 +401,17 @@ class ProfileMixin:
282
401
  def update_user_profile_tags(
283
402
  self, user_id: str, profile_id: str, tags: list[str]
284
403
  ) -> None:
285
- self._execute(
286
- "UPDATE profiles SET tags=? WHERE user_id=? AND profile_id=?",
287
- (_json_dumps(tags), user_id, profile_id),
288
- )
404
+ with self._lock:
405
+ if (
406
+ self._assert_profile_writable_locked(profile_id, user_id=user_id)
407
+ is None
408
+ ):
409
+ return
410
+ self.conn.execute(
411
+ "UPDATE profiles SET tags=? WHERE user_id=? AND profile_id=?",
412
+ (_json_dumps(tags), user_id, profile_id),
413
+ )
414
+ self.conn.commit()
289
415
 
290
416
  @SQLiteStorageBase.handle_exceptions
291
417
  def delete_user_profile(self, request: DeleteUserProfileRequest) -> None:
@@ -408,20 +534,24 @@ class ProfileMixin:
408
534
 
409
535
  batch_request_id = uuid.uuid4().hex
410
536
  with self._lock:
411
- affected = [
412
- r["profile_id"]
413
- for r in self.conn.execute(
414
- f"SELECT profile_id FROM profiles WHERE {where}",
537
+ affected = list(
538
+ self.conn.execute(
539
+ f"SELECT profile_id, user_id, governance_subject_ref FROM profiles WHERE {where}",
415
540
  select_params + extra_params,
416
541
  ).fetchall()
417
- ]
542
+ )
543
+ for row in affected:
544
+ self._assert_subject_writable_locked(
545
+ self._subject_ref_from_profile_row(row)
546
+ )
418
547
  cur = self.conn.execute(
419
548
  f"UPDATE profiles SET status = ?, last_modified_timestamp = ?, retired_at = ? WHERE {where}",
420
549
  [new_val, now_ts, retired_at_val] + select_params + extra_params,
421
550
  )
422
551
  from_val = old_status.value if old_status else None
423
552
  to_val = new_status.value if new_status else None
424
- for pid in affected:
553
+ for row in affected:
554
+ pid = row["profile_id"]
425
555
  _append_event_stmt(
426
556
  self.conn,
427
557
  org_id=self.org_id,
@@ -530,6 +660,11 @@ class ProfileMixin:
530
660
  @SQLiteStorageBase.handle_exceptions
531
661
  def archive_profile_by_id(self, user_id: str, profile_id: str) -> bool:
532
662
  with self._lock:
663
+ if (
664
+ self._assert_profile_writable_locked(profile_id, user_id=user_id)
665
+ is None
666
+ ):
667
+ return False
533
668
  now_ts = _epoch_now()
534
669
  cur = self.conn.execute(
535
670
  "UPDATE profiles SET status = ?, last_modified_timestamp = ?, retired_at = ? "
@@ -590,11 +725,14 @@ class ProfileMixin:
590
725
  for pid in profile_ids:
591
726
  # Read current status for from_status derivation (user_id scoped)
592
727
  row = self.conn.execute(
593
- "SELECT status FROM profiles WHERE profile_id = ? AND user_id = ?",
728
+ "SELECT status, user_id, governance_subject_ref FROM profiles WHERE profile_id = ? AND user_id = ?",
594
729
  (pid, user_id),
595
730
  ).fetchone()
596
731
  if row is None:
597
732
  continue
733
+ self._assert_subject_writable_locked(
734
+ self._subject_ref_from_profile_row(row)
735
+ )
598
736
  old_status_val = (
599
737
  row[0] if isinstance(row, (tuple, list)) else row["status"]
600
738
  )
@@ -744,62 +882,79 @@ class ProfileMixin:
744
882
 
745
883
  def _insert_interaction(self, interaction: Interaction) -> int:
746
884
  created_at_iso = _epoch_to_iso(interaction.created_at)
885
+ subject_ref = self._subject_ref_for_user_id(interaction.user_id)
747
886
  with self._lock:
748
- if interaction.interaction_id:
749
- self.conn.execute(
750
- """INSERT OR REPLACE INTO interactions
751
- (interaction_id, user_id, content, request_id, created_at,
752
- role, user_action, user_action_description,
753
- interacted_image_url, image_encoding, shadow_content,
754
- expert_content, tools_used, citations, embedding)
755
- VALUES (?,?,?,?,?,?,?,?,?,?,?,?,?,?,?)""",
756
- (
757
- interaction.interaction_id,
758
- interaction.user_id,
759
- interaction.content,
760
- interaction.request_id,
761
- created_at_iso,
762
- interaction.role,
763
- interaction.user_action.value,
764
- interaction.user_action_description,
765
- interaction.interacted_image_url,
766
- interaction.image_encoding,
767
- interaction.shadow_content,
768
- interaction.expert_content,
769
- _json_dumps([t.model_dump() for t in interaction.tools_used]),
770
- _json_dumps([c.model_dump() for c in interaction.citations]),
771
- _json_dumps(interaction.embedding),
772
- ),
773
- )
774
- iid = interaction.interaction_id
775
- else:
776
- cur = self.conn.execute(
777
- """INSERT INTO interactions
778
- (user_id, content, request_id, created_at,
779
- role, user_action, user_action_description,
780
- interacted_image_url, image_encoding, shadow_content,
781
- expert_content, tools_used, citations, embedding)
782
- VALUES (?,?,?,?,?,?,?,?,?,?,?,?,?,?)""",
783
- (
784
- interaction.user_id,
785
- interaction.content,
786
- interaction.request_id,
787
- created_at_iso,
788
- interaction.role,
789
- interaction.user_action.value,
790
- interaction.user_action_description,
791
- interaction.interacted_image_url,
792
- interaction.image_encoding,
793
- interaction.shadow_content,
794
- interaction.expert_content,
795
- _json_dumps([t.model_dump() for t in interaction.tools_used]),
796
- _json_dumps([c.model_dump() for c in interaction.citations]),
797
- _json_dumps(interaction.embedding),
798
- ),
799
- )
800
- iid = cur.lastrowid or 0
801
- interaction.interaction_id = iid
802
- self.conn.commit()
887
+ try:
888
+ self.conn.execute("BEGIN IMMEDIATE")
889
+ self._assert_subject_writable_locked(subject_ref)
890
+ if interaction.interaction_id:
891
+ self.conn.execute(
892
+ """INSERT OR REPLACE INTO interactions
893
+ (interaction_id, user_id, content, request_id, created_at,
894
+ role, user_action, user_action_description,
895
+ interacted_image_url, image_encoding, shadow_content,
896
+ expert_content, tools_used, citations, embedding, governance_subject_ref)
897
+ VALUES (?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?)""",
898
+ (
899
+ interaction.interaction_id,
900
+ interaction.user_id,
901
+ interaction.content,
902
+ interaction.request_id,
903
+ created_at_iso,
904
+ interaction.role,
905
+ interaction.user_action.value,
906
+ interaction.user_action_description,
907
+ interaction.interacted_image_url,
908
+ interaction.image_encoding,
909
+ interaction.shadow_content,
910
+ interaction.expert_content,
911
+ _json_dumps(
912
+ [t.model_dump() for t in interaction.tools_used]
913
+ ),
914
+ _json_dumps(
915
+ [c.model_dump() for c in interaction.citations]
916
+ ),
917
+ _json_dumps(interaction.embedding),
918
+ subject_ref,
919
+ ),
920
+ )
921
+ iid = interaction.interaction_id
922
+ else:
923
+ cur = self.conn.execute(
924
+ """INSERT INTO interactions
925
+ (user_id, content, request_id, created_at,
926
+ role, user_action, user_action_description,
927
+ interacted_image_url, image_encoding, shadow_content,
928
+ expert_content, tools_used, citations, embedding, governance_subject_ref)
929
+ VALUES (?,?,?,?,?,?,?,?,?,?,?,?,?,?,?)""",
930
+ (
931
+ interaction.user_id,
932
+ interaction.content,
933
+ interaction.request_id,
934
+ created_at_iso,
935
+ interaction.role,
936
+ interaction.user_action.value,
937
+ interaction.user_action_description,
938
+ interaction.interacted_image_url,
939
+ interaction.image_encoding,
940
+ interaction.shadow_content,
941
+ interaction.expert_content,
942
+ _json_dumps(
943
+ [t.model_dump() for t in interaction.tools_used]
944
+ ),
945
+ _json_dumps(
946
+ [c.model_dump() for c in interaction.citations]
947
+ ),
948
+ _json_dumps(interaction.embedding),
949
+ subject_ref,
950
+ ),
951
+ )
952
+ iid = cur.lastrowid or 0
953
+ interaction.interaction_id = iid
954
+ self.conn.commit()
955
+ except Exception:
956
+ self.conn.rollback()
957
+ raise
803
958
  # Update FTS and vec
804
959
  self._fts_upsert(
805
960
  "interactions_fts",
@@ -30,6 +30,8 @@ class RequestMixin:
30
30
  _execute: Any
31
31
  _fetchone: Any
32
32
  _fetchall: Any
33
+ _subject_ref_for_user_id: Any
34
+ _assert_subject_writable_locked: Any
33
35
 
34
36
  # ------------------------------------------------------------------
35
37
  # Request methods
@@ -38,20 +40,31 @@ class RequestMixin:
38
40
  @SQLiteStorageBase.handle_exceptions
39
41
  def add_request(self, request: Request) -> None:
40
42
  created_at_iso = _epoch_to_iso(request.created_at)
41
- self._execute(
42
- """INSERT OR REPLACE INTO requests
43
- (request_id, user_id, created_at, source, agent_version, session_id, evaluation_only)
44
- VALUES (?,?,?,?,?,?,?)""",
45
- (
46
- request.request_id,
47
- request.user_id,
48
- created_at_iso,
49
- request.source,
50
- request.agent_version,
51
- request.session_id,
52
- 1 if request.evaluation_only else 0,
53
- ),
54
- )
43
+ subject_ref = self._subject_ref_for_user_id(request.user_id)
44
+ with self._lock:
45
+ try:
46
+ self.conn.execute("BEGIN IMMEDIATE")
47
+ self._assert_subject_writable_locked(subject_ref)
48
+ self.conn.execute(
49
+ """INSERT OR REPLACE INTO requests
50
+ (request_id, user_id, created_at, source, agent_version, session_id,
51
+ evaluation_only, governance_subject_ref)
52
+ VALUES (?,?,?,?,?,?,?,?)""",
53
+ (
54
+ request.request_id,
55
+ request.user_id,
56
+ created_at_iso,
57
+ request.source,
58
+ request.agent_version,
59
+ request.session_id,
60
+ 1 if request.evaluation_only else 0,
61
+ subject_ref,
62
+ ),
63
+ )
64
+ self.conn.commit()
65
+ except Exception:
66
+ self.conn.rollback()
67
+ raise
55
68
 
56
69
  @SQLiteStorageBase.handle_exceptions
57
70
  def get_request(self, request_id: str) -> Request | None:
@@ -155,35 +168,62 @@ class RequestMixin:
155
168
  end_time: int | None = None,
156
169
  top_k: int | None = 30,
157
170
  offset: int = 0,
171
+ source: str | None = None,
158
172
  ) -> dict[str, list[RequestInteractionDataModel]]:
159
- sql = "SELECT * FROM requests WHERE 1=1"
160
- params: list[Any] = []
161
-
173
+ # Request-level filters shared by both the session-page query and the
174
+ # request-fetch query. Pagination is applied at the SESSION level
175
+ # (top_k/offset count sessions, not request rows) so a session with many
176
+ # requests is never truncated to a subset of its rows.
177
+ filter_sql = ""
178
+ filter_params: list[Any] = []
162
179
  if user_id:
163
- sql += " AND user_id = ?"
164
- params.append(user_id)
180
+ filter_sql += " AND user_id = ?"
181
+ filter_params.append(user_id)
165
182
  if request_id:
166
- sql += " AND request_id = ?"
167
- params.append(request_id)
183
+ filter_sql += " AND request_id = ?"
184
+ filter_params.append(request_id)
168
185
  if session_id:
169
- sql += " AND session_id = ?"
170
- params.append(session_id)
171
- if start_time:
172
- sql += " AND created_at >= ?"
173
- params.append(_epoch_to_iso(start_time))
174
- if end_time:
175
- sql += " AND created_at <= ?"
176
- params.append(_epoch_to_iso(end_time))
186
+ filter_sql += " AND session_id = ?"
187
+ filter_params.append(session_id)
188
+ if source is not None:
189
+ filter_sql += " AND source = ?"
190
+ filter_params.append(source)
191
+ if start_time is not None:
192
+ filter_sql += " AND created_at >= ?"
193
+ filter_params.append(_epoch_to_iso(start_time))
194
+ if end_time is not None:
195
+ filter_sql += " AND created_at <= ?"
196
+ filter_params.append(_epoch_to_iso(end_time))
177
197
 
198
+ # Step 1: select the page of session_ids, ordered by each session's most
199
+ # recent matching request (latest-first), with session_id as a stable
200
+ # tiebreak so pages don't overlap or skip.
178
201
  effective_limit = top_k or 100
179
- sql += " ORDER BY created_at DESC LIMIT ? OFFSET ?"
180
- params.extend([effective_limit, offset])
202
+ page_rows = self._fetchall(
203
+ f"SELECT session_id, MAX(created_at) AS latest FROM requests "
204
+ f"WHERE 1=1{filter_sql} "
205
+ f"GROUP BY session_id ORDER BY latest DESC, session_id DESC "
206
+ f"LIMIT ? OFFSET ?",
207
+ [*filter_params, effective_limit, offset],
208
+ )
209
+ if not page_rows:
210
+ return {}
211
+ page_session_ids = [r["session_id"] for r in page_rows]
181
212
 
182
- req_rows = self._fetchall(sql, params)
213
+ # Step 2: fetch ALL matching requests for the sessions in this page.
214
+ placeholders = ",".join("?" for _ in page_session_ids)
215
+ req_rows = self._fetchall(
216
+ f"SELECT * FROM requests WHERE 1=1{filter_sql} "
217
+ f"AND session_id IN ({placeholders}) ORDER BY created_at DESC",
218
+ [*filter_params, *page_session_ids],
219
+ )
183
220
  if not req_rows:
184
221
  return {}
185
222
 
186
- grouped: dict[str, list[RequestInteractionDataModel]] = {}
223
+ # Preserve the latest-first session ordering from step 1.
224
+ grouped: dict[str, list[RequestInteractionDataModel]] = {
225
+ (sid or ""): [] for sid in page_session_ids
226
+ }
187
227
  for rr in req_rows:
188
228
  req = _row_to_request(rr)
189
229
  group_name = req.session_id or ""
@@ -0,0 +1,13 @@
1
+ from ._agent import AgentPlaybookStoreMixin
2
+ from ._eval_results import AgentEvaluationResultStoreMixin
3
+ from ._optimization import OptimizationJobStoreMixin
4
+ from ._source_linkage import PlaybookSourceLinkageMixin
5
+ from ._user import UserPlaybookStoreMixin
6
+
7
+ __all__ = [
8
+ "AgentEvaluationResultStoreMixin",
9
+ "AgentPlaybookStoreMixin",
10
+ "OptimizationJobStoreMixin",
11
+ "PlaybookSourceLinkageMixin",
12
+ "UserPlaybookStoreMixin",
13
+ ]