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
@@ -1,54 +1,9 @@
1
1
  """Playbook CRUD + search methods for SQLite storage."""
2
2
 
3
- import json
4
- import logging
5
3
  import sqlite3
6
- import uuid
7
- from collections.abc import Sequence
8
- from concurrent.futures import ThreadPoolExecutor
9
4
  from typing import Any
10
5
 
11
- logger = logging.getLogger(__name__)
12
-
13
- from reflexio.models.api_schema.common import BlockingIssue
14
- from reflexio.models.api_schema.retriever_schema import (
15
- SearchAgentPlaybookRequest,
16
- SearchUserPlaybookRequest,
17
- )
18
- from reflexio.models.api_schema.service_schemas import (
19
- AgentPlaybook,
20
- AgentPlaybookSourceWindow,
21
- AgentSuccessEvaluationResult,
22
- PlaybookOptimizationCandidate,
23
- PlaybookOptimizationEvaluation,
24
- PlaybookOptimizationEvent,
25
- PlaybookOptimizationJob,
26
- PlaybookStatus,
27
- Status,
28
- UserPlaybook,
29
- )
30
- from reflexio.models.config_schema import SearchMode, SearchOptions
31
- from reflexio.server.services.storage.storage_base._playbook import (
32
- AGGREGATE_REASON_PREFIX,
33
- )
34
-
35
- from ._base import (
36
- _TOMBSTONE_STATUS_VALUES,
37
- SQLiteStorageBase,
38
- _build_status_sql,
39
- _effective_search_mode,
40
- _epoch_now,
41
- _epoch_to_iso,
42
- _json_dumps,
43
- _json_loads,
44
- _row_to_agent_playbook,
45
- _row_to_eval_result,
46
- _row_to_user_playbook,
47
- _sanitize_fts_query,
48
- _true_rrf_merge,
49
- _vector_rank_rows,
50
- )
51
- from ._lineage import _GC_ELIGIBLE_STATUSES, _append_event_stmt
6
+ from ._lineage import _append_event_stmt
52
7
 
53
8
 
54
9
  def _emit_hard_delete_playbook(
@@ -75,96 +30,6 @@ def _emit_hard_delete_playbook(
75
30
  )
76
31
 
77
32
 
78
- def _emit_supersede_playbook(
79
- conn: sqlite3.Connection,
80
- *,
81
- org_id: str,
82
- entity_id: str,
83
- old_status: str | None,
84
- request_id: str,
85
- ) -> None:
86
- """Emit a single status_change->superseded lineage event for an agent playbook."""
87
- _append_event_stmt(
88
- conn,
89
- org_id=org_id,
90
- entity_type="agent_playbook",
91
- entity_id=entity_id,
92
- op="status_change",
93
- prov="wasInvalidatedBy",
94
- source_ids=[],
95
- actor="aggregator",
96
- request_id=request_id,
97
- reason=f"{old_status or 'None'}->superseded",
98
- from_status=old_status,
99
- to_status=Status.SUPERSEDED.value,
100
- status_namespace="lifecycle_status",
101
- )
102
-
103
-
104
- def _emit_supersede_user_playbook(
105
- conn: sqlite3.Connection,
106
- *,
107
- org_id: str,
108
- entity_id: str,
109
- old_status: str | None,
110
- request_id: str,
111
- ) -> None:
112
- """Emit a single status_change->superseded lineage event for a user playbook."""
113
- _append_event_stmt(
114
- conn,
115
- org_id=org_id,
116
- entity_type="user_playbook",
117
- entity_id=entity_id,
118
- op="status_change",
119
- prov="wasInvalidatedBy",
120
- source_ids=[],
121
- actor="consolidator",
122
- request_id=request_id,
123
- reason=f"{old_status or 'None'}->superseded",
124
- from_status=old_status,
125
- to_status=Status.SUPERSEDED.value,
126
- status_namespace="lifecycle_status",
127
- )
128
-
129
-
130
- def _row_to_playbook_optimization_candidate(
131
- row: sqlite3.Row,
132
- ) -> PlaybookOptimizationCandidate:
133
- return PlaybookOptimizationCandidate(
134
- candidate_id=row["candidate_id"],
135
- job_id=row["job_id"],
136
- candidate_index=row["candidate_index"],
137
- content=row["content"],
138
- parent_candidate_ids=_json_loads(row["parent_candidate_ids"]) or [],
139
- aggregate_score=row["aggregate_score"],
140
- is_winner=bool(row["is_winner"]),
141
- metadata_json=row["metadata_json"] or "{}",
142
- created_at=row["created_at"],
143
- )
144
-
145
-
146
- def _row_to_playbook_optimization_evaluation(
147
- row: sqlite3.Row,
148
- ) -> PlaybookOptimizationEvaluation:
149
- return PlaybookOptimizationEvaluation(
150
- evaluation_id=row["evaluation_id"],
151
- job_id=row["job_id"],
152
- candidate_id=row["candidate_id"],
153
- target_kind=row["target_kind"],
154
- target_id=row["target_id"],
155
- scenario_user_playbook_id=row["scenario_user_playbook_id"],
156
- source_interaction_ids=_json_loads(row["source_interaction_ids"]) or [],
157
- score=row["score"],
158
- verdict=row["verdict"],
159
- likert=row["likert"],
160
- rationale=row["rationale"],
161
- asi_json=row["asi_json"],
162
- incumbent_rollout_json=row["incumbent_rollout_json"],
163
- candidate_rollout_json=row["candidate_rollout_json"],
164
- created_at=row["created_at"],
165
- )
166
-
167
-
168
33
  def _build_tags_sql(alias: str, tags: list[str] | None) -> tuple[str, list[Any]]:
169
34
  if not tags:
170
35
  return "", []
@@ -173,2000 +38,3 @@ def _build_tags_sql(alias: str, tags: list[str] | None) -> tuple[str, list[Any]]
173
38
  f"EXISTS (SELECT 1 FROM json_each({alias}.tags) WHERE value IN ({placeholders}))",
174
39
  list(tags),
175
40
  )
176
-
177
-
178
- class PlaybookMixin:
179
- """Mixin providing user playbook, agent playbook, and evaluation CRUD + search."""
180
-
181
- # Type hints for instance attributes/methods provided by SQLiteStorageBase via MRO
182
- _lock: Any
183
- conn: sqlite3.Connection
184
- org_id: str
185
- _execute: Any
186
- _fetchone: Any
187
- _fetchall: Any
188
- _get_embedding: Any
189
- _should_expand_documents: Any
190
- _expand_document: Any
191
- _fts_upsert: Any
192
- _fts_delete: Any
193
- _vec_upsert: Any
194
- _vec_delete: Any
195
- _delete_playbook_search_rows: Any
196
- _has_sqlite_vec: bool
197
-
198
- # ------------------------------------------------------------------
199
- # User Playbook methods
200
- # ------------------------------------------------------------------
201
-
202
- @SQLiteStorageBase.handle_exceptions
203
- def save_user_playbooks(self, user_playbooks: list[UserPlaybook]) -> None:
204
- for up in user_playbooks:
205
- embedding_text = up.trigger or up.content
206
- if embedding_text:
207
- if self._should_expand_documents():
208
- with ThreadPoolExecutor(max_workers=2) as executor:
209
- emb_future = executor.submit(
210
- self._get_embedding, embedding_text
211
- )
212
- exp_future = executor.submit(
213
- self._expand_document, embedding_text
214
- )
215
- up.embedding = emb_future.result(timeout=15)
216
- up.expanded_terms = exp_future.result(timeout=15)
217
- else:
218
- up.embedding = self._get_embedding(embedding_text)
219
-
220
- created_at_iso = _epoch_to_iso(up.created_at)
221
- with self._lock:
222
- cur = self.conn.execute(
223
- """INSERT INTO user_playbooks
224
- (user_id, playbook_name, created_at, request_id, agent_version,
225
- content, trigger, rationale, blocking_issue,
226
- source_interaction_ids,
227
- status, source, embedding, expanded_terms,
228
- source_span, notes, reader_angle, tags,
229
- merged_into, superseded_by)
230
- VALUES (?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?)""",
231
- (
232
- up.user_id,
233
- up.playbook_name,
234
- created_at_iso,
235
- up.request_id,
236
- up.agent_version,
237
- up.content,
238
- up.trigger,
239
- up.rationale,
240
- json.dumps(up.blocking_issue.model_dump())
241
- if up.blocking_issue
242
- else None,
243
- _json_dumps(up.source_interaction_ids or None),
244
- up.status.value if up.status else None,
245
- up.source,
246
- _json_dumps(up.embedding),
247
- up.expanded_terms,
248
- up.source_span,
249
- up.notes,
250
- up.reader_angle,
251
- _json_dumps(up.tags),
252
- up.merged_into,
253
- up.superseded_by,
254
- ),
255
- )
256
- upid = cur.lastrowid or 0
257
- up.user_playbook_id = upid
258
- self.conn.commit()
259
-
260
- fts_parts = [up.trigger or "", up.content or ""]
261
- if up.expanded_terms:
262
- fts_parts.append(up.expanded_terms)
263
- self._fts_upsert(
264
- "user_playbooks_fts",
265
- upid,
266
- search_text=" ".join(p for p in fts_parts if p) or "",
267
- )
268
- if up.embedding:
269
- self._vec_upsert("user_playbooks_vec", upid, up.embedding)
270
-
271
- @SQLiteStorageBase.handle_exceptions
272
- def get_user_playbooks(
273
- self,
274
- limit: int = 100,
275
- user_id: str | None = None,
276
- playbook_name: str | None = None,
277
- agent_version: str | None = None,
278
- status_filter: list[Status | None] | None = None,
279
- start_time: int | None = None,
280
- end_time: int | None = None,
281
- include_embedding: bool = False,
282
- tags: list[str] | None = None,
283
- ) -> list[UserPlaybook]:
284
- sql = "SELECT * FROM user_playbooks WHERE 1=1"
285
- params: list[Any] = []
286
-
287
- if user_id is not None:
288
- sql += " AND user_id = ?"
289
- params.append(user_id)
290
- if playbook_name:
291
- sql += " AND playbook_name = ?"
292
- params.append(playbook_name)
293
- if agent_version is not None:
294
- sql += " AND agent_version = ?"
295
- params.append(agent_version)
296
- if start_time is not None:
297
- sql += " AND created_at >= ?"
298
- params.append(_epoch_to_iso(start_time))
299
- if end_time is not None:
300
- sql += " AND created_at <= ?"
301
- params.append(_epoch_to_iso(end_time))
302
- if status_filter is not None:
303
- frag, sparams = _build_status_sql(status_filter)
304
- sql += f" AND {frag}"
305
- params.extend(sparams)
306
- else:
307
- # Default: exclude tombstone statuses (MERGED/SUPERSEDED)
308
- sql += " AND (status IS NULL OR status NOT IN (?, ?))"
309
- params.extend(_TOMBSTONE_STATUS_VALUES)
310
- tag_frag, tag_params = _build_tags_sql("user_playbooks", tags)
311
- if tag_frag:
312
- sql += f" AND {tag_frag}"
313
- params.extend(tag_params)
314
-
315
- sql += " ORDER BY created_at DESC LIMIT ?"
316
- params.append(limit)
317
- rows = self._fetchall(sql, params)
318
- return [
319
- _row_to_user_playbook(r, include_embedding=include_embedding) for r in rows
320
- ]
321
-
322
- @SQLiteStorageBase.handle_exceptions
323
- def count_user_playbooks(
324
- self,
325
- user_id: str | None = None,
326
- playbook_name: str | None = None,
327
- min_user_playbook_id: int | None = None,
328
- agent_version: str | None = None,
329
- status_filter: list[Status | None] | None = None,
330
- ) -> int:
331
- sql = "SELECT COUNT(*) as cnt FROM user_playbooks WHERE 1=1"
332
- params: list[Any] = []
333
-
334
- if user_id is not None:
335
- sql += " AND user_id = ?"
336
- params.append(user_id)
337
- if playbook_name:
338
- sql += " AND playbook_name = ?"
339
- params.append(playbook_name)
340
- if min_user_playbook_id is not None:
341
- sql += " AND user_playbook_id > ?"
342
- params.append(min_user_playbook_id)
343
- if agent_version is not None:
344
- sql += " AND agent_version = ?"
345
- params.append(agent_version)
346
- if status_filter is not None:
347
- frag, sparams = _build_status_sql(status_filter)
348
- sql += f" AND {frag}"
349
- params.extend(sparams)
350
- else:
351
- # Default: exclude tombstone statuses (MERGED/SUPERSEDED)
352
- sql += " AND (status IS NULL OR status NOT IN (?, ?))"
353
- params.extend(_TOMBSTONE_STATUS_VALUES)
354
-
355
- row = self._fetchone(sql, params)
356
- return row["cnt"] if row else 0
357
-
358
- @SQLiteStorageBase.handle_exceptions
359
- def count_user_playbooks_by_session(self, session_id: str) -> int:
360
- row = self._fetchone(
361
- """SELECT COUNT(*) as cnt FROM user_playbooks up
362
- JOIN requests r ON up.request_id = r.request_id
363
- WHERE r.session_id = ?
364
- AND (up.status IS NULL OR up.status NOT IN (?, ?))""",
365
- (session_id, *_TOMBSTONE_STATUS_VALUES),
366
- )
367
- return row["cnt"] if row else 0
368
-
369
- @SQLiteStorageBase.handle_exceptions
370
- def delete_all_user_playbooks(self) -> None:
371
- batch_request_id = uuid.uuid4().hex
372
- with self._lock:
373
- ids = [
374
- r["user_playbook_id"]
375
- for r in self.conn.execute(
376
- "SELECT user_playbook_id FROM user_playbooks"
377
- ).fetchall()
378
- ]
379
- self.conn.execute("DELETE FROM user_playbooks")
380
- for upid in ids:
381
- _emit_hard_delete_playbook(
382
- self.conn,
383
- org_id=self.org_id,
384
- entity_type="user_playbook",
385
- entity_id=str(upid),
386
- request_id=batch_request_id,
387
- )
388
- self.conn.commit()
389
- self._delete_playbook_search_rows("user", ids)
390
-
391
- @SQLiteStorageBase.handle_exceptions
392
- def delete_user_playbook(self, user_playbook_id: int) -> None:
393
- with self._lock:
394
- cur = self.conn.execute(
395
- "DELETE FROM user_playbooks WHERE user_playbook_id = ?",
396
- (user_playbook_id,),
397
- )
398
- if cur.rowcount > 0:
399
- _emit_hard_delete_playbook(
400
- self.conn,
401
- org_id=self.org_id,
402
- entity_type="user_playbook",
403
- entity_id=str(user_playbook_id),
404
- request_id=uuid.uuid4().hex,
405
- )
406
- self._delete_playbook_search_rows("user", [user_playbook_id], commit=False)
407
- self.conn.commit()
408
-
409
- @SQLiteStorageBase.handle_exceptions
410
- def delete_all_user_playbooks_by_playbook_name(
411
- self, playbook_name: str, agent_version: str | None = None
412
- ) -> None:
413
- sql = "SELECT user_playbook_id FROM user_playbooks WHERE playbook_name = ?"
414
- params: list[Any] = [playbook_name]
415
- if agent_version is not None:
416
- sql += " AND agent_version = ?"
417
- params.append(agent_version)
418
- batch_request_id = uuid.uuid4().hex
419
- with self._lock:
420
- ids = [
421
- r["user_playbook_id"] for r in self.conn.execute(sql, params).fetchall()
422
- ]
423
- if not ids:
424
- return
425
- ph = ",".join("?" for _ in ids)
426
- self.conn.execute(
427
- f"DELETE FROM user_playbooks WHERE user_playbook_id IN ({ph})", ids
428
- )
429
- for upid in ids:
430
- _emit_hard_delete_playbook(
431
- self.conn,
432
- org_id=self.org_id,
433
- entity_type="user_playbook",
434
- entity_id=str(upid),
435
- request_id=batch_request_id,
436
- )
437
- self.conn.commit()
438
- self._delete_playbook_search_rows("user", ids)
439
-
440
- @SQLiteStorageBase.handle_exceptions
441
- def delete_user_playbooks_by_ids(
442
- self, user_playbook_ids: list[int], *, emit_hard_delete: bool = True
443
- ) -> int:
444
- if not user_playbook_ids:
445
- return 0
446
- ph = ",".join("?" for _ in user_playbook_ids)
447
- batch_request_id = uuid.uuid4().hex
448
- with self._lock:
449
- existing = [
450
- r["user_playbook_id"]
451
- for r in self.conn.execute(
452
- f"SELECT user_playbook_id FROM user_playbooks WHERE user_playbook_id IN ({ph})",
453
- user_playbook_ids,
454
- ).fetchall()
455
- ]
456
- cur = self.conn.execute(
457
- f"DELETE FROM user_playbooks WHERE user_playbook_id IN ({ph})",
458
- user_playbook_ids,
459
- )
460
- if emit_hard_delete:
461
- for upid in existing:
462
- _emit_hard_delete_playbook(
463
- self.conn,
464
- org_id=self.org_id,
465
- entity_type="user_playbook",
466
- entity_id=str(upid),
467
- request_id=batch_request_id,
468
- actor="system",
469
- )
470
- self.conn.commit()
471
- self._delete_playbook_search_rows("user", user_playbook_ids)
472
- return cur.rowcount
473
-
474
- @SQLiteStorageBase.handle_exceptions
475
- def update_all_user_playbooks_status(
476
- self,
477
- old_status: Status | None,
478
- new_status: Status | None,
479
- agent_version: str | None = None,
480
- playbook_name: str | None = None,
481
- ) -> int:
482
- new_val = new_status.value if new_status else None
483
- now_ts = _epoch_now()
484
- old_val_str = old_status.value if old_status else "None"
485
- new_val_str = new_status.value if new_status else "None"
486
- reason = f"{old_val_str}->{new_val_str}"
487
-
488
- if old_status is None or (
489
- hasattr(old_status, "value") and old_status.value is None
490
- ):
491
- where = "status IS NULL"
492
- select_params: list[Any] = []
493
- else:
494
- where = "status = ?"
495
- select_params = [old_status.value]
496
-
497
- extra_params: list[Any] = []
498
- if agent_version is not None:
499
- where += " AND agent_version = ?"
500
- extra_params.append(agent_version)
501
- if playbook_name is not None:
502
- where += " AND playbook_name = ?"
503
- extra_params.append(playbook_name)
504
-
505
- # Set retired_at = now when transitioning to a GC-eligible status; clear to NULL otherwise.
506
- retired_at_val = now_ts if new_val in _GC_ELIGIBLE_STATUSES else None
507
-
508
- batch_request_id = uuid.uuid4().hex
509
- with self._lock:
510
- affected = [
511
- r["user_playbook_id"]
512
- for r in self.conn.execute(
513
- f"SELECT user_playbook_id FROM user_playbooks WHERE {where}",
514
- select_params + extra_params,
515
- ).fetchall()
516
- ]
517
- cur = self.conn.execute(
518
- f"UPDATE user_playbooks SET status = ?, retired_at = ? WHERE {where}",
519
- [new_val, retired_at_val] + select_params + extra_params,
520
- )
521
- from_val = old_status.value if old_status else None
522
- to_val = new_status.value if new_status else None
523
- for upid in affected:
524
- _append_event_stmt(
525
- self.conn,
526
- org_id=self.org_id,
527
- entity_type="user_playbook",
528
- entity_id=str(upid),
529
- op="status_change",
530
- prov="wasInvalidatedBy",
531
- source_ids=[],
532
- actor="api",
533
- request_id=batch_request_id,
534
- reason=reason,
535
- from_status=from_val,
536
- to_status=to_val,
537
- status_namespace="lifecycle_status",
538
- )
539
- self.conn.commit()
540
- return cur.rowcount
541
-
542
- @SQLiteStorageBase.handle_exceptions
543
- def delete_all_user_playbooks_by_status(
544
- self,
545
- status: Status,
546
- agent_version: str | None = None,
547
- playbook_name: str | None = None,
548
- ) -> int:
549
- # Bulk delete-by-status emits no hard_delete lineage events (parity with
550
- # the Supabase backend, which routes this through _hard_delete_and_log with
551
- # emit_hard_delete=False). Accepts any status: the upgrade flow legitimately
552
- # deletes old ARCHIVED playbooks via _delete_items_by_status(Status.ARCHIVED).
553
- where = "status = ?"
554
- params: list[Any] = [status.value]
555
- if agent_version is not None:
556
- where += " AND agent_version = ?"
557
- params.append(agent_version)
558
- if playbook_name is not None:
559
- where += " AND playbook_name = ?"
560
- params.append(playbook_name)
561
-
562
- with self._lock:
563
- ids = [
564
- r["user_playbook_id"]
565
- for r in self.conn.execute(
566
- f"SELECT user_playbook_id FROM user_playbooks WHERE {where}",
567
- params, # noqa: S608
568
- ).fetchall()
569
- ]
570
- if not ids:
571
- return 0
572
- ph = ",".join("?" for _ in ids)
573
- cur = self.conn.execute(
574
- f"DELETE FROM user_playbooks WHERE user_playbook_id IN ({ph})", # noqa: S608
575
- ids,
576
- )
577
- self._delete_playbook_search_rows("user", ids, commit=False)
578
- self.conn.commit()
579
- return cur.rowcount
580
-
581
- @SQLiteStorageBase.handle_exceptions
582
- def get_user_playbooks_by_ids(
583
- self,
584
- user_id: str,
585
- user_playbook_ids: list[int],
586
- status_filter: list[Status | None] | None = None,
587
- ) -> list[UserPlaybook]:
588
- if not user_playbook_ids:
589
- return []
590
- if status_filter is None:
591
- status_filter = [None]
592
- frag, sparams = _build_status_sql(status_filter)
593
- ph = ",".join("?" for _ in user_playbook_ids)
594
- sql = (
595
- f"SELECT * FROM user_playbooks "
596
- f"WHERE user_id = ? AND user_playbook_id IN ({ph}) AND {frag}"
597
- )
598
- params: list[Any] = [user_id, *user_playbook_ids, *sparams]
599
- return [_row_to_user_playbook(r) for r in self._fetchall(sql, params)]
600
-
601
- @SQLiteStorageBase.handle_exceptions
602
- def archive_user_playbook_by_id(self, user_id: str, user_playbook_id: int) -> bool:
603
- cur = self._execute(
604
- "UPDATE user_playbooks SET status = ?, retired_at = ? "
605
- "WHERE user_playbook_id = ? AND user_id = ? AND status IS NULL",
606
- (Status.ARCHIVED.value, _epoch_now(), user_playbook_id, user_id),
607
- )
608
- return cur.rowcount > 0
609
-
610
- @SQLiteStorageBase.handle_exceptions
611
- def has_user_playbooks_with_status(
612
- self,
613
- status: Status | None,
614
- agent_version: str | None = None,
615
- playbook_name: str | None = None,
616
- ) -> bool:
617
- sql = "SELECT 1 FROM user_playbooks WHERE "
618
- params: list[Any] = []
619
-
620
- if status is None or (hasattr(status, "value") and status.value is None):
621
- sql += "status IS NULL"
622
- else:
623
- sql += "status = ?"
624
- params.append(status.value)
625
-
626
- if agent_version is not None:
627
- sql += " AND agent_version = ?"
628
- params.append(agent_version)
629
- if playbook_name is not None:
630
- sql += " AND playbook_name = ?"
631
- params.append(playbook_name)
632
-
633
- sql += " LIMIT 1"
634
- row = self._fetchone(sql, params)
635
- return row is not None
636
-
637
- @SQLiteStorageBase.handle_exceptions
638
- def search_user_playbooks( # noqa: C901
639
- self,
640
- request: SearchUserPlaybookRequest,
641
- options: SearchOptions | None = None,
642
- ) -> list[UserPlaybook]:
643
- query = request.query
644
- user_id = request.user_id
645
- agent_version = request.agent_version
646
- playbook_name = request.playbook_name
647
- start_time = int(request.start_time.timestamp()) if request.start_time else None
648
- end_time = int(request.end_time.timestamp()) if request.end_time else None
649
- status_filter = request.status_filter
650
- match_count = request.top_k or 10
651
- query_embedding = options.query_embedding if options else None
652
- mode = _effective_search_mode(
653
- request.search_mode, query_embedding, request.query
654
- )
655
- rrf_k = options.rrf_k if options else 60
656
- vector_weight = options.vector_weight if options else 1.0
657
- fts_weight = options.fts_weight if options else 1.0
658
-
659
- conditions: list[str] = []
660
- params: list[Any] = []
661
-
662
- if user_id:
663
- conditions.append("up.user_id = ?")
664
- params.append(user_id)
665
- if agent_version:
666
- conditions.append("up.agent_version = ?")
667
- params.append(agent_version)
668
- if playbook_name:
669
- conditions.append("up.playbook_name = ?")
670
- params.append(playbook_name)
671
- if start_time:
672
- conditions.append("up.created_at >= ?")
673
- params.append(_epoch_to_iso(start_time))
674
- if end_time:
675
- conditions.append("up.created_at <= ?")
676
- params.append(_epoch_to_iso(end_time))
677
- if status_filter is not None:
678
- frag, sparams = _build_status_sql(status_filter)
679
- conditions.append(frag)
680
- params.extend(sparams)
681
- else:
682
- # Default: exclude tombstone statuses (MERGED/SUPERSEDED)
683
- conditions.append("(up.status IS NULL OR up.status NOT IN (?, ?))")
684
- params.extend(_TOMBSTONE_STATUS_VALUES)
685
- tag_frag, tag_params = _build_tags_sql("up", request.tags)
686
- if tag_frag:
687
- conditions.append(tag_frag)
688
- params.extend(tag_params)
689
-
690
- where_extra = (" AND " + " AND ".join(conditions)) if conditions else ""
691
- overfetch = match_count * 5 if mode != SearchMode.FTS else match_count
692
-
693
- # Pure vector search: fetch all candidates, rank by cosine similarity
694
- if mode == SearchMode.VECTOR and query_embedding:
695
- base_where = "WHERE " + " AND ".join(conditions) if conditions else ""
696
- sql = f"""SELECT * FROM user_playbooks up
697
- {base_where}
698
- ORDER BY up.created_at DESC"""
699
- rows = self._fetchall(sql, params)
700
- rows = _vector_rank_rows(rows, query_embedding, match_count)
701
- return [_row_to_user_playbook(r) for r in rows]
702
-
703
- if query:
704
- fts_query = _sanitize_fts_query(query)
705
- sql = f"""SELECT up.* FROM user_playbooks up
706
- JOIN user_playbooks_fts f ON up.user_playbook_id = f.rowid
707
- WHERE user_playbooks_fts MATCH ?{where_extra}
708
- ORDER BY bm25(user_playbooks_fts, 1.0)
709
- LIMIT ?"""
710
- fts_rows = self._fetchall(sql, [fts_query, *params, overfetch])
711
-
712
- if mode == SearchMode.HYBRID and query_embedding:
713
- base_where = "WHERE " + " AND ".join(conditions) if conditions else ""
714
- vec_limit = match_count * 10
715
- vec_sql = f"""SELECT * FROM user_playbooks up
716
- {base_where}
717
- ORDER BY up.created_at DESC
718
- LIMIT ?"""
719
- vec_candidates = self._fetchall(vec_sql, [*params, vec_limit])
720
- vec_rows = _vector_rank_rows(vec_candidates, query_embedding, overfetch)
721
- rows = _true_rrf_merge(
722
- fts_rows,
723
- vec_rows,
724
- "user_playbook_id",
725
- match_count,
726
- rrf_k,
727
- vector_weight,
728
- fts_weight,
729
- )
730
- return [_row_to_user_playbook(r) for r in rows]
731
- return [_row_to_user_playbook(r) for r in fts_rows[:match_count]]
732
-
733
- # HYBRID without query text: rank by embedding only
734
- if query_embedding:
735
- base_where = "WHERE " + " AND ".join(conditions) if conditions else ""
736
- sql = f"""SELECT * FROM user_playbooks up
737
- {base_where}
738
- ORDER BY up.created_at DESC"""
739
- rows = self._fetchall(sql, params)
740
- rows = _vector_rank_rows(rows, query_embedding, match_count)
741
- return [_row_to_user_playbook(r) for r in rows]
742
-
743
- # No query text, no embedding -- recency fallback
744
- base_where = "WHERE " + " AND ".join(conditions) if conditions else "WHERE 1=1"
745
- sql = f"""SELECT * FROM user_playbooks up
746
- {base_where}
747
- ORDER BY up.created_at DESC LIMIT ?"""
748
- params.append(match_count)
749
- rows = self._fetchall(sql, params)
750
- return [_row_to_user_playbook(r) for r in rows]
751
-
752
- @SQLiteStorageBase.handle_exceptions
753
- def get_user_playbook_by_id(
754
- self, user_playbook_id: int, *, include_tombstones: bool = False
755
- ) -> UserPlaybook | None:
756
- sql = "SELECT * FROM user_playbooks WHERE user_playbook_id = ?"
757
- if not include_tombstones:
758
- sql += " AND (status IS NULL OR status NOT IN (?, ?))"
759
- row = self._fetchone(sql, (user_playbook_id, *_TOMBSTONE_STATUS_VALUES))
760
- else:
761
- row = self._fetchone(sql, (user_playbook_id,))
762
- return _row_to_user_playbook(row) if row else None
763
-
764
- @SQLiteStorageBase.handle_exceptions
765
- def get_user_playbooks_by_ids_any_user(
766
- self,
767
- user_playbook_ids: list[int],
768
- status_filter: list[Status | None] | None = None,
769
- ) -> list[UserPlaybook]:
770
- if not user_playbook_ids:
771
- return []
772
- ph = ",".join("?" for _ in user_playbook_ids)
773
- sql = f"SELECT * FROM user_playbooks WHERE user_playbook_id IN ({ph})" # noqa: S608
774
- params: list[Any] = list(user_playbook_ids)
775
- if status_filter is not None:
776
- frag, sparams = _build_status_sql(status_filter)
777
- sql += f" AND {frag}"
778
- params.extend(sparams)
779
- rows = self._fetchall(sql, params)
780
- by_id = {
781
- _row_to_user_playbook(row).user_playbook_id: _row_to_user_playbook(row)
782
- for row in rows
783
- }
784
- return [by_id[upid] for upid in user_playbook_ids if upid in by_id]
785
-
786
- # ------------------------------------------------------------------
787
- # Agent Playbook methods
788
- # ------------------------------------------------------------------
789
-
790
- def _index_agent_playbook_fts_vec(self, ap: AgentPlaybook) -> None:
791
- """Update the FTS and vector indexes for a single agent playbook row.
792
-
793
- Must be called AFTER the row's transaction has been committed. The FTS
794
- and vec helpers self-commit, so they must never be interleaved inside a
795
- transaction that still has pending mutations.
796
-
797
- Args:
798
- ap (AgentPlaybook): The already-saved playbook (``agent_playbook_id``
799
- must be set).
800
- """
801
- fts_parts = [ap.trigger or "", ap.content or ""]
802
- if ap.expanded_terms:
803
- fts_parts.append(ap.expanded_terms)
804
- self._fts_upsert(
805
- "agent_playbooks_fts",
806
- ap.agent_playbook_id,
807
- search_text=" ".join(p for p in fts_parts if p) or "",
808
- )
809
- if ap.embedding:
810
- self._vec_upsert("agent_playbooks_vec", ap.agent_playbook_id, ap.embedding)
811
-
812
- def _insert_agent_playbook_row(
813
- self, conn: "sqlite3.Connection", ap: AgentPlaybook, created_at_iso: str
814
- ) -> "sqlite3.Cursor":
815
- """Execute the agent_playbooks INSERT and populate ``ap.agent_playbook_id``.
816
-
817
- Runs the INSERT inside the caller's connection context; does NOT commit.
818
- The caller is responsible for committing (or rolling back) the transaction.
819
-
820
- Args:
821
- conn: The open SQLite connection to execute against.
822
- ap: The playbook to insert; ``agent_playbook_id`` is set on return.
823
- created_at_iso: ISO-8601 timestamp string for ``created_at``.
824
-
825
- Returns:
826
- sqlite3.Cursor: The cursor from the INSERT (``lastrowid`` is the new PK).
827
- """
828
- cur = conn.execute(
829
- """INSERT INTO agent_playbooks
830
- (playbook_name, created_at, agent_version, content,
831
- trigger, rationale, blocking_issue,
832
- playbook_status, playbook_metadata, embedding,
833
- expanded_terms, tags, status,
834
- merged_into, superseded_by)
835
- VALUES (?,?,?,?,?,?,?,?,?,?,?,?,?,?,?)""",
836
- (
837
- ap.playbook_name,
838
- created_at_iso,
839
- ap.agent_version,
840
- ap.content,
841
- ap.trigger,
842
- ap.rationale,
843
- json.dumps(ap.blocking_issue.model_dump())
844
- if ap.blocking_issue
845
- else None,
846
- ap.playbook_status.value
847
- if isinstance(ap.playbook_status, PlaybookStatus)
848
- else ap.playbook_status,
849
- ap.playbook_metadata,
850
- _json_dumps(ap.embedding),
851
- ap.expanded_terms,
852
- _json_dumps(ap.tags),
853
- ap.status.value if ap.status else None,
854
- ap.merged_into,
855
- ap.superseded_by,
856
- ),
857
- )
858
- ap.agent_playbook_id = cur.lastrowid or 0
859
- return cur
860
-
861
- @SQLiteStorageBase.handle_exceptions
862
- def save_agent_playbooks(
863
- self, agent_playbooks: list[AgentPlaybook]
864
- ) -> list[AgentPlaybook]:
865
- saved: list[AgentPlaybook] = []
866
- for ap in agent_playbooks:
867
- embedding_text = ap.trigger or ap.content
868
- if self._should_expand_documents():
869
- with ThreadPoolExecutor(max_workers=2) as executor:
870
- emb_future = executor.submit(self._get_embedding, embedding_text)
871
- exp_future = executor.submit(self._expand_document, embedding_text)
872
- ap.embedding = emb_future.result(timeout=15)
873
- ap.expanded_terms = exp_future.result(timeout=15)
874
- else:
875
- ap.embedding = self._get_embedding(embedding_text)
876
-
877
- created_at_iso = _epoch_to_iso(ap.created_at)
878
- with self._lock:
879
- self._insert_agent_playbook_row(self.conn, ap, created_at_iso)
880
- self.conn.commit()
881
-
882
- self._index_agent_playbook_fts_vec(ap)
883
- saved.append(ap)
884
- return saved
885
-
886
- @SQLiteStorageBase.handle_exceptions
887
- def save_agent_playbook_with_aggregate_event(
888
- self,
889
- agent_playbook: AgentPlaybook,
890
- *,
891
- source_ids: list[str],
892
- request_id: str,
893
- run_mode: str,
894
- ) -> AgentPlaybook:
895
- """Persist an agent playbook AND its ``op=aggregate`` lineage event atomically.
896
-
897
- The INSERT and the event are committed in a single transaction — if either
898
- fails, both roll back. The event is the sole record of the run->playbook
899
- membership for reconstruction, so atomicity is critical.
900
-
901
- Args:
902
- agent_playbook (AgentPlaybook): The playbook to persist.
903
- source_ids (list[str]): IDs of the source entities that produced this playbook.
904
- request_id (str): The aggregation run ID.
905
- run_mode (str): Aggregation run mode (e.g. ``full_archive`` or ``incremental``).
906
-
907
- Returns:
908
- AgentPlaybook: The saved playbook with ``agent_playbook_id`` populated.
909
-
910
- Raises:
911
- ValueError: If ``request_id`` is empty (would produce an unreconstructable event).
912
- """
913
- if not request_id or not request_id.strip():
914
- raise ValueError(
915
- "save_agent_playbook_with_aggregate_event requires a non-empty request_id"
916
- )
917
- ap = agent_playbook
918
- embedding_text = ap.trigger or ap.content
919
- if self._should_expand_documents():
920
- with ThreadPoolExecutor(max_workers=2) as executor:
921
- emb_future = executor.submit(self._get_embedding, embedding_text)
922
- exp_future = executor.submit(self._expand_document, embedding_text)
923
- ap.embedding = emb_future.result(timeout=15)
924
- ap.expanded_terms = exp_future.result(timeout=15)
925
- else:
926
- ap.embedding = self._get_embedding(embedding_text)
927
-
928
- created_at_iso = _epoch_to_iso(ap.created_at)
929
- with self._lock:
930
- try:
931
- self._insert_agent_playbook_row(self.conn, ap, created_at_iso)
932
- _append_event_stmt(
933
- self.conn,
934
- org_id=self.org_id,
935
- entity_type="agent_playbook",
936
- entity_id=str(ap.agent_playbook_id),
937
- op="aggregate",
938
- prov="wasDerivedFrom",
939
- source_ids=source_ids,
940
- actor="aggregator",
941
- request_id=request_id,
942
- reason=f"{AGGREGATE_REASON_PREFIX}{run_mode}",
943
- )
944
- self.conn.commit()
945
- except Exception:
946
- self.conn.rollback()
947
- raise
948
-
949
- # FTS/vec indexing AFTER commit — these helpers self-commit and must
950
- # not be interleaved inside the atomic transaction above.
951
- # Index failure does NOT invalidate the committed row+event; the index
952
- # is reconstructable from the authoritative row.
953
- try:
954
- self._index_agent_playbook_fts_vec(ap)
955
- except Exception:
956
- logger.exception(
957
- "FTS/vec indexing failed for agent_playbook %s (row committed, index skipped)",
958
- ap.agent_playbook_id,
959
- )
960
- return ap
961
-
962
- @SQLiteStorageBase.handle_exceptions
963
- def get_agent_playbooks(
964
- self,
965
- limit: int = 100,
966
- playbook_name: str | None = None,
967
- agent_version: str | None = None,
968
- status_filter: list[Status | None] | None = None,
969
- playbook_status_filter: list[PlaybookStatus] | None = None,
970
- tags: list[str] | None = None,
971
- ) -> list[AgentPlaybook]:
972
- sql = "SELECT * FROM agent_playbooks WHERE 1=1"
973
- params: list[Any] = []
974
-
975
- if playbook_name:
976
- sql += " AND playbook_name = ?"
977
- params.append(playbook_name)
978
-
979
- if agent_version is not None:
980
- sql += " AND agent_version = ?"
981
- params.append(agent_version)
982
-
983
- if status_filter is not None:
984
- frag, sparams = _build_status_sql(status_filter)
985
- sql += f" AND {frag}"
986
- params.extend(sparams)
987
- else:
988
- sql += " AND status IS NULL"
989
-
990
- if playbook_status_filter:
991
- ph = ",".join("?" for _ in playbook_status_filter)
992
- sql += f" AND playbook_status IN ({ph})"
993
- params.extend(ps.value for ps in playbook_status_filter)
994
- tag_frag, tag_params = _build_tags_sql("agent_playbooks", tags)
995
- if tag_frag:
996
- sql += f" AND {tag_frag}"
997
- params.extend(tag_params)
998
-
999
- sql += " ORDER BY created_at DESC LIMIT ?"
1000
- params.append(limit)
1001
- rows = self._fetchall(sql, params)
1002
- return [_row_to_agent_playbook(r) for r in rows]
1003
-
1004
- @SQLiteStorageBase.handle_exceptions
1005
- def get_agent_playbook_by_id(
1006
- self, agent_playbook_id: int, *, include_tombstones: bool = False
1007
- ) -> AgentPlaybook | None:
1008
- sql = "SELECT * FROM agent_playbooks WHERE agent_playbook_id = ?"
1009
- if not include_tombstones:
1010
- sql += " AND (status IS NULL OR status NOT IN (?, ?))"
1011
- row = self._fetchone(sql, (agent_playbook_id, *_TOMBSTONE_STATUS_VALUES))
1012
- else:
1013
- row = self._fetchone(sql, (agent_playbook_id,))
1014
- return _row_to_agent_playbook(row) if row else None
1015
-
1016
- @SQLiteStorageBase.handle_exceptions
1017
- def delete_all_agent_playbooks(self) -> None:
1018
- batch_request_id = uuid.uuid4().hex
1019
- with self._lock:
1020
- ids = [
1021
- r["agent_playbook_id"]
1022
- for r in self.conn.execute(
1023
- "SELECT agent_playbook_id FROM agent_playbooks"
1024
- ).fetchall()
1025
- ]
1026
- self.conn.execute("DELETE FROM agent_playbooks")
1027
- for apid in ids:
1028
- _emit_hard_delete_playbook(
1029
- self.conn,
1030
- org_id=self.org_id,
1031
- entity_type="agent_playbook",
1032
- entity_id=str(apid),
1033
- request_id=batch_request_id,
1034
- )
1035
- self.conn.commit()
1036
- self._delete_playbook_search_rows("agent", ids)
1037
-
1038
- @SQLiteStorageBase.handle_exceptions
1039
- def delete_agent_playbook(self, agent_playbook_id: int) -> None:
1040
- with self._lock:
1041
- cur = self.conn.execute(
1042
- "DELETE FROM agent_playbooks WHERE agent_playbook_id = ?",
1043
- (agent_playbook_id,),
1044
- )
1045
- if cur.rowcount > 0:
1046
- _emit_hard_delete_playbook(
1047
- self.conn,
1048
- org_id=self.org_id,
1049
- entity_type="agent_playbook",
1050
- entity_id=str(agent_playbook_id),
1051
- request_id=uuid.uuid4().hex,
1052
- )
1053
- self._delete_playbook_search_rows(
1054
- "agent", [agent_playbook_id], commit=False
1055
- )
1056
- self.conn.commit()
1057
-
1058
- @SQLiteStorageBase.handle_exceptions
1059
- def delete_all_agent_playbooks_by_playbook_name(
1060
- self, playbook_name: str, agent_version: str | None = None
1061
- ) -> None:
1062
- sql = "SELECT agent_playbook_id FROM agent_playbooks WHERE playbook_name = ?"
1063
- params: list[Any] = [playbook_name]
1064
- if agent_version is not None:
1065
- sql += " AND agent_version = ?"
1066
- params.append(agent_version)
1067
- batch_request_id = uuid.uuid4().hex
1068
- with self._lock:
1069
- ids = [
1070
- r["agent_playbook_id"]
1071
- for r in self.conn.execute(sql, params).fetchall()
1072
- ]
1073
- if not ids:
1074
- return
1075
- ph = ",".join("?" for _ in ids)
1076
- self.conn.execute(
1077
- f"DELETE FROM agent_playbooks WHERE agent_playbook_id IN ({ph})", ids
1078
- )
1079
- for apid in ids:
1080
- _emit_hard_delete_playbook(
1081
- self.conn,
1082
- org_id=self.org_id,
1083
- entity_type="agent_playbook",
1084
- entity_id=str(apid),
1085
- request_id=batch_request_id,
1086
- )
1087
- self.conn.commit()
1088
- self._delete_playbook_search_rows("agent", ids)
1089
-
1090
- @SQLiteStorageBase.handle_exceptions
1091
- def delete_agent_playbooks_by_ids(
1092
- self, agent_playbook_ids: list[int], *, emit_hard_delete: bool = True
1093
- ) -> None:
1094
- if not agent_playbook_ids:
1095
- return
1096
- ph = ",".join("?" for _ in agent_playbook_ids)
1097
- batch_request_id = uuid.uuid4().hex
1098
- with self._lock:
1099
- existing = [
1100
- r["agent_playbook_id"]
1101
- for r in self.conn.execute(
1102
- f"SELECT agent_playbook_id FROM agent_playbooks WHERE agent_playbook_id IN ({ph})",
1103
- agent_playbook_ids,
1104
- ).fetchall()
1105
- ]
1106
- self.conn.execute(
1107
- f"DELETE FROM agent_playbooks WHERE agent_playbook_id IN ({ph})",
1108
- agent_playbook_ids,
1109
- )
1110
- if emit_hard_delete:
1111
- for apid in existing:
1112
- _emit_hard_delete_playbook(
1113
- self.conn,
1114
- org_id=self.org_id,
1115
- entity_type="agent_playbook",
1116
- entity_id=str(apid),
1117
- request_id=batch_request_id,
1118
- actor="system",
1119
- )
1120
- self.conn.commit()
1121
- self._delete_playbook_search_rows("agent", agent_playbook_ids)
1122
-
1123
- @SQLiteStorageBase.handle_exceptions
1124
- def update_agent_playbook_status(
1125
- self, agent_playbook_id: int, playbook_status: PlaybookStatus
1126
- ) -> None:
1127
- """Update an agent playbook's status and emit a status_change lineage event.
1128
-
1129
- Each call generates a fresh request_id so every status change is recorded as
1130
- a distinct audit event (not collapsed by the idempotency key).
1131
- """
1132
- with self._lock:
1133
- row = self.conn.execute(
1134
- "SELECT playbook_status FROM agent_playbooks WHERE agent_playbook_id = ?",
1135
- (agent_playbook_id,),
1136
- ).fetchone()
1137
- if not row:
1138
- raise ValueError(
1139
- f"Agent playbook with ID {agent_playbook_id} not found"
1140
- )
1141
- prior_playbook_status = row["playbook_status"]
1142
- old_status = prior_playbook_status or "None"
1143
- cur = self.conn.execute(
1144
- "UPDATE agent_playbooks SET playbook_status = ? WHERE agent_playbook_id = ?",
1145
- (playbook_status.value, agent_playbook_id),
1146
- )
1147
- if cur.rowcount > 0:
1148
- _append_event_stmt(
1149
- self.conn,
1150
- org_id=self.org_id,
1151
- entity_type="agent_playbook",
1152
- entity_id=str(agent_playbook_id),
1153
- op="status_change",
1154
- prov="wasInvalidatedBy",
1155
- source_ids=[],
1156
- actor="api",
1157
- request_id=uuid.uuid4().hex,
1158
- reason=f"{old_status}->{playbook_status.value}",
1159
- from_status=prior_playbook_status,
1160
- to_status=playbook_status.value,
1161
- status_namespace="playbook_status",
1162
- )
1163
- self.conn.commit()
1164
-
1165
- @SQLiteStorageBase.handle_exceptions
1166
- def update_agent_playbook(
1167
- self,
1168
- agent_playbook_id: int,
1169
- playbook_name: str | None = None,
1170
- content: str | None = None,
1171
- trigger: str | None = None,
1172
- rationale: str | None = None,
1173
- blocking_issue: BlockingIssue | None = None,
1174
- playbook_status: PlaybookStatus | None = None,
1175
- tags: list[str] | None = None,
1176
- ) -> None:
1177
- updates: list[str] = []
1178
- params: list[Any] = []
1179
- if playbook_name is not None:
1180
- updates.append("playbook_name = ?")
1181
- params.append(playbook_name)
1182
- if content is not None:
1183
- updates.append("content = ?")
1184
- params.append(content)
1185
- if trigger is not None:
1186
- updates.append("trigger = ?")
1187
- params.append(trigger)
1188
- if rationale is not None:
1189
- updates.append("rationale = ?")
1190
- params.append(rationale)
1191
- if blocking_issue is not None:
1192
- updates.append("blocking_issue = ?")
1193
- params.append(json.dumps(blocking_issue.model_dump()))
1194
- if playbook_status is not None:
1195
- updates.append("playbook_status = ?")
1196
- params.append(playbook_status.value)
1197
- if tags is not None:
1198
- updates.append("tags = ?")
1199
- params.append(_json_dumps(tags))
1200
- if updates:
1201
- params.append(agent_playbook_id)
1202
- op = "revise" if content is not None else "status_change"
1203
- prov = "wasRevisionOf" if op == "revise" else "wasInvalidatedBy"
1204
- with self._lock:
1205
- prior_row = self.conn.execute(
1206
- "SELECT playbook_status FROM agent_playbooks WHERE agent_playbook_id = ?",
1207
- (agent_playbook_id,),
1208
- ).fetchone()
1209
- if not prior_row:
1210
- raise ValueError(
1211
- f"Agent playbook with ID {agent_playbook_id} not found"
1212
- )
1213
- prior_playbook_status = prior_row["playbook_status"]
1214
- cur = self.conn.execute(
1215
- f"UPDATE agent_playbooks SET {', '.join(updates)} WHERE agent_playbook_id = ?",
1216
- tuple(params),
1217
- )
1218
- if cur.rowcount > 0:
1219
- # Populate structured status fields only when playbook_status is
1220
- # among the updated fields and the op is status_change (not revise).
1221
- if op == "status_change" and playbook_status is not None:
1222
- from_status = prior_playbook_status
1223
- to_status = playbook_status.value
1224
- status_namespace: str | None = "playbook_status"
1225
- else:
1226
- from_status = None
1227
- to_status = None
1228
- status_namespace = None
1229
- _append_event_stmt(
1230
- self.conn,
1231
- org_id=self.org_id,
1232
- entity_type="agent_playbook",
1233
- entity_id=str(agent_playbook_id),
1234
- op=op,
1235
- prov=prov,
1236
- source_ids=[],
1237
- actor="api",
1238
- request_id=uuid.uuid4().hex,
1239
- reason="in-place update",
1240
- from_status=from_status,
1241
- to_status=to_status,
1242
- status_namespace=status_namespace,
1243
- )
1244
- self.conn.commit()
1245
-
1246
- @SQLiteStorageBase.handle_exceptions
1247
- def update_user_playbook(
1248
- self,
1249
- user_playbook_id: int,
1250
- playbook_name: str | None = None,
1251
- content: str | None = None,
1252
- trigger: str | None = None,
1253
- rationale: str | None = None,
1254
- blocking_issue: BlockingIssue | None = None,
1255
- tags: list[str] | None = None,
1256
- ) -> None:
1257
- updates: list[str] = []
1258
- params: list[Any] = []
1259
- if playbook_name is not None:
1260
- updates.append("playbook_name = ?")
1261
- params.append(playbook_name)
1262
- if content is not None:
1263
- updates.append("content = ?")
1264
- params.append(content)
1265
- if trigger is not None:
1266
- updates.append("trigger = ?")
1267
- params.append(trigger)
1268
- if rationale is not None:
1269
- updates.append("rationale = ?")
1270
- params.append(rationale)
1271
- if blocking_issue is not None:
1272
- updates.append("blocking_issue = ?")
1273
- params.append(json.dumps(blocking_issue.model_dump()))
1274
- if tags is not None:
1275
- updates.append("tags = ?")
1276
- params.append(_json_dumps(tags))
1277
- if updates:
1278
- params.append(user_playbook_id)
1279
- semantic_change = any(
1280
- value is not None for value in (content, trigger, rationale)
1281
- )
1282
- op = "revise" if semantic_change else "status_change"
1283
- prov = "wasRevisionOf" if op == "revise" else "wasInvalidatedBy"
1284
- with self._lock:
1285
- cur = self.conn.execute(
1286
- f"UPDATE user_playbooks SET {', '.join(updates)} WHERE user_playbook_id = ?",
1287
- tuple(params),
1288
- )
1289
- if cur.rowcount > 0:
1290
- _append_event_stmt(
1291
- self.conn,
1292
- org_id=self.org_id,
1293
- entity_type="user_playbook",
1294
- entity_id=str(user_playbook_id),
1295
- op=op,
1296
- prov=prov,
1297
- source_ids=[],
1298
- actor="api",
1299
- request_id=uuid.uuid4().hex,
1300
- reason="in-place update",
1301
- from_status=None,
1302
- to_status=None,
1303
- status_namespace=None,
1304
- )
1305
- self.conn.commit()
1306
-
1307
- @SQLiteStorageBase.handle_exceptions
1308
- def supersede_user_playbooks_by_ids(
1309
- self, user_playbook_ids: list[int], request_id: str
1310
- ) -> int:
1311
- """Soft-delete user playbooks by setting status to SUPERSEDED.
1312
-
1313
- Preserves the row content for strict point-in-time attribution reads.
1314
- Eligible rows are any non-tombstoned status (CURRENT / PENDING /
1315
- ARCHIVED). Atomic: all updates and lineage events commit together.
1316
- """
1317
- if not user_playbook_ids:
1318
- return 0
1319
- if not request_id:
1320
- raise ValueError("request_id must be non-empty for supersede")
1321
- now_ts = _epoch_now()
1322
- updated = 0
1323
- with self._lock:
1324
- for upid in user_playbook_ids:
1325
- row = self.conn.execute(
1326
- "SELECT status FROM user_playbooks WHERE user_playbook_id = ?",
1327
- (upid,),
1328
- ).fetchone()
1329
- if row is None:
1330
- continue
1331
- old_status = row["status"]
1332
- cur = self.conn.execute(
1333
- "UPDATE user_playbooks SET status = ?, retired_at = ?"
1334
- " WHERE user_playbook_id = ?"
1335
- " AND (status IS NULL OR status NOT IN (?, ?))",
1336
- (
1337
- Status.SUPERSEDED.value,
1338
- now_ts,
1339
- upid,
1340
- *_TOMBSTONE_STATUS_VALUES,
1341
- ),
1342
- )
1343
- if cur.rowcount > 0:
1344
- _emit_supersede_user_playbook(
1345
- self.conn,
1346
- org_id=self.org_id,
1347
- entity_id=str(upid),
1348
- old_status=old_status,
1349
- request_id=request_id,
1350
- )
1351
- updated += 1
1352
- self.conn.commit()
1353
- return updated
1354
-
1355
- @SQLiteStorageBase.handle_exceptions
1356
- def archive_agent_playbooks_by_playbook_name(
1357
- self, playbook_name: str, agent_version: str | None = None
1358
- ) -> None:
1359
- where = (
1360
- "playbook_name = ? AND playbook_status != ?"
1361
- " AND (status IS NULL OR status != 'archived')"
1362
- )
1363
- params: list[Any] = [playbook_name, PlaybookStatus.APPROVED.value]
1364
- if agent_version is not None:
1365
- where += " AND agent_version = ?"
1366
- params.append(agent_version)
1367
- now_ts = _epoch_now()
1368
- batch_request_id = uuid.uuid4().hex
1369
- with self._lock:
1370
- affected = self.conn.execute(
1371
- f"SELECT agent_playbook_id, status FROM agent_playbooks WHERE {where}",
1372
- params,
1373
- ).fetchall()
1374
- self.conn.execute(
1375
- f"UPDATE agent_playbooks SET status = 'archived', retired_at = ? WHERE {where}",
1376
- [now_ts, *params],
1377
- )
1378
- for row in affected:
1379
- prior = row["status"] or "None"
1380
- _append_event_stmt(
1381
- self.conn,
1382
- org_id=self.org_id,
1383
- entity_type="agent_playbook",
1384
- entity_id=str(row["agent_playbook_id"]),
1385
- op="status_change",
1386
- prov="wasInvalidatedBy",
1387
- source_ids=[],
1388
- actor="api",
1389
- request_id=batch_request_id,
1390
- reason=f"{prior}->archived",
1391
- from_status=row["status"],
1392
- to_status="archived",
1393
- status_namespace="lifecycle_status",
1394
- )
1395
- self.conn.commit()
1396
-
1397
- @SQLiteStorageBase.handle_exceptions
1398
- def archive_agent_playbooks_by_ids(self, agent_playbook_ids: list[int]) -> None:
1399
- if not agent_playbook_ids:
1400
- return
1401
- ph = ",".join("?" for _ in agent_playbook_ids)
1402
- now_ts = _epoch_now()
1403
- batch_request_id = uuid.uuid4().hex
1404
- with self._lock:
1405
- affected = self.conn.execute(
1406
- f"SELECT agent_playbook_id, status FROM agent_playbooks"
1407
- f" WHERE agent_playbook_id IN ({ph}) AND playbook_status != ?"
1408
- f" AND (status IS NULL OR status != 'archived')",
1409
- [*agent_playbook_ids, PlaybookStatus.APPROVED.value],
1410
- ).fetchall()
1411
- self.conn.execute(
1412
- f"UPDATE agent_playbooks SET status = 'archived', retired_at = ?"
1413
- f" WHERE agent_playbook_id IN ({ph}) AND playbook_status != ?"
1414
- f" AND (status IS NULL OR status != 'archived')",
1415
- [now_ts, *agent_playbook_ids, PlaybookStatus.APPROVED.value],
1416
- )
1417
- for row in affected:
1418
- prior = row["status"] or "None"
1419
- _append_event_stmt(
1420
- self.conn,
1421
- org_id=self.org_id,
1422
- entity_type="agent_playbook",
1423
- entity_id=str(row["agent_playbook_id"]),
1424
- op="status_change",
1425
- prov="wasInvalidatedBy",
1426
- source_ids=[],
1427
- actor="api",
1428
- request_id=batch_request_id,
1429
- reason=f"{prior}->archived",
1430
- from_status=row["status"],
1431
- to_status="archived",
1432
- status_namespace="lifecycle_status",
1433
- )
1434
- self.conn.commit()
1435
-
1436
- @SQLiteStorageBase.handle_exceptions
1437
- def supersede_agent_playbooks_by_ids(
1438
- self, agent_playbook_ids: list[int], request_id: str
1439
- ) -> int:
1440
- """Soft-delete agent playbooks by setting status to SUPERSEDED, emitting set-based lineage.
1441
-
1442
- For each eligible id (not APPROVED, not already tombstoned), updates status to
1443
- SUPERSEDED and emits one ``status_change`` event under the shared ``request_id``.
1444
- Atomic: one ``conn.commit()`` at the end, guarded on rowcount per id.
1445
- FTS/vec rows are NOT removed — reads exclude tombstones by status filter.
1446
-
1447
- Args:
1448
- agent_playbook_ids (list[int]): Agent playbook ids to supersede.
1449
- request_id (str): Shared request id for all emitted lineage events.
1450
-
1451
- Returns:
1452
- int: Number of agent playbooks actually updated.
1453
- """
1454
- if not agent_playbook_ids:
1455
- return 0
1456
- if not request_id:
1457
- raise ValueError("request_id must be non-empty for supersede")
1458
- now_ts = _epoch_now()
1459
- updated = 0
1460
- with self._lock:
1461
- for apid in agent_playbook_ids:
1462
- row = self.conn.execute(
1463
- "SELECT status FROM agent_playbooks WHERE agent_playbook_id = ?",
1464
- (apid,),
1465
- ).fetchone()
1466
- if row is None:
1467
- continue
1468
- old_status = row["status"]
1469
- # NOTE (M3): The model supersede_profiles_by_ids adds an eligible-check
1470
- # `if old_status_val not in eligible: continue` before the UPDATE. For
1471
- # agent_playbooks the ineligible condition spans two columns (status in
1472
- # _TOMBSTONE_STATUS_VALUES OR playbook_status == APPROVED), so aligning
1473
- # would require adding playbook_status to the SELECT. The UPDATE WHERE
1474
- # clause already excludes those rows atomically; the extra continue here
1475
- # would be a no-op and not worth the added complexity.
1476
- cur = self.conn.execute(
1477
- "UPDATE agent_playbooks SET status = ?, retired_at = ?"
1478
- " WHERE agent_playbook_id = ? AND playbook_status != ?"
1479
- " AND (status IS NULL OR status NOT IN (?, ?))",
1480
- (
1481
- Status.SUPERSEDED.value,
1482
- now_ts,
1483
- apid,
1484
- PlaybookStatus.APPROVED.value,
1485
- *_TOMBSTONE_STATUS_VALUES,
1486
- ),
1487
- )
1488
- if cur.rowcount > 0:
1489
- _emit_supersede_playbook(
1490
- self.conn,
1491
- org_id=self.org_id,
1492
- entity_id=str(apid),
1493
- old_status=old_status,
1494
- request_id=request_id,
1495
- )
1496
- updated += 1
1497
- self.conn.commit()
1498
- return updated
1499
-
1500
- @SQLiteStorageBase.handle_exceptions
1501
- def supersede_agent_playbooks_by_playbook_name(
1502
- self, playbook_name: str, agent_version: str | None, request_id: str
1503
- ) -> int:
1504
- """Soft-delete archived agent playbooks by name/version via SUPERSEDED status.
1505
-
1506
- Mirrors ``delete_archived_agent_playbooks_by_playbook_name`` but converts
1507
- the hard-delete to a soft-supersede with status_change lineage events.
1508
- Atomic: one ``conn.commit()`` at the end.
1509
- FTS/vec rows are NOT removed.
1510
-
1511
- Args:
1512
- playbook_name (str): Playbook name to supersede.
1513
- agent_version (str | None): Agent version filter. None matches all versions.
1514
- request_id (str): Shared request id for all emitted lineage events.
1515
-
1516
- Returns:
1517
- int: Number of agent playbooks actually updated.
1518
- """
1519
- if not request_id:
1520
- raise ValueError("request_id must be non-empty for supersede")
1521
- sql = (
1522
- "SELECT agent_playbook_id, status FROM agent_playbooks"
1523
- " WHERE playbook_name = ? AND status = 'archived'"
1524
- )
1525
- params: list[Any] = [playbook_name]
1526
- if agent_version is not None:
1527
- sql += " AND agent_version = ?"
1528
- params.append(agent_version)
1529
- updated = 0
1530
- with self._lock:
1531
- rows = self.conn.execute(sql, params).fetchall()
1532
- if not rows:
1533
- return 0
1534
- now_ts = _epoch_now()
1535
- for row in rows:
1536
- apid = row["agent_playbook_id"]
1537
- old_status = row["status"]
1538
- cur = self.conn.execute(
1539
- "UPDATE agent_playbooks SET status = ?, retired_at = ?"
1540
- " WHERE agent_playbook_id = ? AND playbook_status != ?"
1541
- " AND status = 'archived'",
1542
- (
1543
- Status.SUPERSEDED.value,
1544
- now_ts,
1545
- apid,
1546
- PlaybookStatus.APPROVED.value,
1547
- ),
1548
- )
1549
- if cur.rowcount > 0:
1550
- _emit_supersede_playbook(
1551
- self.conn,
1552
- org_id=self.org_id,
1553
- entity_id=str(apid),
1554
- old_status=old_status,
1555
- request_id=request_id,
1556
- )
1557
- updated += 1
1558
- self.conn.commit()
1559
- return updated
1560
-
1561
- @SQLiteStorageBase.handle_exceptions
1562
- def restore_archived_agent_playbooks_by_playbook_name(
1563
- self, playbook_name: str, agent_version: str | None = None
1564
- ) -> None:
1565
- sql = "UPDATE agent_playbooks SET status = NULL, retired_at = NULL WHERE playbook_name = ? AND status = 'archived'"
1566
- params: list[Any] = [playbook_name]
1567
- if agent_version is not None:
1568
- sql += " AND agent_version = ?"
1569
- params.append(agent_version)
1570
- self._execute(sql, params)
1571
-
1572
- @SQLiteStorageBase.handle_exceptions
1573
- def restore_archived_agent_playbooks_by_ids(
1574
- self, agent_playbook_ids: list[int]
1575
- ) -> None:
1576
- if not agent_playbook_ids:
1577
- return
1578
- ph = ",".join("?" for _ in agent_playbook_ids)
1579
- self._execute(
1580
- f"UPDATE agent_playbooks SET status = NULL, retired_at = NULL WHERE agent_playbook_id IN ({ph}) AND status = 'archived'",
1581
- agent_playbook_ids,
1582
- )
1583
-
1584
- # ------------------------------------------------------------------
1585
- # Playbook optimizer methods
1586
- # ------------------------------------------------------------------
1587
-
1588
- @SQLiteStorageBase.handle_exceptions
1589
- def set_source_user_playbook_ids_for_agent_playbook(
1590
- self, agent_playbook_id: int, user_playbook_ids: list[int]
1591
- ) -> None:
1592
- self.set_source_windows_for_agent_playbook(
1593
- agent_playbook_id,
1594
- [
1595
- AgentPlaybookSourceWindow(
1596
- user_playbook_id=upid, source_interaction_ids=[]
1597
- )
1598
- for upid in user_playbook_ids
1599
- ],
1600
- )
1601
-
1602
- @SQLiteStorageBase.handle_exceptions
1603
- def get_source_user_playbook_ids_for_agent_playbook(
1604
- self, agent_playbook_id: int
1605
- ) -> list[int]:
1606
- return [
1607
- window.user_playbook_id
1608
- for window in self.get_source_windows_for_agent_playbook(agent_playbook_id)
1609
- ]
1610
-
1611
- @SQLiteStorageBase.handle_exceptions
1612
- def get_source_user_playbook_ids_for_agent_playbooks(
1613
- self, agent_playbook_ids: Sequence[int]
1614
- ) -> dict[int, list[int]]:
1615
- if not agent_playbook_ids:
1616
- return {}
1617
- unique_ids = list(dict.fromkeys(int(apid) for apid in agent_playbook_ids))
1618
- ph = ",".join("?" for _ in unique_ids)
1619
- rows = self._fetchall(
1620
- f"""SELECT agent_playbook_id, user_playbook_id
1621
- FROM agent_playbook_source_user_playbooks
1622
- WHERE agent_playbook_id IN ({ph})
1623
- ORDER BY agent_playbook_id ASC, user_playbook_id ASC""",
1624
- unique_ids,
1625
- )
1626
- by_agent_id: dict[int, list[int]] = {apid: [] for apid in unique_ids}
1627
- seen_by_agent_id: dict[int, set[int]] = {apid: set() for apid in unique_ids}
1628
- for row in rows:
1629
- agent_playbook_id = int(row["agent_playbook_id"])
1630
- user_playbook_id = int(row["user_playbook_id"])
1631
- seen = seen_by_agent_id.setdefault(agent_playbook_id, set())
1632
- if user_playbook_id not in seen:
1633
- by_agent_id.setdefault(agent_playbook_id, []).append(user_playbook_id)
1634
- seen.add(user_playbook_id)
1635
- return by_agent_id
1636
-
1637
- @SQLiteStorageBase.handle_exceptions
1638
- def set_source_windows_for_agent_playbook(
1639
- self,
1640
- agent_playbook_id: int,
1641
- source_windows: list[AgentPlaybookSourceWindow],
1642
- ) -> None:
1643
- by_id: dict[int, list[int]] = {}
1644
- for window in source_windows:
1645
- ids = by_id.setdefault(window.user_playbook_id, [])
1646
- seen = set(ids)
1647
- for source_id in window.source_interaction_ids:
1648
- if source_id not in seen:
1649
- ids.append(source_id)
1650
- seen.add(source_id)
1651
- with self._lock:
1652
- self.conn.execute(
1653
- "DELETE FROM agent_playbook_source_user_playbooks WHERE agent_playbook_id = ?",
1654
- (agent_playbook_id,),
1655
- )
1656
- self.conn.executemany(
1657
- """INSERT OR IGNORE INTO agent_playbook_source_user_playbooks
1658
- (agent_playbook_id, user_playbook_id, source_interaction_ids)
1659
- VALUES (?, ?, ?)""",
1660
- [
1661
- (
1662
- agent_playbook_id,
1663
- upid,
1664
- _json_dumps(source_interaction_ids) or "[]",
1665
- )
1666
- for upid, source_interaction_ids in by_id.items()
1667
- ],
1668
- )
1669
- self.conn.commit()
1670
-
1671
- @SQLiteStorageBase.handle_exceptions
1672
- def get_source_windows_for_agent_playbook(
1673
- self, agent_playbook_id: int
1674
- ) -> list[AgentPlaybookSourceWindow]:
1675
- rows = self._fetchall(
1676
- """SELECT user_playbook_id, source_interaction_ids
1677
- FROM agent_playbook_source_user_playbooks
1678
- WHERE agent_playbook_id = ?
1679
- ORDER BY user_playbook_id ASC""",
1680
- (agent_playbook_id,),
1681
- )
1682
- return [
1683
- AgentPlaybookSourceWindow(
1684
- user_playbook_id=int(row["user_playbook_id"]),
1685
- source_interaction_ids=_json_loads(row["source_interaction_ids"]) or [],
1686
- )
1687
- for row in rows
1688
- ]
1689
-
1690
- @SQLiteStorageBase.handle_exceptions
1691
- def create_playbook_optimization_job(
1692
- self, job: PlaybookOptimizationJob
1693
- ) -> PlaybookOptimizationJob:
1694
- with self._lock:
1695
- cur = self.conn.execute(
1696
- """INSERT INTO playbook_optimization_jobs
1697
- (target_kind, target_id, status, best_candidate_id,
1698
- successor_target_id, decision_reason, metadata_json,
1699
- created_at, updated_at)
1700
- VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)""",
1701
- (
1702
- job.target_kind,
1703
- job.target_id,
1704
- job.status,
1705
- job.best_candidate_id,
1706
- job.successor_target_id,
1707
- job.decision_reason,
1708
- job.metadata_json,
1709
- job.created_at,
1710
- job.updated_at,
1711
- ),
1712
- )
1713
- job.job_id = cur.lastrowid or 0
1714
- self.conn.commit()
1715
- return job
1716
-
1717
- @SQLiteStorageBase.handle_exceptions
1718
- def update_playbook_optimization_job(
1719
- self,
1720
- job_id: int,
1721
- *,
1722
- status: str | None = None,
1723
- best_candidate_id: int | None = None,
1724
- successor_target_id: int | None = None,
1725
- decision_reason: str | None = None,
1726
- metadata_json: str | None = None,
1727
- ) -> None:
1728
- updates: list[str] = ["updated_at = strftime('%s','now')"]
1729
- params: list[Any] = []
1730
- if status is not None:
1731
- updates.append("status = ?")
1732
- params.append(status)
1733
- if best_candidate_id is not None:
1734
- updates.append("best_candidate_id = ?")
1735
- params.append(best_candidate_id)
1736
- if successor_target_id is not None:
1737
- updates.append("successor_target_id = ?")
1738
- params.append(successor_target_id)
1739
- if decision_reason is not None:
1740
- updates.append("decision_reason = ?")
1741
- params.append(decision_reason)
1742
- if metadata_json is not None:
1743
- updates.append("metadata_json = ?")
1744
- params.append(metadata_json)
1745
- params.append(job_id)
1746
- self._execute(
1747
- f"UPDATE playbook_optimization_jobs SET {', '.join(updates)} WHERE job_id = ?", # noqa: S608
1748
- tuple(params),
1749
- )
1750
-
1751
- @SQLiteStorageBase.handle_exceptions
1752
- def insert_playbook_optimization_candidate(
1753
- self, candidate: PlaybookOptimizationCandidate
1754
- ) -> PlaybookOptimizationCandidate:
1755
- with self._lock:
1756
- cur = self.conn.execute(
1757
- """INSERT INTO playbook_optimization_candidates
1758
- (job_id, candidate_index, content, parent_candidate_ids,
1759
- aggregate_score, is_winner, metadata_json, created_at)
1760
- VALUES (?, ?, ?, ?, ?, ?, ?, ?)""",
1761
- (
1762
- candidate.job_id,
1763
- candidate.candidate_index,
1764
- candidate.content,
1765
- _json_dumps(candidate.parent_candidate_ids) or "[]",
1766
- candidate.aggregate_score,
1767
- 1 if candidate.is_winner else 0,
1768
- candidate.metadata_json,
1769
- candidate.created_at,
1770
- ),
1771
- )
1772
- candidate.candidate_id = cur.lastrowid or 0
1773
- self.conn.commit()
1774
- return candidate
1775
-
1776
- @SQLiteStorageBase.handle_exceptions
1777
- def list_playbook_optimization_candidates(
1778
- self, job_id: int
1779
- ) -> list[PlaybookOptimizationCandidate]:
1780
- rows = self._fetchall(
1781
- "SELECT * FROM playbook_optimization_candidates WHERE job_id = ? ORDER BY candidate_id ASC",
1782
- (job_id,),
1783
- )
1784
- return [_row_to_playbook_optimization_candidate(row) for row in rows]
1785
-
1786
- @SQLiteStorageBase.handle_exceptions
1787
- def update_playbook_optimization_candidate(
1788
- self,
1789
- candidate_id: int,
1790
- *,
1791
- aggregate_score: float | None = None,
1792
- is_winner: bool | None = None,
1793
- ) -> None:
1794
- updates: list[str] = []
1795
- params: list[Any] = []
1796
- if aggregate_score is not None:
1797
- updates.append("aggregate_score = ?")
1798
- params.append(aggregate_score)
1799
- if is_winner is not None:
1800
- updates.append("is_winner = ?")
1801
- params.append(1 if is_winner else 0)
1802
- if not updates:
1803
- return
1804
- params.append(candidate_id)
1805
- self._execute(
1806
- f"UPDATE playbook_optimization_candidates SET {', '.join(updates)} WHERE candidate_id = ?", # noqa: S608
1807
- tuple(params),
1808
- )
1809
-
1810
- @SQLiteStorageBase.handle_exceptions
1811
- def insert_playbook_optimization_evaluation(
1812
- self, evaluation: PlaybookOptimizationEvaluation
1813
- ) -> PlaybookOptimizationEvaluation:
1814
- with self._lock:
1815
- cur = self.conn.execute(
1816
- """INSERT INTO playbook_optimization_evaluations
1817
- (job_id, candidate_id, target_kind, target_id,
1818
- scenario_user_playbook_id, source_interaction_ids, score,
1819
- verdict, likert, rationale, asi_json, incumbent_rollout_json,
1820
- candidate_rollout_json, created_at)
1821
- VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)""",
1822
- (
1823
- evaluation.job_id,
1824
- evaluation.candidate_id,
1825
- evaluation.target_kind,
1826
- evaluation.target_id,
1827
- evaluation.scenario_user_playbook_id,
1828
- _json_dumps(evaluation.source_interaction_ids) or "[]",
1829
- evaluation.score,
1830
- evaluation.verdict,
1831
- evaluation.likert,
1832
- evaluation.rationale,
1833
- evaluation.asi_json,
1834
- evaluation.incumbent_rollout_json,
1835
- evaluation.candidate_rollout_json,
1836
- evaluation.created_at,
1837
- ),
1838
- )
1839
- evaluation.evaluation_id = cur.lastrowid or 0
1840
- self.conn.commit()
1841
- return evaluation
1842
-
1843
- @SQLiteStorageBase.handle_exceptions
1844
- def list_playbook_optimization_evaluations(
1845
- self, job_id: int
1846
- ) -> list[PlaybookOptimizationEvaluation]:
1847
- rows = self._fetchall(
1848
- "SELECT * FROM playbook_optimization_evaluations WHERE job_id = ? ORDER BY evaluation_id ASC",
1849
- (job_id,),
1850
- )
1851
- return [_row_to_playbook_optimization_evaluation(row) for row in rows]
1852
-
1853
- @SQLiteStorageBase.handle_exceptions
1854
- def insert_playbook_optimization_event(
1855
- self, event: PlaybookOptimizationEvent
1856
- ) -> PlaybookOptimizationEvent:
1857
- with self._lock:
1858
- cur = self.conn.execute(
1859
- """INSERT INTO playbook_optimization_events
1860
- (job_id, event_type, payload_json, created_at)
1861
- VALUES (?, ?, ?, ?)""",
1862
- (event.job_id, event.event_type, event.payload_json, event.created_at),
1863
- )
1864
- event.event_id = cur.lastrowid or 0
1865
- self.conn.commit()
1866
- return event
1867
-
1868
- @SQLiteStorageBase.handle_exceptions
1869
- def delete_archived_agent_playbooks_by_playbook_name(
1870
- self, playbook_name: str, agent_version: str | None = None
1871
- ) -> None:
1872
- sql = "SELECT agent_playbook_id FROM agent_playbooks WHERE playbook_name = ? AND status = 'archived'"
1873
- params: list[Any] = [playbook_name]
1874
- if agent_version is not None:
1875
- sql += " AND agent_version = ?"
1876
- params.append(agent_version)
1877
- batch_request_id = uuid.uuid4().hex
1878
- with self._lock:
1879
- ids = [
1880
- r["agent_playbook_id"]
1881
- for r in self.conn.execute(sql, params).fetchall()
1882
- ]
1883
- if not ids:
1884
- return
1885
- ph = ",".join("?" for _ in ids)
1886
- self.conn.execute(
1887
- f"DELETE FROM agent_playbooks WHERE agent_playbook_id IN ({ph})", ids
1888
- )
1889
- for apid in ids:
1890
- _emit_hard_delete_playbook(
1891
- self.conn,
1892
- org_id=self.org_id,
1893
- entity_type="agent_playbook",
1894
- entity_id=str(apid),
1895
- request_id=batch_request_id,
1896
- )
1897
- self.conn.commit()
1898
- self._delete_playbook_search_rows("agent", ids)
1899
-
1900
- @SQLiteStorageBase.handle_exceptions
1901
- def search_agent_playbooks( # noqa: C901
1902
- self,
1903
- request: SearchAgentPlaybookRequest,
1904
- options: SearchOptions | None = None,
1905
- ) -> list[AgentPlaybook]:
1906
- query = request.query
1907
- agent_version = request.agent_version
1908
- playbook_name = request.playbook_name
1909
- start_time = int(request.start_time.timestamp()) if request.start_time else None
1910
- end_time = int(request.end_time.timestamp()) if request.end_time else None
1911
- status_filter = request.status_filter
1912
- playbook_status_filter = request.playbook_status_filter
1913
- match_count = request.top_k or 10
1914
- query_embedding = options.query_embedding if options else None
1915
- mode = _effective_search_mode(
1916
- request.search_mode, query_embedding, request.query
1917
- )
1918
- rrf_k = options.rrf_k if options else 60
1919
- vector_weight = options.vector_weight if options else 1.0
1920
- fts_weight = options.fts_weight if options else 1.0
1921
-
1922
- conditions: list[str] = []
1923
- params: list[Any] = []
1924
-
1925
- if agent_version:
1926
- conditions.append("ap.agent_version = ?")
1927
- params.append(agent_version)
1928
- if playbook_name:
1929
- conditions.append("ap.playbook_name = ?")
1930
- params.append(playbook_name)
1931
- if start_time:
1932
- conditions.append("ap.created_at >= ?")
1933
- params.append(_epoch_to_iso(start_time))
1934
- if end_time:
1935
- conditions.append("ap.created_at <= ?")
1936
- params.append(_epoch_to_iso(end_time))
1937
- if playbook_status_filter is not None:
1938
- if isinstance(playbook_status_filter, list):
1939
- if not playbook_status_filter:
1940
- conditions.append("1=0")
1941
- else:
1942
- placeholders = ",".join("?" for _ in playbook_status_filter)
1943
- conditions.append(f"ap.playbook_status IN ({placeholders})")
1944
- params.extend(s.value for s in playbook_status_filter)
1945
- else:
1946
- conditions.append("ap.playbook_status = ?")
1947
- params.append(playbook_status_filter.value)
1948
- if status_filter is not None:
1949
- frag, sparams = _build_status_sql(status_filter)
1950
- conditions.append(frag)
1951
- params.extend(sparams)
1952
- else:
1953
- # Default: exclude tombstone statuses (MERGED/SUPERSEDED)
1954
- conditions.append("(ap.status IS NULL OR ap.status NOT IN (?, ?))")
1955
- params.extend(_TOMBSTONE_STATUS_VALUES)
1956
- tag_frag, tag_params = _build_tags_sql("ap", request.tags)
1957
- if tag_frag:
1958
- conditions.append(tag_frag)
1959
- params.extend(tag_params)
1960
-
1961
- where_extra = (" AND " + " AND ".join(conditions)) if conditions else ""
1962
- overfetch = match_count * 5 if mode != SearchMode.FTS else match_count
1963
-
1964
- # Pure vector search: fetch all candidates, rank by cosine similarity
1965
- if mode == SearchMode.VECTOR and query_embedding:
1966
- base_where = "WHERE " + " AND ".join(conditions) if conditions else ""
1967
- sql = f"""SELECT * FROM agent_playbooks ap
1968
- {base_where}
1969
- ORDER BY ap.created_at DESC"""
1970
- rows = self._fetchall(sql, params)
1971
- rows = _vector_rank_rows(rows, query_embedding, match_count)
1972
- return [_row_to_agent_playbook(r) for r in rows]
1973
-
1974
- if query:
1975
- fts_query = _sanitize_fts_query(query)
1976
- sql = f"""SELECT ap.* FROM agent_playbooks ap
1977
- JOIN agent_playbooks_fts f ON ap.agent_playbook_id = f.rowid
1978
- WHERE agent_playbooks_fts MATCH ?{where_extra}
1979
- ORDER BY bm25(agent_playbooks_fts, 1.0)
1980
- LIMIT ?"""
1981
- fts_rows = self._fetchall(sql, [fts_query, *params, overfetch])
1982
-
1983
- if mode == SearchMode.HYBRID and query_embedding:
1984
- base_where = "WHERE " + " AND ".join(conditions) if conditions else ""
1985
- vec_limit = match_count * 10
1986
- vec_sql = f"""SELECT * FROM agent_playbooks ap
1987
- {base_where}
1988
- ORDER BY ap.created_at DESC
1989
- LIMIT ?"""
1990
- vec_candidates = self._fetchall(vec_sql, [*params, vec_limit])
1991
- vec_rows = _vector_rank_rows(vec_candidates, query_embedding, overfetch)
1992
- rows = _true_rrf_merge(
1993
- fts_rows,
1994
- vec_rows,
1995
- "agent_playbook_id",
1996
- match_count,
1997
- rrf_k,
1998
- vector_weight,
1999
- fts_weight,
2000
- )
2001
- return [_row_to_agent_playbook(r) for r in rows]
2002
- return [_row_to_agent_playbook(r) for r in fts_rows[:match_count]]
2003
-
2004
- # HYBRID without query text: rank by embedding only
2005
- if query_embedding:
2006
- base_where = "WHERE " + " AND ".join(conditions) if conditions else ""
2007
- sql = f"""SELECT * FROM agent_playbooks ap
2008
- {base_where}
2009
- ORDER BY ap.created_at DESC"""
2010
- rows = self._fetchall(sql, params)
2011
- rows = _vector_rank_rows(rows, query_embedding, match_count)
2012
- return [_row_to_agent_playbook(r) for r in rows]
2013
-
2014
- # No query text, no embedding -- recency fallback
2015
- base_where = "WHERE " + " AND ".join(conditions) if conditions else "WHERE 1=1"
2016
- sql = f"""SELECT * FROM agent_playbooks ap
2017
- {base_where}
2018
- ORDER BY ap.created_at DESC LIMIT ?"""
2019
- params.append(match_count)
2020
- rows = self._fetchall(sql, params)
2021
- return [_row_to_agent_playbook(r) for r in rows]
2022
-
2023
- # ------------------------------------------------------------------
2024
- # Agent Success Evaluation methods
2025
- # ------------------------------------------------------------------
2026
-
2027
- @SQLiteStorageBase.handle_exceptions
2028
- def save_agent_success_evaluation_results(
2029
- self, results: list[AgentSuccessEvaluationResult]
2030
- ) -> None:
2031
- for result in results:
2032
- embedding_text = f"{result.failure_type} {result.failure_reason}"
2033
- if embedding_text.strip():
2034
- result.embedding = self._get_embedding(embedding_text)
2035
- else:
2036
- result.embedding = []
2037
-
2038
- created_at_iso = _epoch_to_iso(result.created_at)
2039
- self._execute(
2040
- """INSERT INTO agent_success_evaluation_result
2041
- (user_id, session_id, agent_version, evaluation_name, is_success,
2042
- failure_type, failure_reason, regular_vs_shadow,
2043
- number_of_correction_per_session, user_turns_to_resolution,
2044
- is_escalated, embedding, created_at)
2045
- VALUES (?,?,?,?,?,?,?,?,?,?,?,?,?)""",
2046
- (
2047
- result.user_id,
2048
- result.session_id,
2049
- result.agent_version,
2050
- result.evaluation_name,
2051
- int(result.is_success),
2052
- result.failure_type,
2053
- result.failure_reason,
2054
- result.regular_vs_shadow.value
2055
- if result.regular_vs_shadow
2056
- else None,
2057
- result.number_of_correction_per_session,
2058
- result.user_turns_to_resolution,
2059
- int(result.is_escalated),
2060
- _json_dumps(result.embedding) if result.embedding else None,
2061
- created_at_iso,
2062
- ),
2063
- )
2064
-
2065
- @SQLiteStorageBase.handle_exceptions
2066
- def get_agent_success_evaluation_results(
2067
- self, limit: int = 100, agent_version: str | None = None
2068
- ) -> list[AgentSuccessEvaluationResult]:
2069
- sql = "SELECT * FROM agent_success_evaluation_result"
2070
- params: list[Any] = []
2071
- if agent_version is not None:
2072
- sql += " WHERE agent_version = ?"
2073
- params.append(agent_version)
2074
- sql += " ORDER BY created_at DESC LIMIT ?"
2075
- params.append(limit)
2076
- rows = self._fetchall(sql, params)
2077
- return [_row_to_eval_result(r) for r in rows]
2078
-
2079
- @SQLiteStorageBase.handle_exceptions
2080
- def get_agent_success_evaluation_results_in_window(
2081
- self,
2082
- from_ts: int,
2083
- to_ts: int,
2084
- agent_version: str | None = None,
2085
- limit: int | None = None,
2086
- ) -> list[AgentSuccessEvaluationResult]:
2087
- sql = """SELECT * FROM agent_success_evaluation_result
2088
- WHERE created_at >= ? AND created_at <= ?"""
2089
- params: list[Any] = [_epoch_to_iso(from_ts), _epoch_to_iso(to_ts)]
2090
- if agent_version is not None:
2091
- sql += " AND agent_version = ?"
2092
- params.append(agent_version)
2093
- sql += " ORDER BY created_at DESC"
2094
- if limit is not None:
2095
- sql += " LIMIT ?"
2096
- params.append(limit)
2097
- rows = self._fetchall(sql, params)
2098
- return [_row_to_eval_result(r) for r in rows]
2099
-
2100
- @SQLiteStorageBase.handle_exceptions
2101
- def get_agent_success_evaluation_result_ids(
2102
- self,
2103
- user_id: str,
2104
- session_id: str,
2105
- evaluation_name: str,
2106
- agent_version: str,
2107
- ) -> list[int]:
2108
- rows = self._fetchall(
2109
- """SELECT result_id FROM agent_success_evaluation_result
2110
- WHERE user_id = ?
2111
- AND session_id = ?
2112
- AND evaluation_name = ?
2113
- AND agent_version = ?
2114
- ORDER BY created_at DESC""",
2115
- (user_id, session_id, evaluation_name, agent_version),
2116
- )
2117
- return [int(r["result_id"]) for r in rows]
2118
-
2119
- @SQLiteStorageBase.handle_exceptions
2120
- def delete_all_agent_success_evaluation_results(self) -> None:
2121
- self._execute("DELETE FROM agent_success_evaluation_result")
2122
-
2123
- @SQLiteStorageBase.handle_exceptions
2124
- def delete_agent_success_evaluation_results_for_session(
2125
- self,
2126
- user_id: str,
2127
- session_id: str,
2128
- evaluation_name: str,
2129
- agent_version: str,
2130
- ) -> int:
2131
- """Delete results scoped to (user_id, session_id, evaluation_name, agent_version).
2132
-
2133
- Args:
2134
- user_id (str): User whose session results to clear.
2135
- session_id (str): Session whose results to clear.
2136
- evaluation_name (str): Which evaluator's results to clear.
2137
- agent_version (str): Agent version scope.
2138
-
2139
- Returns:
2140
- int: Number of rows deleted.
2141
- """
2142
- cur = self._execute(
2143
- """DELETE FROM agent_success_evaluation_result
2144
- WHERE user_id = ?
2145
- AND session_id = ?
2146
- AND evaluation_name = ?
2147
- AND agent_version = ?""",
2148
- (user_id, session_id, evaluation_name, agent_version),
2149
- )
2150
- return cur.rowcount
2151
-
2152
- @SQLiteStorageBase.handle_exceptions
2153
- def delete_agent_success_evaluation_results_by_ids(
2154
- self, result_ids: list[int]
2155
- ) -> int:
2156
- """Delete agent success eval result rows by primary key.
2157
-
2158
- Args:
2159
- result_ids (list[int]): Primary-key result_ids to delete. An empty
2160
- list is a no-op that returns 0.
2161
-
2162
- Returns:
2163
- int: Number of rows actually deleted (ignores non-existent ids).
2164
- """
2165
- if not result_ids:
2166
- return 0
2167
- placeholders = ",".join(["?"] * len(result_ids))
2168
- cur = self._execute(
2169
- f"DELETE FROM agent_success_evaluation_result WHERE result_id IN ({placeholders})",
2170
- list(result_ids),
2171
- )
2172
- return cur.rowcount