claude-smart 0.2.44 → 0.2.46

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 (323) hide show
  1. package/.claude-plugin/marketplace.json +3 -3
  2. package/README.md +34 -5
  3. package/bin/claude-smart.js +295 -5
  4. package/package.json +22 -3
  5. package/plugin/.claude-plugin/plugin.json +4 -2
  6. package/plugin/.codex-plugin/plugin.json +1 -1
  7. package/plugin/README.md +22 -2
  8. package/plugin/commands/clear-all.md +1 -0
  9. package/plugin/commands/dashboard.md +1 -0
  10. package/plugin/commands/learn.md +1 -0
  11. package/plugin/commands/restart.md +1 -0
  12. package/plugin/commands/show.md +1 -0
  13. package/plugin/dashboard/app/configure/env/page.tsx +40 -14
  14. package/plugin/dashboard/app/configure/server/page.tsx +51 -1
  15. package/plugin/dashboard/app/preferences/[id]/page.tsx +8 -8
  16. package/plugin/dashboard/app/skills/project/[id]/page.tsx +7 -3
  17. package/plugin/dashboard/app/skills/shared/[id]/page.tsx +10 -3
  18. package/plugin/dashboard/lib/claude-settings-file.ts +20 -10
  19. package/plugin/dashboard/lib/reflexio-client.ts +16 -0
  20. package/plugin/dashboard/lib/status.ts +10 -3
  21. package/plugin/dashboard/lib/types.ts +18 -6
  22. package/plugin/hooks/codex-hooks.json +0 -1
  23. package/plugin/opencode/assistant-buffer.ts +108 -0
  24. package/plugin/opencode/dist/assistant-buffer.js +96 -0
  25. package/plugin/opencode/dist/internal.js +12 -0
  26. package/plugin/opencode/dist/payload.js +85 -0
  27. package/plugin/opencode/dist/server.mjs +158 -0
  28. package/plugin/opencode/internal.ts +18 -0
  29. package/plugin/opencode/package.json +3 -0
  30. package/plugin/opencode/payload.ts +90 -0
  31. package/plugin/opencode/server.mts +174 -0
  32. package/plugin/opencode/tsconfig.json +13 -0
  33. package/plugin/pyproject.toml +20 -3
  34. package/plugin/scripts/_lib.sh +77 -29
  35. package/plugin/scripts/backend-service.sh +27 -5
  36. package/plugin/scripts/dashboard-service.sh +7 -5
  37. package/plugin/scripts/ensure-plugin-root.sh +4 -4
  38. package/plugin/scripts/hook_entry.sh +3 -3
  39. package/plugin/scripts/opencode-claude-compat +4 -0
  40. package/plugin/scripts/opencode-claude-compat.cmd +3 -0
  41. package/plugin/scripts/opencode-claude-compat.js +225 -0
  42. package/plugin/scripts/smart-install.sh +10 -8
  43. package/plugin/src/README.md +1 -1
  44. package/plugin/src/claude_smart/cli.py +304 -6
  45. package/plugin/src/claude_smart/env_config.py +12 -4
  46. package/plugin/src/claude_smart/events/session_start.py +26 -7
  47. package/plugin/src/claude_smart/events/stop.py +1 -1
  48. package/plugin/src/claude_smart/ids.py +1 -1
  49. package/plugin/src/claude_smart/reflexio_adapter.py +5 -5
  50. package/plugin/src/claude_smart/runtime.py +7 -1
  51. package/plugin/uv.lock +1 -1
  52. package/plugin/vendor/reflexio/.env.example +54 -30
  53. package/plugin/vendor/reflexio/README.md +14 -8
  54. package/plugin/vendor/reflexio/pyproject.toml +13 -1
  55. package/plugin/vendor/reflexio/reflexio/README.md +1 -0
  56. package/plugin/vendor/reflexio/reflexio/cli/README.md +9 -7
  57. package/plugin/vendor/reflexio/reflexio/cli/__main__.py +10 -1
  58. package/plugin/vendor/reflexio/reflexio/cli/bootstrap_config.py +22 -5
  59. package/plugin/vendor/reflexio/reflexio/cli/commands/interactions.py +34 -4
  60. package/plugin/vendor/reflexio/reflexio/cli/commands/profiles.py +12 -7
  61. package/plugin/vendor/reflexio/reflexio/cli/commands/services.py +13 -6
  62. package/plugin/vendor/reflexio/reflexio/cli/commands/setup_cmd.py +210 -50
  63. package/plugin/vendor/reflexio/reflexio/cli/commands/shortcuts.py +15 -2
  64. package/plugin/vendor/reflexio/reflexio/cli/env_loader.py +292 -22
  65. package/plugin/vendor/reflexio/reflexio/cli/log_format.py +29 -4
  66. package/plugin/vendor/reflexio/reflexio/cli/run_services.py +17 -8
  67. package/plugin/vendor/reflexio/reflexio/cli/stop_services.py +13 -10
  68. package/plugin/vendor/reflexio/reflexio/client/client.py +57 -13
  69. package/plugin/vendor/reflexio/reflexio/integrations/openclaw/README.md +135 -257
  70. package/plugin/vendor/reflexio/reflexio/integrations/openclaw/npm/openclaw-smart/bin/openclaw-smart.js +13 -0
  71. package/plugin/vendor/reflexio/reflexio/integrations/openclaw/npm/openclaw-smart/package.json +15 -0
  72. package/plugin/vendor/reflexio/reflexio/integrations/openclaw/plugin/README.md +38 -0
  73. package/plugin/vendor/reflexio/reflexio/integrations/openclaw/plugin/index.ts +151 -110
  74. package/plugin/vendor/reflexio/reflexio/integrations/openclaw/plugin/openclaw.plugin.json +20 -10
  75. package/plugin/vendor/reflexio/reflexio/integrations/openclaw/plugin/package.json +28 -6
  76. package/plugin/vendor/reflexio/reflexio/integrations/openclaw/plugin/pyproject.toml +42 -0
  77. package/plugin/vendor/reflexio/reflexio/integrations/openclaw/plugin/scripts/_lib.sh +371 -0
  78. package/plugin/vendor/reflexio/reflexio/integrations/openclaw/plugin/scripts/backend-log-runner.sh +33 -0
  79. package/plugin/vendor/reflexio/reflexio/integrations/openclaw/plugin/scripts/backend-service.sh +271 -0
  80. package/plugin/vendor/reflexio/reflexio/integrations/openclaw/plugin/scripts/cli.sh +68 -0
  81. package/plugin/vendor/reflexio/reflexio/integrations/openclaw/plugin/scripts/dashboard-open.sh +15 -0
  82. package/plugin/vendor/reflexio/reflexio/integrations/openclaw/plugin/scripts/ensure-plugin-root.sh +84 -0
  83. package/plugin/vendor/reflexio/reflexio/integrations/openclaw/plugin/scripts/hook_entry.sh +106 -0
  84. package/plugin/vendor/reflexio/reflexio/integrations/openclaw/plugin/scripts/npm-cli.js +219 -0
  85. package/plugin/vendor/reflexio/reflexio/integrations/openclaw/plugin/scripts/smart-install.sh +269 -0
  86. package/plugin/vendor/reflexio/reflexio/integrations/openclaw/plugin/skills/clear-all/SKILL.md +8 -0
  87. package/plugin/vendor/reflexio/reflexio/integrations/openclaw/plugin/skills/dashboard/SKILL.md +8 -0
  88. package/plugin/vendor/reflexio/reflexio/integrations/openclaw/plugin/skills/learn/SKILL.md +10 -0
  89. package/plugin/vendor/reflexio/reflexio/integrations/openclaw/plugin/skills/reflexio/SKILL.md +15 -44
  90. package/plugin/vendor/reflexio/reflexio/integrations/openclaw/plugin/skills/restart/SKILL.md +6 -0
  91. package/plugin/vendor/reflexio/reflexio/integrations/openclaw/plugin/skills/show/SKILL.md +8 -0
  92. package/plugin/vendor/reflexio/reflexio/integrations/openclaw/plugin/src/openclaw_smart/cli.py +634 -0
  93. package/plugin/vendor/reflexio/reflexio/integrations/openclaw/plugin/src/openclaw_smart/context_format.py +224 -0
  94. package/plugin/vendor/reflexio/reflexio/integrations/openclaw/plugin/src/openclaw_smart/context_inject.py +79 -0
  95. package/plugin/vendor/reflexio/reflexio/integrations/openclaw/plugin/src/openclaw_smart/events/__init__.py +0 -0
  96. package/plugin/vendor/reflexio/reflexio/integrations/openclaw/plugin/src/openclaw_smart/events/after_tool_call.py +160 -0
  97. package/plugin/vendor/reflexio/reflexio/integrations/openclaw/plugin/src/openclaw_smart/events/agent_end.py +187 -0
  98. package/plugin/vendor/reflexio/reflexio/integrations/openclaw/plugin/src/openclaw_smart/events/before_prompt_build.py +69 -0
  99. package/plugin/vendor/reflexio/reflexio/integrations/openclaw/plugin/src/openclaw_smart/events/before_tool_call.py +30 -0
  100. package/plugin/vendor/reflexio/reflexio/integrations/openclaw/plugin/src/openclaw_smart/events/session_end.py +36 -0
  101. package/plugin/vendor/reflexio/reflexio/integrations/openclaw/plugin/src/openclaw_smart/events/session_start.py +130 -0
  102. package/plugin/vendor/reflexio/reflexio/integrations/openclaw/plugin/src/openclaw_smart/hook.py +131 -0
  103. package/plugin/vendor/reflexio/reflexio/integrations/openclaw/plugin/src/openclaw_smart/ids.py +94 -0
  104. package/plugin/vendor/reflexio/reflexio/integrations/openclaw/plugin/src/openclaw_smart/internal_call.py +75 -0
  105. package/plugin/vendor/reflexio/reflexio/integrations/openclaw/plugin/src/openclaw_smart/oc_cite.py +196 -0
  106. package/plugin/vendor/reflexio/reflexio/integrations/openclaw/plugin/src/openclaw_smart/optimizer_assistant.py +272 -0
  107. package/plugin/vendor/reflexio/reflexio/integrations/openclaw/plugin/src/openclaw_smart/publish.py +96 -0
  108. package/plugin/vendor/reflexio/reflexio/integrations/openclaw/plugin/src/openclaw_smart/query_compose.py +66 -0
  109. package/plugin/vendor/reflexio/reflexio/integrations/openclaw/plugin/src/openclaw_smart/reflexio_adapter.py +336 -0
  110. package/plugin/vendor/reflexio/reflexio/integrations/openclaw/plugin/src/openclaw_smart/runtime.py +47 -0
  111. package/plugin/vendor/reflexio/reflexio/integrations/openclaw/plugin/src/openclaw_smart/stall_banner.py +61 -0
  112. package/plugin/vendor/reflexio/reflexio/integrations/openclaw/plugin/src/openclaw_smart/state.py +323 -0
  113. package/plugin/vendor/reflexio/reflexio/integrations/openclaw/plugin/tests/__init__.py +0 -0
  114. package/plugin/vendor/reflexio/reflexio/integrations/openclaw/plugin/tests/integration/__init__.py +0 -0
  115. package/plugin/vendor/reflexio/reflexio/integrations/openclaw/plugin/tests/integration/test_e2e_session_loop.py +190 -0
  116. package/plugin/vendor/reflexio/reflexio/integrations/openclaw/plugin/tests/integration/test_publish_to_local_reflexio_integration.py +112 -0
  117. package/plugin/vendor/reflexio/reflexio/integrations/openclaw/plugin/tests/integration/test_recursion_guard_integration.py +86 -0
  118. package/plugin/vendor/reflexio/reflexio/integrations/openclaw/plugin/tests/integration/test_search_inject_integration.py +144 -0
  119. package/plugin/vendor/reflexio/reflexio/integrations/openclaw/plugin/tests/test_cli.py +184 -0
  120. package/plugin/vendor/reflexio/reflexio/integrations/openclaw/plugin/tests/test_events_after_tool_call.py +142 -0
  121. package/plugin/vendor/reflexio/reflexio/integrations/openclaw/plugin/tests/test_events_agent_end.py +233 -0
  122. package/plugin/vendor/reflexio/reflexio/integrations/openclaw/plugin/tests/test_events_before_prompt_build.py +116 -0
  123. package/plugin/vendor/reflexio/reflexio/integrations/openclaw/plugin/tests/test_events_before_tool_call.py +35 -0
  124. package/plugin/vendor/reflexio/reflexio/integrations/openclaw/plugin/tests/test_events_session_end.py +47 -0
  125. package/plugin/vendor/reflexio/reflexio/integrations/openclaw/plugin/tests/test_events_session_start.py +109 -0
  126. package/plugin/vendor/reflexio/reflexio/integrations/openclaw/plugin/tests/test_hook_dispatch.py +117 -0
  127. package/plugin/vendor/reflexio/reflexio/integrations/openclaw/plugin/tests/test_ids.py +41 -0
  128. package/plugin/vendor/reflexio/reflexio/integrations/openclaw/plugin/tests/test_internal_call.py +50 -0
  129. package/plugin/vendor/reflexio/reflexio/integrations/openclaw/plugin/tests/test_oc_cite.py +88 -0
  130. package/plugin/vendor/reflexio/reflexio/integrations/openclaw/plugin/tests/test_publish.py +48 -0
  131. package/plugin/vendor/reflexio/reflexio/integrations/openclaw/plugin/tests/test_query_compose.py +48 -0
  132. package/plugin/vendor/reflexio/reflexio/integrations/openclaw/plugin/tests/test_reflexio_adapter.py +188 -0
  133. package/plugin/vendor/reflexio/reflexio/integrations/openclaw/plugin/tests/test_runtime.py +41 -0
  134. package/plugin/vendor/reflexio/reflexio/integrations/openclaw/plugin/tests/test_state.py +235 -0
  135. package/plugin/vendor/reflexio/reflexio/integrations/openclaw/plugin/tests-ts/__mocks__/plugin-entry-stub.ts +6 -0
  136. package/plugin/vendor/reflexio/reflexio/integrations/openclaw/plugin/tests-ts/test_npm_cli.test.ts +56 -0
  137. package/plugin/vendor/reflexio/reflexio/integrations/openclaw/plugin/tests-ts/test_shim_dispatch.test.ts +171 -0
  138. package/plugin/vendor/reflexio/reflexio/integrations/openclaw/plugin/tsconfig.build.json +18 -0
  139. package/plugin/vendor/reflexio/reflexio/integrations/openclaw/{tsconfig.json → plugin/tsconfig.json} +2 -2
  140. package/plugin/vendor/reflexio/reflexio/integrations/openclaw/plugin/uv.lock +3835 -0
  141. package/plugin/vendor/reflexio/reflexio/integrations/openclaw/plugin/vitest.config.ts +18 -0
  142. package/plugin/vendor/reflexio/reflexio/lib/_agent_playbook.py +238 -10
  143. package/plugin/vendor/reflexio/reflexio/lib/_config.py +11 -4
  144. package/plugin/vendor/reflexio/reflexio/lib/_generation.py +16 -5
  145. package/plugin/vendor/reflexio/reflexio/lib/_lineage_parity_readers.py +187 -0
  146. package/plugin/vendor/reflexio/reflexio/lib/_profiles.py +198 -6
  147. package/plugin/vendor/reflexio/reflexio/lib/_reflection.py +1 -1
  148. package/plugin/vendor/reflexio/reflexio/lib/_search.py +29 -20
  149. package/plugin/vendor/reflexio/reflexio/lib/_user_playbook.py +2 -1
  150. package/plugin/vendor/reflexio/reflexio/models/api_schema/domain/entities.py +127 -38
  151. package/plugin/vendor/reflexio/reflexio/models/api_schema/domain/enums.py +4 -0
  152. package/plugin/vendor/reflexio/reflexio/models/api_schema/eval_overview_schema.py +64 -41
  153. package/plugin/vendor/reflexio/reflexio/models/api_schema/internal_schema.py +19 -0
  154. package/plugin/vendor/reflexio/reflexio/models/api_schema/pending_tool_call_schema.py +12 -5
  155. package/plugin/vendor/reflexio/reflexio/models/api_schema/retriever_schema.py +52 -0
  156. package/plugin/vendor/reflexio/reflexio/models/api_schema/ui/converters.py +5 -1
  157. package/plugin/vendor/reflexio/reflexio/models/api_schema/ui/entities.py +5 -1
  158. package/plugin/vendor/reflexio/reflexio/models/config_schema.py +88 -7
  159. package/plugin/vendor/reflexio/reflexio/models/structured_output.py +148 -0
  160. package/plugin/vendor/reflexio/reflexio/server/OVERVIEW.md +3 -3
  161. package/plugin/vendor/reflexio/reflexio/server/README.md +45 -32
  162. package/plugin/vendor/reflexio/reflexio/server/__init__.py +29 -5
  163. package/plugin/vendor/reflexio/reflexio/server/__main__.py +2 -2
  164. package/plugin/vendor/reflexio/reflexio/server/_auth.py +65 -2
  165. package/plugin/vendor/reflexio/reflexio/server/api.py +734 -85
  166. package/plugin/vendor/reflexio/reflexio/server/api_endpoints/precondition_checks.py +7 -0
  167. package/plugin/vendor/reflexio/reflexio/server/api_endpoints/request_context.py +3 -1
  168. package/plugin/vendor/reflexio/reflexio/server/billing_meter.py +173 -0
  169. package/plugin/vendor/reflexio/reflexio/server/billing_signals.py +64 -0
  170. package/plugin/vendor/reflexio/reflexio/server/cache/reflexio_cache.py +46 -16
  171. package/plugin/vendor/reflexio/reflexio/server/env_utils.py +65 -0
  172. package/plugin/vendor/reflexio/reflexio/server/llm/embedding_service.py +19 -4
  173. package/plugin/vendor/reflexio/reflexio/server/llm/litellm_client.py +264 -77
  174. package/plugin/vendor/reflexio/reflexio/server/llm/llm_utils.py +69 -7
  175. package/plugin/vendor/reflexio/reflexio/server/llm/providers/embedding_service_provider.py +75 -16
  176. package/plugin/vendor/reflexio/reflexio/server/llm/providers/openclaw_provider.py +280 -0
  177. package/plugin/vendor/reflexio/reflexio/server/llm/token_accounting.py +48 -0
  178. package/plugin/vendor/reflexio/reflexio/server/llm/tools.py +107 -17
  179. package/plugin/vendor/reflexio/reflexio/server/operation_limiter.py +360 -4
  180. package/plugin/vendor/reflexio/reflexio/server/prompt/prompt_bank/memory_reflection/v1.6.0.prompt.md +1 -1
  181. package/plugin/vendor/reflexio/reflexio/server/prompt/prompt_bank/memory_reflection/v1.7.0.prompt.md +85 -0
  182. package/plugin/vendor/reflexio/reflexio/server/prompt/prompt_bank/playbook_aggregation/v2.2.0.prompt.md +1 -1
  183. package/plugin/vendor/reflexio/reflexio/server/prompt/prompt_bank/playbook_aggregation/v2.3.0.prompt.md +253 -0
  184. package/plugin/vendor/reflexio/reflexio/server/prompt/prompt_bank/playbook_consolidation/v2.3.2.prompt.md +1 -1
  185. package/plugin/vendor/reflexio/reflexio/server/prompt/prompt_bank/playbook_consolidation/v2.3.3.prompt.md +74 -0
  186. package/plugin/vendor/reflexio/reflexio/server/prompt/prompt_bank/playbook_extraction_context/v4.2.3.prompt.md +1 -1
  187. package/plugin/vendor/reflexio/reflexio/server/prompt/prompt_bank/playbook_extraction_context/v4.3.0.prompt.md +266 -0
  188. package/plugin/vendor/reflexio/reflexio/server/prompt/prompt_bank/playbook_extraction_context/v4.4.0.prompt.md +266 -0
  189. package/plugin/vendor/reflexio/reflexio/server/prompt/prompt_bank/playbook_extraction_context_expert/v3.3.0.prompt.md +1 -1
  190. package/plugin/vendor/reflexio/reflexio/server/prompt/prompt_bank/playbook_extraction_context_expert/v3.4.0.prompt.md +137 -0
  191. package/plugin/vendor/reflexio/reflexio/server/prompt/prompt_bank/playbook_extraction_main/v1.2.0.prompt.md +1 -1
  192. package/plugin/vendor/reflexio/reflexio/server/prompt/prompt_bank/playbook_extraction_main/v1.3.0.prompt.md +34 -0
  193. package/plugin/vendor/reflexio/reflexio/server/prompt/prompt_bank/profile_update_instruction_start/v1.0.0.prompt.md +5 -12
  194. package/plugin/vendor/reflexio/reflexio/server/prompt/prompt_bank/profile_update_instruction_start/v1.1.0.prompt.md +5 -12
  195. package/plugin/vendor/reflexio/reflexio/server/prompt/prompt_bank/profile_update_instruction_start/v1.2.0.prompt.md +155 -0
  196. package/plugin/vendor/reflexio/reflexio/server/prompt/prompt_bank/tagging/v1.0.0.prompt.md +23 -0
  197. package/plugin/vendor/reflexio/reflexio/server/prompt/prompt_manager.py +89 -32
  198. package/plugin/vendor/reflexio/reflexio/server/services/README.md +26 -11
  199. package/plugin/vendor/reflexio/reflexio/server/services/agent_success_evaluation/README.md +22 -0
  200. package/plugin/vendor/reflexio/reflexio/server/services/agent_success_evaluation/__init__.py +1 -0
  201. package/plugin/vendor/reflexio/reflexio/server/services/agent_success_evaluation/agent_success_evaluation_constants.py +4 -2
  202. package/plugin/vendor/reflexio/reflexio/server/services/agent_success_evaluation/agent_success_evaluation_utils.py +1 -0
  203. package/plugin/vendor/reflexio/reflexio/server/services/agent_success_evaluation/components/__init__.py +7 -0
  204. package/plugin/vendor/reflexio/reflexio/server/services/agent_success_evaluation/{agent_success_evaluator.py → components/evaluator.py} +3 -16
  205. package/plugin/vendor/reflexio/reflexio/server/services/agent_success_evaluation/regen_jobs.py +28 -72
  206. package/plugin/vendor/reflexio/reflexio/server/services/agent_success_evaluation/{group_evaluation_runner.py → runner.py} +12 -10
  207. package/plugin/vendor/reflexio/reflexio/server/services/agent_success_evaluation/{delayed_group_evaluator.py → scheduler.py} +10 -2
  208. package/plugin/vendor/reflexio/reflexio/server/services/agent_success_evaluation/{agent_success_evaluation_service.py → service.py} +36 -1
  209. package/plugin/vendor/reflexio/reflexio/server/services/base_generation_service.py +160 -0
  210. package/plugin/vendor/reflexio/reflexio/server/services/configurator/base_configurator.py +5 -0
  211. package/plugin/vendor/reflexio/reflexio/server/services/deduplication_utils.py +1 -1
  212. package/plugin/vendor/reflexio/reflexio/server/services/evaluation_overview/README.md +9 -0
  213. package/plugin/vendor/reflexio/reflexio/server/services/evaluation_overview/components/__init__.py +0 -0
  214. package/plugin/vendor/reflexio/reflexio/server/services/evaluation_overview/{rule_attribution.py → components/rule_attribution.py} +10 -9
  215. package/plugin/vendor/reflexio/reflexio/server/services/evaluation_overview/eval_sampler.py +15 -24
  216. package/plugin/vendor/reflexio/reflexio/server/services/evaluation_overview/service.py +238 -164
  217. package/plugin/vendor/reflexio/reflexio/server/services/extraction/README.md +31 -0
  218. package/plugin/vendor/reflexio/reflexio/server/services/extraction/outcome.py +13 -3
  219. package/plugin/vendor/reflexio/reflexio/server/services/extraction/pending_tool_call_dispatch.py +3 -2
  220. package/plugin/vendor/reflexio/reflexio/server/services/extraction/resumable_agent.py +32 -118
  221. package/plugin/vendor/reflexio/reflexio/server/services/extraction/resume_worker.py +39 -16
  222. package/plugin/vendor/reflexio/reflexio/server/services/generation_service.py +105 -23
  223. package/plugin/vendor/reflexio/reflexio/server/services/lineage/__init__.py +0 -0
  224. package/plugin/vendor/reflexio/reflexio/server/services/lineage/gc_scheduler.py +193 -0
  225. package/plugin/vendor/reflexio/reflexio/server/services/lineage/resolve.py +98 -0
  226. package/plugin/vendor/reflexio/reflexio/server/services/operation_state_utils.py +26 -0
  227. package/plugin/vendor/reflexio/reflexio/server/services/playbook/README.md +11 -12
  228. package/plugin/vendor/reflexio/reflexio/server/services/playbook/components/__init__.py +5 -0
  229. package/plugin/vendor/reflexio/reflexio/server/services/playbook/{playbook_aggregator.py → components/aggregator.py} +316 -178
  230. package/plugin/vendor/reflexio/reflexio/server/services/playbook/{playbook_consolidator.py → components/consolidator.py} +168 -62
  231. package/plugin/vendor/reflexio/reflexio/server/services/playbook/{playbook_extractor.py → components/extractor.py} +5 -1
  232. package/plugin/vendor/reflexio/reflexio/server/services/playbook/playbook_edit_apply.py +74 -0
  233. package/plugin/vendor/reflexio/reflexio/server/services/playbook/playbook_service_utils.py +33 -3
  234. package/plugin/vendor/reflexio/reflexio/server/services/playbook/{playbook_generation_service.py → service.py} +121 -19
  235. package/plugin/vendor/reflexio/reflexio/server/services/playbook/user_detail_stripping.py +84 -0
  236. package/plugin/vendor/reflexio/reflexio/server/services/playbook_optimizer/README.md +10 -0
  237. package/plugin/vendor/reflexio/reflexio/server/services/playbook_optimizer/models.py +2 -1
  238. package/plugin/vendor/reflexio/reflexio/server/services/playbook_optimizer/optimizer.py +178 -30
  239. package/plugin/vendor/reflexio/reflexio/server/services/pre_retrieval/README.md +9 -0
  240. package/plugin/vendor/reflexio/reflexio/server/services/profile/components/__init__.py +17 -0
  241. package/plugin/vendor/reflexio/reflexio/server/services/profile/{profile_deduplicator.py → components/consolidator.py} +7 -6
  242. package/plugin/vendor/reflexio/reflexio/server/services/profile/{profile_extractor.py → components/extractor.py} +30 -22
  243. package/plugin/vendor/reflexio/reflexio/server/services/profile/profile_generation_service_utils.py +6 -13
  244. package/plugin/vendor/reflexio/reflexio/server/services/profile/{profile_generation_service.py → service.py} +32 -50
  245. package/plugin/vendor/reflexio/reflexio/server/services/reflection/__init__.py +4 -8
  246. package/plugin/vendor/reflexio/reflexio/server/services/reflection/components/__init__.py +7 -0
  247. package/plugin/vendor/reflexio/reflexio/server/services/reflection/reflection_service_utils.py +12 -1
  248. package/plugin/vendor/reflexio/reflexio/server/services/reflection/{reflection_service.py → service.py} +36 -16
  249. package/plugin/vendor/reflexio/reflexio/server/services/retrieval/relevance_floor.py +83 -6
  250. package/plugin/vendor/reflexio/reflexio/server/services/service_utils.py +36 -1
  251. package/plugin/vendor/reflexio/reflexio/server/services/shadow_comparison/README.md +8 -0
  252. package/plugin/vendor/reflexio/reflexio/server/services/shadow_comparison/outcome.py +1 -2
  253. package/plugin/vendor/reflexio/reflexio/server/services/storage/error.py +27 -0
  254. package/plugin/vendor/reflexio/reflexio/server/services/storage/retention.py +9 -9
  255. package/plugin/vendor/reflexio/reflexio/server/services/storage/sqlite_storage/__init__.py +2 -0
  256. package/plugin/vendor/reflexio/reflexio/server/services/storage/sqlite_storage/_agent_run.py +4 -1
  257. package/plugin/vendor/reflexio/reflexio/server/services/storage/sqlite_storage/_base.py +642 -197
  258. package/plugin/vendor/reflexio/reflexio/server/services/storage/sqlite_storage/_extras.py +61 -104
  259. package/plugin/vendor/reflexio/reflexio/server/services/storage/sqlite_storage/_lineage.py +628 -0
  260. package/plugin/vendor/reflexio/reflexio/server/services/storage/sqlite_storage/_operations.py +38 -6
  261. package/plugin/vendor/reflexio/reflexio/server/services/storage/sqlite_storage/_playbook.py +978 -184
  262. package/plugin/vendor/reflexio/reflexio/server/services/storage/sqlite_storage/_profiles.py +519 -128
  263. package/plugin/vendor/reflexio/reflexio/server/services/storage/sqlite_storage/_requests.py +77 -3
  264. package/plugin/vendor/reflexio/reflexio/server/services/storage/sqlite_storage/_shadow_verdicts.py +23 -9
  265. package/plugin/vendor/reflexio/reflexio/server/services/storage/sqlite_storage/_stall_state.py +5 -2
  266. package/plugin/vendor/reflexio/reflexio/server/services/storage/storage_base/__init__.py +131 -20
  267. package/plugin/vendor/reflexio/reflexio/server/services/storage/storage_base/_agent_run.py +2 -8
  268. package/plugin/vendor/reflexio/reflexio/server/services/storage/storage_base/_base.py +5 -0
  269. package/plugin/vendor/reflexio/reflexio/server/services/storage/storage_base/_extras.py +36 -53
  270. package/plugin/vendor/reflexio/reflexio/server/services/storage/storage_base/_lineage.py +215 -0
  271. package/plugin/vendor/reflexio/reflexio/server/services/storage/storage_base/_operations.py +20 -0
  272. package/plugin/vendor/reflexio/reflexio/server/services/storage/storage_base/_playbook.py +243 -7
  273. package/plugin/vendor/reflexio/reflexio/server/services/storage/storage_base/_profiles.py +121 -1
  274. package/plugin/vendor/reflexio/reflexio/server/services/storage/storage_base/_requests.py +60 -0
  275. package/plugin/vendor/reflexio/reflexio/server/services/storage/storage_base/_retrieval_log.py +51 -0
  276. package/plugin/vendor/reflexio/reflexio/server/services/storage/storage_base/_shadow_verdicts.py +17 -0
  277. package/plugin/vendor/reflexio/reflexio/server/services/tagging/README.md +8 -0
  278. package/plugin/vendor/reflexio/reflexio/server/services/tagging/__init__.py +1 -0
  279. package/plugin/vendor/reflexio/reflexio/server/services/tagging/service.py +200 -0
  280. package/plugin/vendor/reflexio/reflexio/server/services/tagging/tagging_scheduler.py +149 -0
  281. package/plugin/vendor/reflexio/reflexio/server/services/unified_search_service.py +229 -32
  282. package/plugin/vendor/reflexio/reflexio/server/site_var/README.md +2 -1
  283. package/plugin/vendor/reflexio/reflexio/server/site_var/feature_flags.py +120 -1
  284. package/plugin/vendor/reflexio/reflexio/server/site_var/site_var_sources/feature_flags.json +4 -0
  285. package/plugin/vendor/reflexio/reflexio/server/site_var/site_var_sources/search_settings.json +5 -0
  286. package/plugin/vendor/reflexio/reflexio/server/tracing.py +30 -0
  287. package/plugin/vendor/reflexio/reflexio/server/usage_metrics.py +18 -0
  288. package/plugin/vendor/reflexio/reflexio/test_support/llm_mock.py +61 -26
  289. package/plugin/vendor/reflexio/reflexio/test_support/llm_model_registry.py +41 -4
  290. package/scripts/setup-claude-smart.sh +8 -3
  291. package/plugin/vendor/reflexio/reflexio/integrations/openclaw/TESTING.md +0 -517
  292. package/plugin/vendor/reflexio/reflexio/integrations/openclaw/hook/handler.js +0 -473
  293. package/plugin/vendor/reflexio/reflexio/integrations/openclaw/package-lock.json +0 -2156
  294. package/plugin/vendor/reflexio/reflexio/integrations/openclaw/package.json +0 -18
  295. package/plugin/vendor/reflexio/reflexio/integrations/openclaw/plugin/hook/handler.ts +0 -241
  296. package/plugin/vendor/reflexio/reflexio/integrations/openclaw/plugin/hook/setup.ts +0 -140
  297. package/plugin/vendor/reflexio/reflexio/integrations/openclaw/plugin/lib/publish.ts +0 -113
  298. package/plugin/vendor/reflexio/reflexio/integrations/openclaw/plugin/lib/search.ts +0 -52
  299. package/plugin/vendor/reflexio/reflexio/integrations/openclaw/plugin/lib/server.ts +0 -103
  300. package/plugin/vendor/reflexio/reflexio/integrations/openclaw/plugin/lib/sqlite-buffer.ts +0 -156
  301. package/plugin/vendor/reflexio/reflexio/integrations/openclaw/plugin/lib/user-id.ts +0 -134
  302. package/plugin/vendor/reflexio/reflexio/integrations/openclaw/plugin/rules/reflexio.md +0 -24
  303. package/plugin/vendor/reflexio/reflexio/integrations/openclaw/publish_clawhub.sh +0 -278
  304. package/plugin/vendor/reflexio/reflexio/integrations/openclaw/references/HOOK.md +0 -164
  305. package/plugin/vendor/reflexio/reflexio/integrations/openclaw/scripts/install.sh +0 -36
  306. package/plugin/vendor/reflexio/reflexio/integrations/openclaw/scripts/uninstall.sh +0 -35
  307. package/plugin/vendor/reflexio/reflexio/integrations/openclaw/tests/publish.test.ts +0 -27
  308. package/plugin/vendor/reflexio/reflexio/integrations/openclaw/tests/search.test.ts +0 -31
  309. package/plugin/vendor/reflexio/reflexio/integrations/openclaw/tests/server.test.ts +0 -42
  310. package/plugin/vendor/reflexio/reflexio/integrations/openclaw/tests/setup.test.ts +0 -49
  311. package/plugin/vendor/reflexio/reflexio/integrations/openclaw/tests/sqlite-buffer.test.ts +0 -91
  312. package/plugin/vendor/reflexio/reflexio/integrations/openclaw/tests/user-id.test.ts +0 -50
  313. package/plugin/vendor/reflexio/reflexio/integrations/openclaw/vitest.config.ts +0 -13
  314. package/plugin/vendor/reflexio/reflexio/server/services/evaluation_overview/group_aggregation.py +0 -192
  315. package/plugin/vendor/reflexio/reflexio/server/services/extraction/invariants.py +0 -303
  316. package/plugin/vendor/reflexio/reflexio/server/services/extraction/plan.py +0 -138
  317. package/plugin/vendor/reflexio/reflexio/server/services/extraction/tools.py +0 -1125
  318. /package/plugin/vendor/reflexio/reflexio/integrations/{__init__.py → openclaw/plugin/src/openclaw_smart/__init__.py} +0 -0
  319. /package/plugin/vendor/reflexio/reflexio/integrations/openclaw/{types → plugin/types}/openclaw.d.ts +0 -0
  320. /package/plugin/vendor/reflexio/reflexio/server/services/evaluation_overview/{distribution.py → components/distribution.py} +0 -0
  321. /package/plugin/vendor/reflexio/reflexio/server/services/evaluation_overview/{hero_state.py → components/hero_state.py} +0 -0
  322. /package/plugin/vendor/reflexio/reflexio/server/services/evaluation_overview/{shadow_aggregation.py → components/shadow_aggregation.py} +0 -0
  323. /package/plugin/vendor/reflexio/reflexio/server/services/reflection/{reflection_extractor.py → components/extractor.py} +0 -0
@@ -1,10 +1,15 @@
1
1
  """Playbook CRUD + search methods for SQLite storage."""
2
2
 
3
3
  import json
4
+ import logging
4
5
  import sqlite3
6
+ import uuid
7
+ from collections.abc import Sequence
5
8
  from concurrent.futures import ThreadPoolExecutor
6
9
  from typing import Any
7
10
 
11
+ logger = logging.getLogger(__name__)
12
+
8
13
  from reflexio.models.api_schema.common import BlockingIssue
9
14
  from reflexio.models.api_schema.retriever_schema import (
10
15
  SearchAgentPlaybookRequest,
@@ -23,11 +28,16 @@ from reflexio.models.api_schema.service_schemas import (
23
28
  UserPlaybook,
24
29
  )
25
30
  from reflexio.models.config_schema import SearchMode, SearchOptions
31
+ from reflexio.server.services.storage.storage_base._playbook import (
32
+ AGGREGATE_REASON_PREFIX,
33
+ )
26
34
 
27
35
  from ._base import (
36
+ _TOMBSTONE_STATUS_VALUES,
28
37
  SQLiteStorageBase,
29
38
  _build_status_sql,
30
39
  _effective_search_mode,
40
+ _epoch_now,
31
41
  _epoch_to_iso,
32
42
  _json_dumps,
33
43
  _json_loads,
@@ -38,6 +48,83 @@ from ._base import (
38
48
  _true_rrf_merge,
39
49
  _vector_rank_rows,
40
50
  )
51
+ from ._lineage import _GC_ELIGIBLE_STATUSES, _append_event_stmt
52
+
53
+
54
+ def _emit_hard_delete_playbook(
55
+ conn: sqlite3.Connection,
56
+ *,
57
+ org_id: str,
58
+ entity_type: str,
59
+ entity_id: str,
60
+ request_id: str,
61
+ actor: str = "api",
62
+ ) -> None:
63
+ """Emit a single hard_delete lineage event for a playbook entity."""
64
+ _append_event_stmt(
65
+ conn,
66
+ org_id=org_id,
67
+ entity_type=entity_type,
68
+ entity_id=entity_id,
69
+ op="hard_delete",
70
+ prov="wasInvalidatedBy",
71
+ source_ids=[],
72
+ actor=actor,
73
+ request_id=request_id,
74
+ reason="erasure",
75
+ )
76
+
77
+
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
+ )
41
128
 
42
129
 
43
130
  def _row_to_playbook_optimization_candidate(
@@ -51,6 +138,7 @@ def _row_to_playbook_optimization_candidate(
51
138
  parent_candidate_ids=_json_loads(row["parent_candidate_ids"]) or [],
52
139
  aggregate_score=row["aggregate_score"],
53
140
  is_winner=bool(row["is_winner"]),
141
+ metadata_json=row["metadata_json"] or "{}",
54
142
  created_at=row["created_at"],
55
143
  )
56
144
 
@@ -77,12 +165,23 @@ def _row_to_playbook_optimization_evaluation(
77
165
  )
78
166
 
79
167
 
168
+ def _build_tags_sql(alias: str, tags: list[str] | None) -> tuple[str, list[Any]]:
169
+ if not tags:
170
+ return "", []
171
+ placeholders = ",".join("?" for _ in tags)
172
+ return (
173
+ f"EXISTS (SELECT 1 FROM json_each({alias}.tags) WHERE value IN ({placeholders}))",
174
+ list(tags),
175
+ )
176
+
177
+
80
178
  class PlaybookMixin:
81
179
  """Mixin providing user playbook, agent playbook, and evaluation CRUD + search."""
82
180
 
83
181
  # Type hints for instance attributes/methods provided by SQLiteStorageBase via MRO
84
182
  _lock: Any
85
183
  conn: sqlite3.Connection
184
+ org_id: str
86
185
  _execute: Any
87
186
  _fetchone: Any
88
187
  _fetchall: Any
@@ -93,6 +192,7 @@ class PlaybookMixin:
93
192
  _fts_delete: Any
94
193
  _vec_upsert: Any
95
194
  _vec_delete: Any
195
+ _delete_playbook_search_rows: Any
96
196
  _has_sqlite_vec: bool
97
197
 
98
198
  # ------------------------------------------------------------------
@@ -125,8 +225,9 @@ class PlaybookMixin:
125
225
  content, trigger, rationale, blocking_issue,
126
226
  source_interaction_ids,
127
227
  status, source, embedding, expanded_terms,
128
- source_span, notes, reader_angle)
129
- VALUES (?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?)""",
228
+ source_span, notes, reader_angle, tags,
229
+ merged_into, superseded_by)
230
+ VALUES (?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?)""",
130
231
  (
131
232
  up.user_id,
132
233
  up.playbook_name,
@@ -147,6 +248,9 @@ class PlaybookMixin:
147
248
  up.source_span,
148
249
  up.notes,
149
250
  up.reader_angle,
251
+ _json_dumps(up.tags),
252
+ up.merged_into,
253
+ up.superseded_by,
150
254
  ),
151
255
  )
152
256
  upid = cur.lastrowid or 0
@@ -175,6 +279,7 @@ class PlaybookMixin:
175
279
  start_time: int | None = None,
176
280
  end_time: int | None = None,
177
281
  include_embedding: bool = False,
282
+ tags: list[str] | None = None,
178
283
  ) -> list[UserPlaybook]:
179
284
  sql = "SELECT * FROM user_playbooks WHERE 1=1"
180
285
  params: list[Any] = []
@@ -198,6 +303,14 @@ class PlaybookMixin:
198
303
  frag, sparams = _build_status_sql(status_filter)
199
304
  sql += f" AND {frag}"
200
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)
201
314
 
202
315
  sql += " ORDER BY created_at DESC LIMIT ?"
203
316
  params.append(limit)
@@ -234,6 +347,10 @@ class PlaybookMixin:
234
347
  frag, sparams = _build_status_sql(status_filter)
235
348
  sql += f" AND {frag}"
236
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)
237
354
 
238
355
  row = self._fetchone(sql, params)
239
356
  return row["cnt"] if row else 0
@@ -243,25 +360,51 @@ class PlaybookMixin:
243
360
  row = self._fetchone(
244
361
  """SELECT COUNT(*) as cnt FROM user_playbooks up
245
362
  JOIN requests r ON up.request_id = r.request_id
246
- WHERE r.session_id = ?""",
247
- (session_id,),
363
+ WHERE r.session_id = ?
364
+ AND (up.status IS NULL OR up.status NOT IN (?, ?))""",
365
+ (session_id, *_TOMBSTONE_STATUS_VALUES),
248
366
  )
249
367
  return row["cnt"] if row else 0
250
368
 
251
369
  @SQLiteStorageBase.handle_exceptions
252
370
  def delete_all_user_playbooks(self) -> None:
371
+ batch_request_id = uuid.uuid4().hex
253
372
  with self._lock:
254
- self.conn.execute("DELETE FROM user_playbooks_fts")
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
+ ]
255
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
+ )
256
388
  self.conn.commit()
389
+ self._delete_playbook_search_rows("user", ids)
257
390
 
258
391
  @SQLiteStorageBase.handle_exceptions
259
392
  def delete_user_playbook(self, user_playbook_id: int) -> None:
260
- self._fts_delete("user_playbooks_fts", user_playbook_id)
261
- self._vec_delete("user_playbooks_vec", user_playbook_id)
262
- self._execute(
263
- "DELETE FROM user_playbooks WHERE user_playbook_id = ?", (user_playbook_id,)
264
- )
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()
265
408
 
266
409
  @SQLiteStorageBase.handle_exceptions
267
410
  def delete_all_user_playbooks_by_playbook_name(
@@ -272,37 +415,60 @@ class PlaybookMixin:
272
415
  if agent_version is not None:
273
416
  sql += " AND agent_version = ?"
274
417
  params.append(agent_version)
275
- ids = [r["user_playbook_id"] for r in self._fetchall(sql, params)]
276
- if ids:
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
277
425
  ph = ",".join("?" for _ in ids)
278
- with self._lock:
279
- self.conn.execute(
280
- f"DELETE FROM user_playbooks_fts WHERE rowid IN ({ph})", 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,
281
436
  )
282
- self.conn.commit()
283
-
284
- del_sql = "DELETE FROM user_playbooks WHERE playbook_name = ?"
285
- del_params: list[Any] = [playbook_name]
286
- if agent_version is not None:
287
- del_sql += " AND agent_version = ?"
288
- del_params.append(agent_version)
289
- self._execute(del_sql, del_params)
437
+ self.conn.commit()
438
+ self._delete_playbook_search_rows("user", ids)
290
439
 
291
440
  @SQLiteStorageBase.handle_exceptions
292
- def delete_user_playbooks_by_ids(self, user_playbook_ids: list[int]) -> int:
441
+ def delete_user_playbooks_by_ids(
442
+ self, user_playbook_ids: list[int], *, emit_hard_delete: bool = True
443
+ ) -> int:
293
444
  if not user_playbook_ids:
294
445
  return 0
295
446
  ph = ",".join("?" for _ in user_playbook_ids)
447
+ batch_request_id = uuid.uuid4().hex
296
448
  with self._lock:
297
- self.conn.execute(
298
- f"DELETE FROM user_playbooks_fts WHERE rowid IN ({ph})",
299
- user_playbook_ids,
300
- )
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
+ ]
301
456
  cur = self.conn.execute(
302
457
  f"DELETE FROM user_playbooks WHERE user_playbook_id IN ({ph})",
303
458
  user_playbook_ids,
304
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
+ )
305
470
  self.conn.commit()
471
+ self._delete_playbook_search_rows("user", user_playbook_ids)
306
472
  return cur.rowcount
307
473
 
308
474
  @SQLiteStorageBase.handle_exceptions
@@ -314,26 +480,63 @@ class PlaybookMixin:
314
480
  playbook_name: str | None = None,
315
481
  ) -> int:
316
482
  new_val = new_status.value if new_status else None
317
- params: list[Any] = [new_val]
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}"
318
487
 
319
488
  if old_status is None or (
320
489
  hasattr(old_status, "value") and old_status.value is None
321
490
  ):
322
491
  where = "status IS NULL"
492
+ select_params: list[Any] = []
323
493
  else:
324
494
  where = "status = ?"
325
- params.append(old_status.value)
495
+ select_params = [old_status.value]
326
496
 
497
+ extra_params: list[Any] = []
327
498
  if agent_version is not None:
328
499
  where += " AND agent_version = ?"
329
- params.append(agent_version)
500
+ extra_params.append(agent_version)
330
501
  if playbook_name is not None:
331
502
  where += " AND playbook_name = ?"
332
- params.append(playbook_name)
503
+ extra_params.append(playbook_name)
333
504
 
334
- cur = self._execute(
335
- f"UPDATE user_playbooks SET status = ? WHERE {where}", params
336
- )
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()
337
540
  return cur.rowcount
338
541
 
339
542
  @SQLiteStorageBase.handle_exceptions
@@ -343,6 +546,10 @@ class PlaybookMixin:
343
546
  agent_version: str | None = None,
344
547
  playbook_name: str | None = None,
345
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).
346
553
  where = "status = ?"
347
554
  params: list[Any] = [status.value]
348
555
  if agent_version is not None:
@@ -352,22 +559,23 @@ class PlaybookMixin:
352
559
  where += " AND playbook_name = ?"
353
560
  params.append(playbook_name)
354
561
 
355
- # Clean up FTS
356
- ids = [
357
- r["user_playbook_id"]
358
- for r in self._fetchall(
359
- f"SELECT user_playbook_id FROM user_playbooks WHERE {where}", params
360
- )
361
- ]
362
- if ids:
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
363
572
  ph = ",".join("?" for _ in ids)
364
- with self._lock:
365
- self.conn.execute(
366
- f"DELETE FROM user_playbooks_fts WHERE rowid IN ({ph})", ids
367
- )
368
- self.conn.commit()
369
-
370
- cur = self._execute(f"DELETE FROM user_playbooks WHERE {where}", params)
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()
371
579
  return cur.rowcount
372
580
 
373
581
  @SQLiteStorageBase.handle_exceptions
@@ -393,9 +601,9 @@ class PlaybookMixin:
393
601
  @SQLiteStorageBase.handle_exceptions
394
602
  def archive_user_playbook_by_id(self, user_id: str, user_playbook_id: int) -> bool:
395
603
  cur = self._execute(
396
- "UPDATE user_playbooks SET status = ? "
604
+ "UPDATE user_playbooks SET status = ?, retired_at = ? "
397
605
  "WHERE user_playbook_id = ? AND user_id = ? AND status IS NULL",
398
- (Status.ARCHIVED.value, user_playbook_id, user_id),
606
+ (Status.ARCHIVED.value, _epoch_now(), user_playbook_id, user_id),
399
607
  )
400
608
  return cur.rowcount > 0
401
609
 
@@ -470,6 +678,14 @@ class PlaybookMixin:
470
678
  frag, sparams = _build_status_sql(status_filter)
471
679
  conditions.append(frag)
472
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)
473
689
 
474
690
  where_extra = (" AND " + " AND ".join(conditions)) if conditions else ""
475
691
  overfetch = match_count * 5 if mode != SearchMode.FTS else match_count
@@ -534,11 +750,15 @@ class PlaybookMixin:
534
750
  return [_row_to_user_playbook(r) for r in rows]
535
751
 
536
752
  @SQLiteStorageBase.handle_exceptions
537
- def get_user_playbook_by_id(self, user_playbook_id: int) -> UserPlaybook | None:
538
- row = self._fetchone(
539
- "SELECT * FROM user_playbooks WHERE user_playbook_id = ?",
540
- (user_playbook_id,),
541
- )
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,))
542
762
  return _row_to_user_playbook(row) if row else None
543
763
 
544
764
  @SQLiteStorageBase.handle_exceptions
@@ -567,6 +787,77 @@ class PlaybookMixin:
567
787
  # Agent Playbook methods
568
788
  # ------------------------------------------------------------------
569
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
+
570
861
  @SQLiteStorageBase.handle_exceptions
571
862
  def save_agent_playbooks(
572
863
  self, agent_playbooks: list[AgentPlaybook]
@@ -585,50 +876,89 @@ class PlaybookMixin:
585
876
 
586
877
  created_at_iso = _epoch_to_iso(ap.created_at)
587
878
  with self._lock:
588
- cur = self.conn.execute(
589
- """INSERT INTO agent_playbooks
590
- (playbook_name, created_at, agent_version, content,
591
- trigger, rationale, blocking_issue,
592
- playbook_status, playbook_metadata, embedding,
593
- expanded_terms, status)
594
- VALUES (?,?,?,?,?,?,?,?,?,?,?,?)""",
595
- (
596
- ap.playbook_name,
597
- created_at_iso,
598
- ap.agent_version,
599
- ap.content,
600
- ap.trigger,
601
- ap.rationale,
602
- json.dumps(ap.blocking_issue.model_dump())
603
- if ap.blocking_issue
604
- else None,
605
- ap.playbook_status.value
606
- if isinstance(ap.playbook_status, PlaybookStatus)
607
- else ap.playbook_status,
608
- ap.playbook_metadata,
609
- _json_dumps(ap.embedding),
610
- ap.expanded_terms,
611
- ap.status.value if ap.status else None,
612
- ),
613
- )
614
- ap.agent_playbook_id = cur.lastrowid or 0
879
+ self._insert_agent_playbook_row(self.conn, ap, created_at_iso)
615
880
  self.conn.commit()
616
881
 
617
- fts_parts = [ap.trigger or "", ap.content or ""]
618
- if ap.expanded_terms:
619
- fts_parts.append(ap.expanded_terms)
620
- self._fts_upsert(
621
- "agent_playbooks_fts",
622
- ap.agent_playbook_id,
623
- search_text=" ".join(p for p in fts_parts if p) or "",
624
- )
625
- if ap.embedding:
626
- self._vec_upsert(
627
- "agent_playbooks_vec", ap.agent_playbook_id, ap.embedding
628
- )
882
+ self._index_agent_playbook_fts_vec(ap)
629
883
  saved.append(ap)
630
884
  return saved
631
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
+
632
962
  @SQLiteStorageBase.handle_exceptions
633
963
  def get_agent_playbooks(
634
964
  self,
@@ -637,6 +967,7 @@ class PlaybookMixin:
637
967
  agent_version: str | None = None,
638
968
  status_filter: list[Status | None] | None = None,
639
969
  playbook_status_filter: list[PlaybookStatus] | None = None,
970
+ tags: list[str] | None = None,
640
971
  ) -> list[AgentPlaybook]:
641
972
  sql = "SELECT * FROM agent_playbooks WHERE 1=1"
642
973
  params: list[Any] = []
@@ -660,6 +991,10 @@ class PlaybookMixin:
660
991
  ph = ",".join("?" for _ in playbook_status_filter)
661
992
  sql += f" AND playbook_status IN ({ph})"
662
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)
663
998
 
664
999
  sql += " ORDER BY created_at DESC LIMIT ?"
665
1000
  params.append(limit)
@@ -667,28 +1002,58 @@ class PlaybookMixin:
667
1002
  return [_row_to_agent_playbook(r) for r in rows]
668
1003
 
669
1004
  @SQLiteStorageBase.handle_exceptions
670
- def get_agent_playbook_by_id(self, agent_playbook_id: int) -> AgentPlaybook | None:
671
- row = self._fetchone(
672
- "SELECT * FROM agent_playbooks WHERE agent_playbook_id = ?",
673
- (agent_playbook_id,),
674
- )
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,))
675
1014
  return _row_to_agent_playbook(row) if row else None
676
1015
 
677
1016
  @SQLiteStorageBase.handle_exceptions
678
1017
  def delete_all_agent_playbooks(self) -> None:
1018
+ batch_request_id = uuid.uuid4().hex
679
1019
  with self._lock:
680
- self.conn.execute("DELETE FROM agent_playbooks_fts")
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
+ ]
681
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
+ )
682
1035
  self.conn.commit()
1036
+ self._delete_playbook_search_rows("agent", ids)
683
1037
 
684
1038
  @SQLiteStorageBase.handle_exceptions
685
1039
  def delete_agent_playbook(self, agent_playbook_id: int) -> None:
686
- self._fts_delete("agent_playbooks_fts", agent_playbook_id)
687
- self._vec_delete("agent_playbooks_vec", agent_playbook_id)
688
- self._execute(
689
- "DELETE FROM agent_playbooks WHERE agent_playbook_id = ?",
690
- (agent_playbook_id,),
691
- )
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()
692
1057
 
693
1058
  @SQLiteStorageBase.handle_exceptions
694
1059
  def delete_all_agent_playbooks_by_playbook_name(
@@ -699,52 +1064,103 @@ class PlaybookMixin:
699
1064
  if agent_version is not None:
700
1065
  sql += " AND agent_version = ?"
701
1066
  params.append(agent_version)
702
- ids = [r["agent_playbook_id"] for r in self._fetchall(sql, params)]
703
- if ids:
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
704
1075
  ph = ",".join("?" for _ in ids)
705
- with self._lock:
706
- self.conn.execute(
707
- f"DELETE FROM agent_playbooks_fts WHERE rowid IN ({ph})", 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,
708
1086
  )
709
- self.conn.commit()
710
-
711
- del_sql = "DELETE FROM agent_playbooks WHERE playbook_name = ?"
712
- del_params: list[Any] = [playbook_name]
713
- if agent_version is not None:
714
- del_sql += " AND agent_version = ?"
715
- del_params.append(agent_version)
716
- self._execute(del_sql, del_params)
1087
+ self.conn.commit()
1088
+ self._delete_playbook_search_rows("agent", ids)
717
1089
 
718
1090
  @SQLiteStorageBase.handle_exceptions
719
- def delete_agent_playbooks_by_ids(self, agent_playbook_ids: list[int]) -> None:
1091
+ def delete_agent_playbooks_by_ids(
1092
+ self, agent_playbook_ids: list[int], *, emit_hard_delete: bool = True
1093
+ ) -> None:
720
1094
  if not agent_playbook_ids:
721
1095
  return
722
1096
  ph = ",".join("?" for _ in agent_playbook_ids)
1097
+ batch_request_id = uuid.uuid4().hex
723
1098
  with self._lock:
724
- self.conn.execute(
725
- f"DELETE FROM agent_playbooks_fts WHERE rowid IN ({ph})",
726
- agent_playbook_ids,
727
- )
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
+ ]
728
1106
  self.conn.execute(
729
1107
  f"DELETE FROM agent_playbooks WHERE agent_playbook_id IN ({ph})",
730
1108
  agent_playbook_ids,
731
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
+ )
732
1120
  self.conn.commit()
1121
+ self._delete_playbook_search_rows("agent", agent_playbook_ids)
733
1122
 
734
1123
  @SQLiteStorageBase.handle_exceptions
735
1124
  def update_agent_playbook_status(
736
1125
  self, agent_playbook_id: int, playbook_status: PlaybookStatus
737
1126
  ) -> None:
738
- row = self._fetchone(
739
- "SELECT agent_playbook_id FROM agent_playbooks WHERE agent_playbook_id = ?",
740
- (agent_playbook_id,),
741
- )
742
- if not row:
743
- raise ValueError(f"Agent playbook with ID {agent_playbook_id} not found")
744
- self._execute(
745
- "UPDATE agent_playbooks SET playbook_status = ? WHERE agent_playbook_id = ?",
746
- (playbook_status.value, agent_playbook_id),
747
- )
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()
748
1164
 
749
1165
  @SQLiteStorageBase.handle_exceptions
750
1166
  def update_agent_playbook(
@@ -756,13 +1172,8 @@ class PlaybookMixin:
756
1172
  rationale: str | None = None,
757
1173
  blocking_issue: BlockingIssue | None = None,
758
1174
  playbook_status: PlaybookStatus | None = None,
1175
+ tags: list[str] | None = None,
759
1176
  ) -> None:
760
- row = self._fetchone(
761
- "SELECT agent_playbook_id FROM agent_playbooks WHERE agent_playbook_id = ?",
762
- (agent_playbook_id,),
763
- )
764
- if not row:
765
- raise ValueError(f"Agent playbook with ID {agent_playbook_id} not found")
766
1177
  updates: list[str] = []
767
1178
  params: list[Any] = []
768
1179
  if playbook_name is not None:
@@ -783,12 +1194,54 @@ class PlaybookMixin:
783
1194
  if playbook_status is not None:
784
1195
  updates.append("playbook_status = ?")
785
1196
  params.append(playbook_status.value)
1197
+ if tags is not None:
1198
+ updates.append("tags = ?")
1199
+ params.append(_json_dumps(tags))
786
1200
  if updates:
787
1201
  params.append(agent_playbook_id)
788
- self._execute(
789
- f"UPDATE agent_playbooks SET {', '.join(updates)} WHERE agent_playbook_id = ?",
790
- tuple(params),
791
- )
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()
792
1245
 
793
1246
  @SQLiteStorageBase.handle_exceptions
794
1247
  def update_user_playbook(
@@ -799,13 +1252,8 @@ class PlaybookMixin:
799
1252
  trigger: str | None = None,
800
1253
  rationale: str | None = None,
801
1254
  blocking_issue: BlockingIssue | None = None,
1255
+ tags: list[str] | None = None,
802
1256
  ) -> None:
803
- row = self._fetchone(
804
- "SELECT user_playbook_id FROM user_playbooks WHERE user_playbook_id = ?",
805
- (user_playbook_id,),
806
- )
807
- if not row:
808
- raise ValueError(f"User playbook with ID {user_playbook_id} not found")
809
1257
  updates: list[str] = []
810
1258
  params: list[Any] = []
811
1259
  if playbook_name is not None:
@@ -823,39 +1271,298 @@ class PlaybookMixin:
823
1271
  if blocking_issue is not None:
824
1272
  updates.append("blocking_issue = ?")
825
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))
826
1277
  if updates:
827
1278
  params.append(user_playbook_id)
828
- self._execute(
829
- f"UPDATE user_playbooks SET {', '.join(updates)} WHERE user_playbook_id = ?",
830
- tuple(params),
1279
+ semantic_change = any(
1280
+ value is not None for value in (content, trigger, rationale)
831
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
832
1354
 
833
1355
  @SQLiteStorageBase.handle_exceptions
834
1356
  def archive_agent_playbooks_by_playbook_name(
835
1357
  self, playbook_name: str, agent_version: str | None = None
836
1358
  ) -> None:
837
- sql = "UPDATE agent_playbooks SET status = 'archived' WHERE playbook_name = ? AND playbook_status != ?"
1359
+ where = (
1360
+ "playbook_name = ? AND playbook_status != ?"
1361
+ " AND (status IS NULL OR status != 'archived')"
1362
+ )
838
1363
  params: list[Any] = [playbook_name, PlaybookStatus.APPROVED.value]
839
1364
  if agent_version is not None:
840
- sql += " AND agent_version = ?"
1365
+ where += " AND agent_version = ?"
841
1366
  params.append(agent_version)
842
- self._execute(sql, params)
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()
843
1396
 
844
1397
  @SQLiteStorageBase.handle_exceptions
845
1398
  def archive_agent_playbooks_by_ids(self, agent_playbook_ids: list[int]) -> None:
846
1399
  if not agent_playbook_ids:
847
1400
  return
848
1401
  ph = ",".join("?" for _ in agent_playbook_ids)
849
- self._execute(
850
- f"UPDATE agent_playbooks SET status = 'archived' WHERE agent_playbook_id IN ({ph}) AND playbook_status != ?",
851
- [*agent_playbook_ids, PlaybookStatus.APPROVED.value],
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'"
852
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
853
1560
 
854
1561
  @SQLiteStorageBase.handle_exceptions
855
1562
  def restore_archived_agent_playbooks_by_playbook_name(
856
1563
  self, playbook_name: str, agent_version: str | None = None
857
1564
  ) -> None:
858
- sql = "UPDATE agent_playbooks SET status = NULL WHERE playbook_name = ? AND status = 'archived'"
1565
+ sql = "UPDATE agent_playbooks SET status = NULL, retired_at = NULL WHERE playbook_name = ? AND status = 'archived'"
859
1566
  params: list[Any] = [playbook_name]
860
1567
  if agent_version is not None:
861
1568
  sql += " AND agent_version = ?"
@@ -870,7 +1577,7 @@ class PlaybookMixin:
870
1577
  return
871
1578
  ph = ",".join("?" for _ in agent_playbook_ids)
872
1579
  self._execute(
873
- f"UPDATE agent_playbooks SET status = NULL WHERE agent_playbook_id IN ({ph}) AND status = 'archived'",
1580
+ f"UPDATE agent_playbooks SET status = NULL, retired_at = NULL WHERE agent_playbook_id IN ({ph}) AND status = 'archived'",
874
1581
  agent_playbook_ids,
875
1582
  )
876
1583
 
@@ -901,6 +1608,32 @@ class PlaybookMixin:
901
1608
  for window in self.get_source_windows_for_agent_playbook(agent_playbook_id)
902
1609
  ]
903
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
+
904
1637
  @SQLiteStorageBase.handle_exceptions
905
1638
  def set_source_windows_for_agent_playbook(
906
1639
  self,
@@ -1023,8 +1756,8 @@ class PlaybookMixin:
1023
1756
  cur = self.conn.execute(
1024
1757
  """INSERT INTO playbook_optimization_candidates
1025
1758
  (job_id, candidate_index, content, parent_candidate_ids,
1026
- aggregate_score, is_winner, created_at)
1027
- VALUES (?, ?, ?, ?, ?, ?, ?)""",
1759
+ aggregate_score, is_winner, metadata_json, created_at)
1760
+ VALUES (?, ?, ?, ?, ?, ?, ?, ?)""",
1028
1761
  (
1029
1762
  candidate.job_id,
1030
1763
  candidate.candidate_index,
@@ -1032,6 +1765,7 @@ class PlaybookMixin:
1032
1765
  _json_dumps(candidate.parent_candidate_ids) or "[]",
1033
1766
  candidate.aggregate_score,
1034
1767
  1 if candidate.is_winner else 0,
1768
+ candidate.metadata_json,
1035
1769
  candidate.created_at,
1036
1770
  ),
1037
1771
  )
@@ -1135,27 +1869,33 @@ class PlaybookMixin:
1135
1869
  def delete_archived_agent_playbooks_by_playbook_name(
1136
1870
  self, playbook_name: str, agent_version: str | None = None
1137
1871
  ) -> None:
1138
- # Get IDs for FTS cleanup
1139
1872
  sql = "SELECT agent_playbook_id FROM agent_playbooks WHERE playbook_name = ? AND status = 'archived'"
1140
1873
  params: list[Any] = [playbook_name]
1141
1874
  if agent_version is not None:
1142
1875
  sql += " AND agent_version = ?"
1143
1876
  params.append(agent_version)
1144
- ids = [r["agent_playbook_id"] for r in self._fetchall(sql, params)]
1145
- if ids:
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
1146
1885
  ph = ",".join("?" for _ in ids)
1147
- with self._lock:
1148
- self.conn.execute(
1149
- f"DELETE FROM agent_playbooks_fts WHERE rowid IN ({ph})", 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,
1150
1896
  )
1151
- self.conn.commit()
1152
-
1153
- del_sql = "DELETE FROM agent_playbooks WHERE playbook_name = ? AND status = 'archived'"
1154
- del_params: list[Any] = [playbook_name]
1155
- if agent_version is not None:
1156
- del_sql += " AND agent_version = ?"
1157
- del_params.append(agent_version)
1158
- self._execute(del_sql, del_params)
1897
+ self.conn.commit()
1898
+ self._delete_playbook_search_rows("agent", ids)
1159
1899
 
1160
1900
  @SQLiteStorageBase.handle_exceptions
1161
1901
  def search_agent_playbooks( # noqa: C901
@@ -1209,6 +1949,14 @@ class PlaybookMixin:
1209
1949
  frag, sparams = _build_status_sql(status_filter)
1210
1950
  conditions.append(frag)
1211
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)
1212
1960
 
1213
1961
  where_extra = (" AND " + " AND ".join(conditions)) if conditions else ""
1214
1962
  overfetch = match_count * 5 if mode != SearchMode.FTS else match_count
@@ -1290,12 +2038,13 @@ class PlaybookMixin:
1290
2038
  created_at_iso = _epoch_to_iso(result.created_at)
1291
2039
  self._execute(
1292
2040
  """INSERT INTO agent_success_evaluation_result
1293
- (session_id, agent_version, evaluation_name, is_success,
2041
+ (user_id, session_id, agent_version, evaluation_name, is_success,
1294
2042
  failure_type, failure_reason, regular_vs_shadow,
1295
2043
  number_of_correction_per_session, user_turns_to_resolution,
1296
2044
  is_escalated, embedding, created_at)
1297
- VALUES (?,?,?,?,?,?,?,?,?,?,?,?)""",
2045
+ VALUES (?,?,?,?,?,?,?,?,?,?,?,?,?)""",
1298
2046
  (
2047
+ result.user_id,
1299
2048
  result.session_id,
1300
2049
  result.agent_version,
1301
2050
  result.evaluation_name,
@@ -1327,6 +2076,46 @@ class PlaybookMixin:
1327
2076
  rows = self._fetchall(sql, params)
1328
2077
  return [_row_to_eval_result(r) for r in rows]
1329
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
+
1330
2119
  @SQLiteStorageBase.handle_exceptions
1331
2120
  def delete_all_agent_success_evaluation_results(self) -> None:
1332
2121
  self._execute("DELETE FROM agent_success_evaluation_result")
@@ -1334,13 +2123,15 @@ class PlaybookMixin:
1334
2123
  @SQLiteStorageBase.handle_exceptions
1335
2124
  def delete_agent_success_evaluation_results_for_session(
1336
2125
  self,
2126
+ user_id: str,
1337
2127
  session_id: str,
1338
2128
  evaluation_name: str,
1339
2129
  agent_version: str,
1340
2130
  ) -> int:
1341
- """Delete results scoped to (session_id, evaluation_name, agent_version).
2131
+ """Delete results scoped to (user_id, session_id, evaluation_name, agent_version).
1342
2132
 
1343
2133
  Args:
2134
+ user_id (str): User whose session results to clear.
1344
2135
  session_id (str): Session whose results to clear.
1345
2136
  evaluation_name (str): Which evaluator's results to clear.
1346
2137
  agent_version (str): Agent version scope.
@@ -1350,8 +2141,11 @@ class PlaybookMixin:
1350
2141
  """
1351
2142
  cur = self._execute(
1352
2143
  """DELETE FROM agent_success_evaluation_result
1353
- WHERE session_id = ? AND evaluation_name = ? AND agent_version = ?""",
1354
- (session_id, evaluation_name, agent_version),
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),
1355
2149
  )
1356
2150
  return cur.rowcount
1357
2151