claude-smart 0.2.45 → 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 (353) hide show
  1. package/.claude-plugin/marketplace.json +3 -3
  2. package/README.md +47 -10
  3. package/bin/claude-smart.js +535 -23
  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 +27 -6
  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/layout.tsx +20 -0
  16. package/plugin/dashboard/app/preferences/[id]/page.tsx +8 -8
  17. package/plugin/dashboard/app/skills/project/[id]/page.tsx +7 -3
  18. package/plugin/dashboard/app/skills/shared/[id]/page.tsx +10 -3
  19. package/plugin/dashboard/lib/claude-settings-file.ts +20 -10
  20. package/plugin/dashboard/lib/reflexio-client.ts +16 -0
  21. package/plugin/dashboard/lib/status.ts +10 -3
  22. package/plugin/dashboard/lib/types.ts +18 -6
  23. package/plugin/hooks/codex-hooks.json +0 -1
  24. package/plugin/opencode/assistant-buffer.ts +108 -0
  25. package/plugin/opencode/dist/assistant-buffer.js +96 -0
  26. package/plugin/opencode/dist/internal.js +12 -0
  27. package/plugin/opencode/dist/payload.js +85 -0
  28. package/plugin/opencode/dist/server.mjs +232 -0
  29. package/plugin/opencode/internal.ts +18 -0
  30. package/plugin/opencode/package.json +3 -0
  31. package/plugin/opencode/payload.ts +90 -0
  32. package/plugin/opencode/server.mts +251 -0
  33. package/plugin/opencode/tsconfig.json +13 -0
  34. package/plugin/pyproject.toml +25 -4
  35. package/plugin/scripts/_lib.sh +5 -1
  36. package/plugin/scripts/backend-service.sh +22 -2
  37. package/plugin/scripts/ensure-plugin-root.sh +4 -4
  38. package/plugin/scripts/hook_entry.sh +1 -1
  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 +17 -9
  43. package/plugin/src/README.md +1 -1
  44. package/plugin/src/claude_smart/cli.py +498 -12
  45. package/plugin/src/claude_smart/context_format.py +9 -9
  46. package/plugin/src/claude_smart/cs_cite.py +66 -19
  47. package/plugin/src/claude_smart/env_config.py +12 -4
  48. package/plugin/src/claude_smart/events/session_start.py +26 -7
  49. package/plugin/src/claude_smart/events/stop.py +1 -1
  50. package/plugin/src/claude_smart/ids.py +1 -1
  51. package/plugin/src/claude_smart/reflexio_adapter.py +5 -5
  52. package/plugin/src/claude_smart/runtime.py +7 -1
  53. package/plugin/uv.lock +5 -5
  54. package/plugin/vendor/reflexio/.env.example +54 -30
  55. package/plugin/vendor/reflexio/README.md +17 -11
  56. package/plugin/vendor/reflexio/pyproject.toml +13 -1
  57. package/plugin/vendor/reflexio/reflexio/README.md +4 -1
  58. package/plugin/vendor/reflexio/reflexio/cli/README.md +9 -7
  59. package/plugin/vendor/reflexio/reflexio/cli/__main__.py +10 -1
  60. package/plugin/vendor/reflexio/reflexio/cli/bootstrap_config.py +22 -5
  61. package/plugin/vendor/reflexio/reflexio/cli/commands/interactions.py +34 -4
  62. package/plugin/vendor/reflexio/reflexio/cli/commands/profiles.py +12 -7
  63. package/plugin/vendor/reflexio/reflexio/cli/commands/services.py +13 -6
  64. package/plugin/vendor/reflexio/reflexio/cli/commands/setup_cmd.py +212 -52
  65. package/plugin/vendor/reflexio/reflexio/cli/commands/shortcuts.py +15 -2
  66. package/plugin/vendor/reflexio/reflexio/cli/env_loader.py +292 -22
  67. package/plugin/vendor/reflexio/reflexio/cli/log_format.py +29 -4
  68. package/plugin/vendor/reflexio/reflexio/cli/run_services.py +17 -8
  69. package/plugin/vendor/reflexio/reflexio/cli/stop_services.py +13 -10
  70. package/plugin/vendor/reflexio/reflexio/cli/utils.py +44 -1
  71. package/plugin/vendor/reflexio/reflexio/client/client.py +154 -13
  72. package/plugin/vendor/reflexio/reflexio/integrations/openclaw/README.md +135 -257
  73. package/plugin/vendor/reflexio/reflexio/integrations/openclaw/npm/openclaw-smart/bin/openclaw-smart.js +13 -0
  74. package/plugin/vendor/reflexio/reflexio/integrations/openclaw/npm/openclaw-smart/package.json +15 -0
  75. package/plugin/vendor/reflexio/reflexio/integrations/openclaw/plugin/README.md +38 -0
  76. package/plugin/vendor/reflexio/reflexio/integrations/openclaw/plugin/index.ts +151 -110
  77. package/plugin/vendor/reflexio/reflexio/integrations/openclaw/plugin/openclaw.plugin.json +20 -10
  78. package/plugin/vendor/reflexio/reflexio/integrations/openclaw/plugin/package.json +28 -6
  79. package/plugin/vendor/reflexio/reflexio/integrations/openclaw/plugin/pyproject.toml +42 -0
  80. package/plugin/vendor/reflexio/reflexio/integrations/openclaw/plugin/scripts/_lib.sh +371 -0
  81. package/plugin/vendor/reflexio/reflexio/integrations/openclaw/plugin/scripts/backend-log-runner.sh +33 -0
  82. package/plugin/vendor/reflexio/reflexio/integrations/openclaw/plugin/scripts/backend-service.sh +271 -0
  83. package/plugin/vendor/reflexio/reflexio/integrations/openclaw/plugin/scripts/cli.sh +68 -0
  84. package/plugin/vendor/reflexio/reflexio/integrations/openclaw/plugin/scripts/dashboard-open.sh +15 -0
  85. package/plugin/vendor/reflexio/reflexio/integrations/openclaw/plugin/scripts/ensure-plugin-root.sh +84 -0
  86. package/plugin/vendor/reflexio/reflexio/integrations/openclaw/plugin/scripts/hook_entry.sh +106 -0
  87. package/plugin/vendor/reflexio/reflexio/integrations/openclaw/plugin/scripts/npm-cli.js +219 -0
  88. package/plugin/vendor/reflexio/reflexio/integrations/openclaw/plugin/scripts/smart-install.sh +269 -0
  89. package/plugin/vendor/reflexio/reflexio/integrations/openclaw/plugin/skills/clear-all/SKILL.md +8 -0
  90. package/plugin/vendor/reflexio/reflexio/integrations/openclaw/plugin/skills/dashboard/SKILL.md +8 -0
  91. package/plugin/vendor/reflexio/reflexio/integrations/openclaw/plugin/skills/learn/SKILL.md +10 -0
  92. package/plugin/vendor/reflexio/reflexio/integrations/openclaw/plugin/skills/reflexio/SKILL.md +15 -44
  93. package/plugin/vendor/reflexio/reflexio/integrations/openclaw/plugin/skills/restart/SKILL.md +6 -0
  94. package/plugin/vendor/reflexio/reflexio/integrations/openclaw/plugin/skills/show/SKILL.md +8 -0
  95. package/plugin/vendor/reflexio/reflexio/integrations/openclaw/plugin/src/openclaw_smart/cli.py +634 -0
  96. package/plugin/vendor/reflexio/reflexio/integrations/openclaw/plugin/src/openclaw_smart/context_format.py +224 -0
  97. package/plugin/vendor/reflexio/reflexio/integrations/openclaw/plugin/src/openclaw_smart/context_inject.py +79 -0
  98. package/plugin/vendor/reflexio/reflexio/integrations/openclaw/plugin/src/openclaw_smart/events/__init__.py +0 -0
  99. package/plugin/vendor/reflexio/reflexio/integrations/openclaw/plugin/src/openclaw_smart/events/after_tool_call.py +160 -0
  100. package/plugin/vendor/reflexio/reflexio/integrations/openclaw/plugin/src/openclaw_smart/events/agent_end.py +187 -0
  101. package/plugin/vendor/reflexio/reflexio/integrations/openclaw/plugin/src/openclaw_smart/events/before_prompt_build.py +69 -0
  102. package/plugin/vendor/reflexio/reflexio/integrations/openclaw/plugin/src/openclaw_smart/events/before_tool_call.py +30 -0
  103. package/plugin/vendor/reflexio/reflexio/integrations/openclaw/plugin/src/openclaw_smart/events/session_end.py +36 -0
  104. package/plugin/vendor/reflexio/reflexio/integrations/openclaw/plugin/src/openclaw_smart/events/session_start.py +130 -0
  105. package/plugin/vendor/reflexio/reflexio/integrations/openclaw/plugin/src/openclaw_smart/hook.py +131 -0
  106. package/plugin/vendor/reflexio/reflexio/integrations/openclaw/plugin/src/openclaw_smart/ids.py +94 -0
  107. package/plugin/vendor/reflexio/reflexio/integrations/openclaw/plugin/src/openclaw_smart/internal_call.py +75 -0
  108. package/plugin/vendor/reflexio/reflexio/integrations/openclaw/plugin/src/openclaw_smart/oc_cite.py +196 -0
  109. package/plugin/vendor/reflexio/reflexio/integrations/openclaw/plugin/src/openclaw_smart/optimizer_assistant.py +272 -0
  110. package/plugin/vendor/reflexio/reflexio/integrations/openclaw/plugin/src/openclaw_smart/publish.py +96 -0
  111. package/plugin/vendor/reflexio/reflexio/integrations/openclaw/plugin/src/openclaw_smart/query_compose.py +66 -0
  112. package/plugin/vendor/reflexio/reflexio/integrations/openclaw/plugin/src/openclaw_smart/reflexio_adapter.py +336 -0
  113. package/plugin/vendor/reflexio/reflexio/integrations/openclaw/plugin/src/openclaw_smart/runtime.py +47 -0
  114. package/plugin/vendor/reflexio/reflexio/integrations/openclaw/plugin/src/openclaw_smart/stall_banner.py +61 -0
  115. package/plugin/vendor/reflexio/reflexio/integrations/openclaw/plugin/src/openclaw_smart/state.py +323 -0
  116. package/plugin/vendor/reflexio/reflexio/integrations/openclaw/plugin/tests/__init__.py +0 -0
  117. package/plugin/vendor/reflexio/reflexio/integrations/openclaw/plugin/tests/integration/__init__.py +0 -0
  118. package/plugin/vendor/reflexio/reflexio/integrations/openclaw/plugin/tests/integration/test_e2e_session_loop.py +190 -0
  119. package/plugin/vendor/reflexio/reflexio/integrations/openclaw/plugin/tests/integration/test_publish_to_local_reflexio_integration.py +112 -0
  120. package/plugin/vendor/reflexio/reflexio/integrations/openclaw/plugin/tests/integration/test_recursion_guard_integration.py +86 -0
  121. package/plugin/vendor/reflexio/reflexio/integrations/openclaw/plugin/tests/integration/test_search_inject_integration.py +144 -0
  122. package/plugin/vendor/reflexio/reflexio/integrations/openclaw/plugin/tests/test_cli.py +184 -0
  123. package/plugin/vendor/reflexio/reflexio/integrations/openclaw/plugin/tests/test_events_after_tool_call.py +142 -0
  124. package/plugin/vendor/reflexio/reflexio/integrations/openclaw/plugin/tests/test_events_agent_end.py +233 -0
  125. package/plugin/vendor/reflexio/reflexio/integrations/openclaw/plugin/tests/test_events_before_prompt_build.py +116 -0
  126. package/plugin/vendor/reflexio/reflexio/integrations/openclaw/plugin/tests/test_events_before_tool_call.py +35 -0
  127. package/plugin/vendor/reflexio/reflexio/integrations/openclaw/plugin/tests/test_events_session_end.py +47 -0
  128. package/plugin/vendor/reflexio/reflexio/integrations/openclaw/plugin/tests/test_events_session_start.py +109 -0
  129. package/plugin/vendor/reflexio/reflexio/integrations/openclaw/plugin/tests/test_hook_dispatch.py +117 -0
  130. package/plugin/vendor/reflexio/reflexio/integrations/openclaw/plugin/tests/test_ids.py +41 -0
  131. package/plugin/vendor/reflexio/reflexio/integrations/openclaw/plugin/tests/test_internal_call.py +50 -0
  132. package/plugin/vendor/reflexio/reflexio/integrations/openclaw/plugin/tests/test_oc_cite.py +88 -0
  133. package/plugin/vendor/reflexio/reflexio/integrations/openclaw/plugin/tests/test_publish.py +48 -0
  134. package/plugin/vendor/reflexio/reflexio/integrations/openclaw/plugin/tests/test_query_compose.py +48 -0
  135. package/plugin/vendor/reflexio/reflexio/integrations/openclaw/plugin/tests/test_reflexio_adapter.py +188 -0
  136. package/plugin/vendor/reflexio/reflexio/integrations/openclaw/plugin/tests/test_runtime.py +41 -0
  137. package/plugin/vendor/reflexio/reflexio/integrations/openclaw/plugin/tests/test_state.py +235 -0
  138. package/plugin/vendor/reflexio/reflexio/integrations/openclaw/plugin/tests-ts/__mocks__/plugin-entry-stub.ts +6 -0
  139. package/plugin/vendor/reflexio/reflexio/integrations/openclaw/plugin/tests-ts/test_npm_cli.test.ts +56 -0
  140. package/plugin/vendor/reflexio/reflexio/integrations/openclaw/plugin/tests-ts/test_shim_dispatch.test.ts +171 -0
  141. package/plugin/vendor/reflexio/reflexio/integrations/openclaw/plugin/tsconfig.build.json +18 -0
  142. package/plugin/vendor/reflexio/reflexio/integrations/openclaw/{tsconfig.json → plugin/tsconfig.json} +2 -2
  143. package/plugin/vendor/reflexio/reflexio/integrations/openclaw/plugin/uv.lock +3835 -0
  144. package/plugin/vendor/reflexio/reflexio/integrations/openclaw/plugin/vitest.config.ts +18 -0
  145. package/plugin/vendor/reflexio/reflexio/lib/_agent_playbook.py +246 -10
  146. package/plugin/vendor/reflexio/reflexio/lib/_base.py +15 -0
  147. package/plugin/vendor/reflexio/reflexio/lib/_config.py +11 -4
  148. package/plugin/vendor/reflexio/reflexio/lib/_generation.py +17 -5
  149. package/plugin/vendor/reflexio/reflexio/lib/_lineage_parity_readers.py +187 -0
  150. package/plugin/vendor/reflexio/reflexio/lib/_profiles.py +224 -21
  151. package/plugin/vendor/reflexio/reflexio/lib/_reflection.py +1 -1
  152. package/plugin/vendor/reflexio/reflexio/lib/_search.py +52 -25
  153. package/plugin/vendor/reflexio/reflexio/lib/_user_playbook.py +11 -1
  154. package/plugin/vendor/reflexio/reflexio/models/api_schema/domain/__init__.py +1 -0
  155. package/plugin/vendor/reflexio/reflexio/models/api_schema/domain/entities.py +127 -38
  156. package/plugin/vendor/reflexio/reflexio/models/api_schema/domain/enums.py +4 -0
  157. package/plugin/vendor/reflexio/reflexio/models/api_schema/domain/governance.py +117 -0
  158. package/plugin/vendor/reflexio/reflexio/models/api_schema/eval_overview_schema.py +64 -41
  159. package/plugin/vendor/reflexio/reflexio/models/api_schema/internal_schema.py +19 -0
  160. package/plugin/vendor/reflexio/reflexio/models/api_schema/pending_tool_call_schema.py +12 -5
  161. package/plugin/vendor/reflexio/reflexio/models/api_schema/retriever_schema.py +97 -3
  162. package/plugin/vendor/reflexio/reflexio/models/api_schema/ui/converters.py +5 -1
  163. package/plugin/vendor/reflexio/reflexio/models/api_schema/ui/entities.py +5 -1
  164. package/plugin/vendor/reflexio/reflexio/models/config_schema.py +104 -7
  165. package/plugin/vendor/reflexio/reflexio/models/structured_output.py +148 -0
  166. package/plugin/vendor/reflexio/reflexio/server/OVERVIEW.md +3 -3
  167. package/plugin/vendor/reflexio/reflexio/server/README.md +58 -33
  168. package/plugin/vendor/reflexio/reflexio/server/__init__.py +29 -5
  169. package/plugin/vendor/reflexio/reflexio/server/__main__.py +2 -2
  170. package/plugin/vendor/reflexio/reflexio/server/api.py +899 -103
  171. package/plugin/vendor/reflexio/reflexio/server/api_endpoints/precondition_checks.py +7 -0
  172. package/plugin/vendor/reflexio/reflexio/server/api_endpoints/request_context.py +4 -2
  173. package/plugin/vendor/reflexio/reflexio/server/auth.py +90 -0
  174. package/plugin/vendor/reflexio/reflexio/server/billing_meter.py +173 -0
  175. package/plugin/vendor/reflexio/reflexio/server/billing_signals.py +64 -0
  176. package/plugin/vendor/reflexio/reflexio/server/cache/reflexio_cache.py +46 -16
  177. package/plugin/vendor/reflexio/reflexio/server/env_utils.py +65 -0
  178. package/plugin/vendor/reflexio/reflexio/server/extensions.py +213 -0
  179. package/plugin/vendor/reflexio/reflexio/server/llm/embedding_service.py +19 -4
  180. package/plugin/vendor/reflexio/reflexio/server/llm/litellm_client.py +264 -77
  181. package/plugin/vendor/reflexio/reflexio/server/llm/llm_utils.py +69 -7
  182. package/plugin/vendor/reflexio/reflexio/server/llm/model_defaults.py +4 -4
  183. package/plugin/vendor/reflexio/reflexio/server/llm/providers/claude_code_provider.py +1 -1
  184. package/plugin/vendor/reflexio/reflexio/server/llm/providers/embedding_service_provider.py +75 -16
  185. package/plugin/vendor/reflexio/reflexio/server/llm/providers/openclaw_provider.py +280 -0
  186. package/plugin/vendor/reflexio/reflexio/server/llm/rerank/cross_encoder_reranker.py +12 -1
  187. package/plugin/vendor/reflexio/reflexio/server/llm/token_accounting.py +48 -0
  188. package/plugin/vendor/reflexio/reflexio/server/llm/tools.py +107 -17
  189. package/plugin/vendor/reflexio/reflexio/server/operation_limiter.py +360 -4
  190. package/plugin/vendor/reflexio/reflexio/server/prompt/prompt_bank/memory_reflection/v1.6.0.prompt.md +1 -1
  191. package/plugin/vendor/reflexio/reflexio/server/prompt/prompt_bank/memory_reflection/v1.7.0.prompt.md +85 -0
  192. package/plugin/vendor/reflexio/reflexio/server/prompt/prompt_bank/playbook_aggregation/v2.2.0.prompt.md +1 -1
  193. package/plugin/vendor/reflexio/reflexio/server/prompt/prompt_bank/playbook_aggregation/v2.3.0.prompt.md +253 -0
  194. package/plugin/vendor/reflexio/reflexio/server/prompt/prompt_bank/playbook_consolidation/v2.3.2.prompt.md +1 -1
  195. package/plugin/vendor/reflexio/reflexio/server/prompt/prompt_bank/playbook_consolidation/v2.3.3.prompt.md +74 -0
  196. package/plugin/vendor/reflexio/reflexio/server/prompt/prompt_bank/playbook_extraction_context/v4.2.3.prompt.md +1 -1
  197. package/plugin/vendor/reflexio/reflexio/server/prompt/prompt_bank/playbook_extraction_context/v4.3.0.prompt.md +266 -0
  198. package/plugin/vendor/reflexio/reflexio/server/prompt/prompt_bank/playbook_extraction_context/v4.4.0.prompt.md +278 -0
  199. package/plugin/vendor/reflexio/reflexio/server/prompt/prompt_bank/playbook_extraction_context_expert/v3.3.0.prompt.md +1 -1
  200. package/plugin/vendor/reflexio/reflexio/server/prompt/prompt_bank/playbook_extraction_context_expert/v3.4.0.prompt.md +137 -0
  201. package/plugin/vendor/reflexio/reflexio/server/prompt/prompt_bank/playbook_extraction_main/v1.2.0.prompt.md +1 -1
  202. package/plugin/vendor/reflexio/reflexio/server/prompt/prompt_bank/playbook_extraction_main/v1.3.0.prompt.md +34 -0
  203. package/plugin/vendor/reflexio/reflexio/server/prompt/prompt_bank/profile_update_instruction_start/v1.0.0.prompt.md +5 -12
  204. package/plugin/vendor/reflexio/reflexio/server/prompt/prompt_bank/profile_update_instruction_start/v1.1.0.prompt.md +5 -12
  205. package/plugin/vendor/reflexio/reflexio/server/prompt/prompt_bank/profile_update_instruction_start/v1.2.0.prompt.md +155 -0
  206. package/plugin/vendor/reflexio/reflexio/server/prompt/prompt_bank/tagging/v1.0.0.prompt.md +23 -0
  207. package/plugin/vendor/reflexio/reflexio/server/prompt/prompt_manager.py +89 -32
  208. package/plugin/vendor/reflexio/reflexio/server/services/README.md +28 -11
  209. package/plugin/vendor/reflexio/reflexio/server/services/agent_success_evaluation/README.md +22 -0
  210. package/plugin/vendor/reflexio/reflexio/server/services/agent_success_evaluation/__init__.py +1 -0
  211. package/plugin/vendor/reflexio/reflexio/server/services/agent_success_evaluation/agent_success_evaluation_constants.py +4 -2
  212. package/plugin/vendor/reflexio/reflexio/server/services/agent_success_evaluation/agent_success_evaluation_utils.py +1 -0
  213. package/plugin/vendor/reflexio/reflexio/server/services/agent_success_evaluation/components/__init__.py +7 -0
  214. package/plugin/vendor/reflexio/reflexio/server/services/agent_success_evaluation/{agent_success_evaluator.py → components/evaluator.py} +3 -16
  215. package/plugin/vendor/reflexio/reflexio/server/services/agent_success_evaluation/regen_jobs.py +28 -72
  216. package/plugin/vendor/reflexio/reflexio/server/services/agent_success_evaluation/{group_evaluation_runner.py → runner.py} +12 -10
  217. package/plugin/vendor/reflexio/reflexio/server/services/agent_success_evaluation/{delayed_group_evaluator.py → scheduler.py} +10 -2
  218. package/plugin/vendor/reflexio/reflexio/server/services/agent_success_evaluation/{agent_success_evaluation_service.py → service.py} +36 -1
  219. package/plugin/vendor/reflexio/reflexio/server/services/base_generation_service.py +160 -0
  220. package/plugin/vendor/reflexio/reflexio/server/services/configurator/base_configurator.py +5 -0
  221. package/plugin/vendor/reflexio/reflexio/server/services/deduplication_utils.py +1 -1
  222. package/plugin/vendor/reflexio/reflexio/server/services/evaluation_overview/README.md +9 -0
  223. package/plugin/vendor/reflexio/reflexio/server/services/evaluation_overview/components/__init__.py +0 -0
  224. package/plugin/vendor/reflexio/reflexio/server/services/evaluation_overview/{rule_attribution.py → components/rule_attribution.py} +10 -9
  225. package/plugin/vendor/reflexio/reflexio/server/services/evaluation_overview/eval_sampler.py +15 -24
  226. package/plugin/vendor/reflexio/reflexio/server/services/evaluation_overview/service.py +238 -164
  227. package/plugin/vendor/reflexio/reflexio/server/services/extraction/README.md +31 -0
  228. package/plugin/vendor/reflexio/reflexio/server/services/extraction/outcome.py +13 -3
  229. package/plugin/vendor/reflexio/reflexio/server/services/extraction/pending_tool_call_dispatch.py +3 -2
  230. package/plugin/vendor/reflexio/reflexio/server/services/extraction/resumable_agent.py +32 -118
  231. package/plugin/vendor/reflexio/reflexio/server/services/extraction/resume_worker.py +47 -16
  232. package/plugin/vendor/reflexio/reflexio/server/services/generation_service.py +105 -23
  233. package/plugin/vendor/reflexio/reflexio/server/services/governance/config.py +52 -0
  234. package/plugin/vendor/reflexio/reflexio/server/services/governance/service.py +378 -0
  235. package/plugin/vendor/reflexio/reflexio/server/services/governance/subject_refs.py +34 -0
  236. package/plugin/vendor/reflexio/reflexio/server/services/lineage/__init__.py +0 -0
  237. package/plugin/vendor/reflexio/reflexio/server/services/lineage/gc_scheduler.py +219 -0
  238. package/plugin/vendor/reflexio/reflexio/server/services/lineage/resolve.py +98 -0
  239. package/plugin/vendor/reflexio/reflexio/server/services/operation_state_utils.py +26 -0
  240. package/plugin/vendor/reflexio/reflexio/server/services/playbook/README.md +18 -11
  241. package/plugin/vendor/reflexio/reflexio/server/services/playbook/aggregation_prompt_processing.py +100 -0
  242. package/plugin/vendor/reflexio/reflexio/server/services/playbook/components/__init__.py +5 -0
  243. package/plugin/vendor/reflexio/reflexio/server/services/playbook/{playbook_aggregator.py → components/aggregator.py} +334 -178
  244. package/plugin/vendor/reflexio/reflexio/server/services/playbook/{playbook_consolidator.py → components/consolidator.py} +168 -62
  245. package/plugin/vendor/reflexio/reflexio/server/services/playbook/{playbook_extractor.py → components/extractor.py} +5 -1
  246. package/plugin/vendor/reflexio/reflexio/server/services/playbook/playbook_edit_apply.py +74 -0
  247. package/plugin/vendor/reflexio/reflexio/server/services/playbook/playbook_service_utils.py +33 -3
  248. package/plugin/vendor/reflexio/reflexio/server/services/playbook/{playbook_generation_service.py → service.py} +121 -19
  249. package/plugin/vendor/reflexio/reflexio/server/services/playbook_optimizer/README.md +10 -0
  250. package/plugin/vendor/reflexio/reflexio/server/services/playbook_optimizer/models.py +2 -1
  251. package/plugin/vendor/reflexio/reflexio/server/services/playbook_optimizer/optimizer.py +178 -30
  252. package/plugin/vendor/reflexio/reflexio/server/services/pre_retrieval/README.md +9 -0
  253. package/plugin/vendor/reflexio/reflexio/server/services/profile/components/__init__.py +17 -0
  254. package/plugin/vendor/reflexio/reflexio/server/services/profile/{profile_deduplicator.py → components/consolidator.py} +7 -6
  255. package/plugin/vendor/reflexio/reflexio/server/services/profile/{profile_extractor.py → components/extractor.py} +31 -22
  256. package/plugin/vendor/reflexio/reflexio/server/services/profile/profile_generation_service_utils.py +6 -13
  257. package/plugin/vendor/reflexio/reflexio/server/services/profile/{profile_generation_service.py → service.py} +32 -50
  258. package/plugin/vendor/reflexio/reflexio/server/services/reflection/__init__.py +4 -8
  259. package/plugin/vendor/reflexio/reflexio/server/services/reflection/components/__init__.py +7 -0
  260. package/plugin/vendor/reflexio/reflexio/server/services/reflection/reflection_service_utils.py +12 -1
  261. package/plugin/vendor/reflexio/reflexio/server/services/reflection/{reflection_service.py → service.py} +36 -16
  262. package/plugin/vendor/reflexio/reflexio/server/services/retrieval/recency.py +211 -0
  263. package/plugin/vendor/reflexio/reflexio/server/services/retrieval/relevance_floor.py +99 -6
  264. package/plugin/vendor/reflexio/reflexio/server/services/service_utils.py +36 -1
  265. package/plugin/vendor/reflexio/reflexio/server/services/shadow_comparison/README.md +8 -0
  266. package/plugin/vendor/reflexio/reflexio/server/services/shadow_comparison/outcome.py +1 -2
  267. package/plugin/vendor/reflexio/reflexio/server/services/storage/error.py +31 -0
  268. package/plugin/vendor/reflexio/reflexio/server/services/storage/governance_validation.py +681 -0
  269. package/plugin/vendor/reflexio/reflexio/server/services/storage/retention.py +9 -9
  270. package/plugin/vendor/reflexio/reflexio/server/services/storage/sqlite_storage/__init__.py +16 -2
  271. package/plugin/vendor/reflexio/reflexio/server/services/storage/sqlite_storage/_agent_run.py +4 -1
  272. package/plugin/vendor/reflexio/reflexio/server/services/storage/sqlite_storage/_base.py +644 -197
  273. package/plugin/vendor/reflexio/reflexio/server/services/storage/sqlite_storage/_extras.py +106 -119
  274. package/plugin/vendor/reflexio/reflexio/server/services/storage/sqlite_storage/_governance.py +1965 -0
  275. package/plugin/vendor/reflexio/reflexio/server/services/storage/sqlite_storage/_lineage.py +630 -0
  276. package/plugin/vendor/reflexio/reflexio/server/services/storage/sqlite_storage/_operations.py +38 -6
  277. package/plugin/vendor/reflexio/reflexio/server/services/storage/sqlite_storage/_playbook.py +31 -1369
  278. package/plugin/vendor/reflexio/reflexio/server/services/storage/sqlite_storage/_profiles.py +756 -210
  279. package/plugin/vendor/reflexio/reflexio/server/services/storage/sqlite_storage/_requests.py +148 -34
  280. package/plugin/vendor/reflexio/reflexio/server/services/storage/sqlite_storage/_shadow_verdicts.py +23 -9
  281. package/plugin/vendor/reflexio/reflexio/server/services/storage/sqlite_storage/_stall_state.py +5 -2
  282. package/plugin/vendor/reflexio/reflexio/server/services/storage/sqlite_storage/playbook/__init__.py +13 -0
  283. package/plugin/vendor/reflexio/reflexio/server/services/storage/sqlite_storage/playbook/_agent.py +952 -0
  284. package/plugin/vendor/reflexio/reflexio/server/services/storage/sqlite_storage/playbook/_eval_results.py +189 -0
  285. package/plugin/vendor/reflexio/reflexio/server/services/storage/sqlite_storage/playbook/_optimization.py +247 -0
  286. package/plugin/vendor/reflexio/reflexio/server/services/storage/sqlite_storage/playbook/_source_linkage.py +145 -0
  287. package/plugin/vendor/reflexio/reflexio/server/services/storage/sqlite_storage/playbook/_user.py +838 -0
  288. package/plugin/vendor/reflexio/reflexio/server/services/storage/storage_base/__init__.py +150 -23
  289. package/plugin/vendor/reflexio/reflexio/server/services/storage/storage_base/_agent_run.py +2 -8
  290. package/plugin/vendor/reflexio/reflexio/server/services/storage/storage_base/_base.py +5 -0
  291. package/plugin/vendor/reflexio/reflexio/server/services/storage/storage_base/_extras.py +40 -57
  292. package/plugin/vendor/reflexio/reflexio/server/services/storage/storage_base/_governance.py +148 -0
  293. package/plugin/vendor/reflexio/reflexio/server/services/storage/storage_base/_lineage.py +215 -0
  294. package/plugin/vendor/reflexio/reflexio/server/services/storage/storage_base/_operations.py +20 -0
  295. package/plugin/vendor/reflexio/reflexio/server/services/storage/storage_base/_playbook.py +4 -677
  296. package/plugin/vendor/reflexio/reflexio/server/services/storage/storage_base/_profiles.py +134 -1
  297. package/plugin/vendor/reflexio/reflexio/server/services/storage/storage_base/_requests.py +62 -0
  298. package/plugin/vendor/reflexio/reflexio/server/services/storage/storage_base/_retrieval_log.py +51 -0
  299. package/plugin/vendor/reflexio/reflexio/server/services/storage/storage_base/_shadow_verdicts.py +17 -0
  300. package/plugin/vendor/reflexio/reflexio/server/services/storage/storage_base/playbook/__init__.py +13 -0
  301. package/plugin/vendor/reflexio/reflexio/server/services/storage/storage_base/playbook/_agent.py +365 -0
  302. package/plugin/vendor/reflexio/reflexio/server/services/storage/storage_base/playbook/_eval_results.py +124 -0
  303. package/plugin/vendor/reflexio/reflexio/server/services/storage/storage_base/playbook/_optimization.py +85 -0
  304. package/plugin/vendor/reflexio/reflexio/server/services/storage/storage_base/playbook/_source_linkage.py +47 -0
  305. package/plugin/vendor/reflexio/reflexio/server/services/storage/storage_base/playbook/_user.py +333 -0
  306. package/plugin/vendor/reflexio/reflexio/server/services/tagging/README.md +8 -0
  307. package/plugin/vendor/reflexio/reflexio/server/services/tagging/__init__.py +1 -0
  308. package/plugin/vendor/reflexio/reflexio/server/services/tagging/service.py +200 -0
  309. package/plugin/vendor/reflexio/reflexio/server/services/tagging/tagging_scheduler.py +149 -0
  310. package/plugin/vendor/reflexio/reflexio/server/services/unified_search_service.py +372 -34
  311. package/plugin/vendor/reflexio/reflexio/server/site_var/README.md +2 -1
  312. package/plugin/vendor/reflexio/reflexio/server/site_var/feature_flags.py +120 -1
  313. package/plugin/vendor/reflexio/reflexio/server/site_var/site_var_sources/feature_flags.json +4 -0
  314. package/plugin/vendor/reflexio/reflexio/server/site_var/site_var_sources/search_settings.json +5 -0
  315. package/plugin/vendor/reflexio/reflexio/server/tracing.py +30 -0
  316. package/plugin/vendor/reflexio/reflexio/server/usage_metrics.py +18 -0
  317. package/plugin/vendor/reflexio/reflexio/test_support/llm_mock.py +61 -26
  318. package/plugin/vendor/reflexio/reflexio/test_support/llm_model_registry.py +41 -4
  319. package/scripts/setup-claude-smart.sh +8 -3
  320. package/plugin/vendor/reflexio/reflexio/integrations/openclaw/TESTING.md +0 -517
  321. package/plugin/vendor/reflexio/reflexio/integrations/openclaw/hook/handler.js +0 -473
  322. package/plugin/vendor/reflexio/reflexio/integrations/openclaw/package-lock.json +0 -2156
  323. package/plugin/vendor/reflexio/reflexio/integrations/openclaw/package.json +0 -18
  324. package/plugin/vendor/reflexio/reflexio/integrations/openclaw/plugin/hook/handler.ts +0 -241
  325. package/plugin/vendor/reflexio/reflexio/integrations/openclaw/plugin/hook/setup.ts +0 -140
  326. package/plugin/vendor/reflexio/reflexio/integrations/openclaw/plugin/lib/publish.ts +0 -113
  327. package/plugin/vendor/reflexio/reflexio/integrations/openclaw/plugin/lib/search.ts +0 -52
  328. package/plugin/vendor/reflexio/reflexio/integrations/openclaw/plugin/lib/server.ts +0 -103
  329. package/plugin/vendor/reflexio/reflexio/integrations/openclaw/plugin/lib/sqlite-buffer.ts +0 -156
  330. package/plugin/vendor/reflexio/reflexio/integrations/openclaw/plugin/lib/user-id.ts +0 -134
  331. package/plugin/vendor/reflexio/reflexio/integrations/openclaw/plugin/rules/reflexio.md +0 -24
  332. package/plugin/vendor/reflexio/reflexio/integrations/openclaw/publish_clawhub.sh +0 -278
  333. package/plugin/vendor/reflexio/reflexio/integrations/openclaw/references/HOOK.md +0 -164
  334. package/plugin/vendor/reflexio/reflexio/integrations/openclaw/scripts/install.sh +0 -36
  335. package/plugin/vendor/reflexio/reflexio/integrations/openclaw/scripts/uninstall.sh +0 -35
  336. package/plugin/vendor/reflexio/reflexio/integrations/openclaw/tests/publish.test.ts +0 -27
  337. package/plugin/vendor/reflexio/reflexio/integrations/openclaw/tests/search.test.ts +0 -31
  338. package/plugin/vendor/reflexio/reflexio/integrations/openclaw/tests/server.test.ts +0 -42
  339. package/plugin/vendor/reflexio/reflexio/integrations/openclaw/tests/setup.test.ts +0 -49
  340. package/plugin/vendor/reflexio/reflexio/integrations/openclaw/tests/sqlite-buffer.test.ts +0 -91
  341. package/plugin/vendor/reflexio/reflexio/integrations/openclaw/tests/user-id.test.ts +0 -50
  342. package/plugin/vendor/reflexio/reflexio/integrations/openclaw/vitest.config.ts +0 -13
  343. package/plugin/vendor/reflexio/reflexio/server/_auth.py +0 -25
  344. package/plugin/vendor/reflexio/reflexio/server/services/evaluation_overview/group_aggregation.py +0 -192
  345. package/plugin/vendor/reflexio/reflexio/server/services/extraction/invariants.py +0 -303
  346. package/plugin/vendor/reflexio/reflexio/server/services/extraction/plan.py +0 -138
  347. package/plugin/vendor/reflexio/reflexio/server/services/extraction/tools.py +0 -1125
  348. /package/plugin/vendor/reflexio/reflexio/integrations/{__init__.py → openclaw/plugin/src/openclaw_smart/__init__.py} +0 -0
  349. /package/plugin/vendor/reflexio/reflexio/integrations/openclaw/{types → plugin/types}/openclaw.d.ts +0 -0
  350. /package/plugin/vendor/reflexio/reflexio/server/services/evaluation_overview/{distribution.py → components/distribution.py} +0 -0
  351. /package/plugin/vendor/reflexio/reflexio/server/services/evaluation_overview/{hero_state.py → components/hero_state.py} +0 -0
  352. /package/plugin/vendor/reflexio/reflexio/server/services/evaluation_overview/{shadow_aggregation.py → components/shadow_aggregation.py} +0 -0
  353. /package/plugin/vendor/reflexio/reflexio/server/services/reflection/{reflection_extractor.py → components/extractor.py} +0 -0
@@ -2,6 +2,7 @@
2
2
 
3
3
  import logging
4
4
  import sqlite3
5
+ import uuid
5
6
  from concurrent.futures import ThreadPoolExecutor
6
7
  from typing import Any
7
8
 
@@ -24,6 +25,7 @@ from reflexio.server.llm.providers.embedding_service_provider import (
24
25
  )
25
26
 
26
27
  from ._base import (
28
+ _TOMBSTONE_STATUS_VALUES,
27
29
  SQLiteStorageBase,
28
30
  _build_status_sql,
29
31
  _effective_search_mode,
@@ -37,6 +39,44 @@ from ._base import (
37
39
  _true_rrf_merge,
38
40
  _vector_rank_rows,
39
41
  )
42
+ from ._lineage import _GC_ELIGIBLE_STATUSES, _append_event_stmt
43
+
44
+
45
+ def _emit_hard_delete_profile(
46
+ conn: sqlite3.Connection,
47
+ *,
48
+ org_id: str,
49
+ entity_id: str,
50
+ request_id: str,
51
+ actor: str = "api",
52
+ ) -> None:
53
+ """Emit a single hard_delete lineage event for a profile entity."""
54
+ _append_event_stmt(
55
+ conn,
56
+ org_id=org_id,
57
+ entity_type="profile",
58
+ entity_id=entity_id,
59
+ op="hard_delete",
60
+ prov="wasInvalidatedBy",
61
+ source_ids=[],
62
+ actor=actor,
63
+ request_id=request_id,
64
+ reason="erasure",
65
+ )
66
+
67
+
68
+ def _build_tags_sql(alias: str, tags: list[str] | None) -> tuple[str, list[Any]]:
69
+ if not tags:
70
+ return "", []
71
+ placeholders = ",".join("?" for _ in tags)
72
+ return (
73
+ f"EXISTS (SELECT 1 FROM json_each({alias}.tags) WHERE value IN ({placeholders}))",
74
+ list(tags),
75
+ )
76
+
77
+
78
+ def _escape_like_pattern(value: str) -> str:
79
+ return value.replace("\\", "\\\\").replace("%", "\\%").replace("_", "\\_")
40
80
 
41
81
 
42
82
  class ProfileMixin:
@@ -45,6 +85,7 @@ class ProfileMixin:
45
85
  # Type hints for instance attributes/methods provided by SQLiteStorageBase via MRO
46
86
  _lock: Any
47
87
  conn: sqlite3.Connection
88
+ org_id: str
48
89
  _execute: Any
49
90
  _fetchone: Any
50
91
  _fetchall: Any
@@ -57,25 +98,93 @@ class ProfileMixin:
57
98
  _fts_delete_profile: Any
58
99
  _vec_upsert: Any
59
100
  _vec_delete: Any
101
+ _delete_profile_search_rows: Any
102
+ _delete_in_chunks: Any
60
103
  _has_sqlite_vec: bool
61
104
  llm_client: Any
62
105
  embedding_model_name: str
63
106
  embedding_dimensions: int
107
+ _subject_ref_for_user_id: Any
108
+ _assert_subject_writable_locked: Any
64
109
 
65
110
  # ------------------------------------------------------------------
66
111
  # CRUD — Profiles
67
112
  # ------------------------------------------------------------------
68
113
 
114
+ def _subject_ref_from_profile_row(self, row: sqlite3.Row) -> str:
115
+ subject_ref = row["governance_subject_ref"]
116
+ return (
117
+ str(subject_ref)
118
+ if subject_ref
119
+ else self._subject_ref_for_user_id(row["user_id"])
120
+ )
121
+
122
+ def _assert_profile_writable_locked(
123
+ self,
124
+ profile_id: str,
125
+ *,
126
+ user_id: str | None = None,
127
+ ) -> sqlite3.Row | None:
128
+ if user_id is None:
129
+ row = self.conn.execute(
130
+ "SELECT user_id, governance_subject_ref FROM profiles WHERE profile_id = ?",
131
+ (profile_id,),
132
+ ).fetchone()
133
+ else:
134
+ row = self.conn.execute(
135
+ "SELECT user_id, governance_subject_ref FROM profiles WHERE profile_id = ? AND user_id = ?",
136
+ (profile_id, user_id),
137
+ ).fetchone()
138
+ if row is None:
139
+ return None
140
+ self._assert_subject_writable_locked(self._subject_ref_from_profile_row(row))
141
+ return row
142
+
69
143
  @SQLiteStorageBase.handle_exceptions
70
144
  def get_all_profiles(
71
145
  self,
72
146
  limit: int = 100,
73
147
  status_filter: list[Status | None] | None = None,
148
+ user_id: str | None = None,
149
+ profile_id: str | None = None,
150
+ query: str | None = None,
151
+ source: str | None = None,
152
+ profile_time_to_live: str | None = None,
153
+ start_time: int | None = None,
154
+ end_time: int | None = None,
74
155
  ) -> list[UserProfile]:
75
156
  if status_filter is None:
76
157
  status_filter = [None]
77
158
  frag, params = _build_status_sql(status_filter)
78
- sql = f"SELECT * FROM profiles WHERE {frag} ORDER BY last_modified_timestamp DESC LIMIT ?"
159
+ conditions = [frag]
160
+ if user_id:
161
+ conditions.append("user_id = ?")
162
+ params.append(user_id)
163
+ if profile_id:
164
+ conditions.append("LOWER(profile_id) = LOWER(?)")
165
+ params.append(profile_id)
166
+ if query:
167
+ like = f"%{_escape_like_pattern(query.lower())}%"
168
+ conditions.append(
169
+ "(LOWER(content) LIKE ? ESCAPE '\\' OR LOWER(profile_id) LIKE ? ESCAPE '\\' OR LOWER(user_id) LIKE ? ESCAPE '\\')"
170
+ )
171
+ params.extend([like, like, like])
172
+ if source is not None:
173
+ conditions.append("source = ?")
174
+ params.append(source)
175
+ if profile_time_to_live:
176
+ conditions.append("profile_time_to_live = ?")
177
+ params.append(profile_time_to_live)
178
+ if start_time is not None:
179
+ conditions.append("last_modified_timestamp >= ?")
180
+ params.append(start_time)
181
+ if end_time is not None:
182
+ conditions.append("last_modified_timestamp <= ?")
183
+ params.append(end_time)
184
+ sql = (
185
+ f"SELECT * FROM profiles WHERE {' AND '.join(conditions)} "
186
+ "ORDER BY last_modified_timestamp DESC LIMIT ?"
187
+ )
79
188
  params.append(limit)
80
189
  return [_row_to_profile(r) for r in self._fetchall(sql, params)]
81
190
 
@@ -84,18 +193,54 @@ class ProfileMixin:
84
193
  self,
85
194
  user_id: str,
86
195
  status_filter: list[Status | None] | None = None,
196
+ tags: list[str] | None = None,
197
+ profile_id: str | None = None,
198
+ query: str | None = None,
199
+ source: str | None = None,
200
+ profile_time_to_live: str | None = None,
201
+ start_time: int | None = None,
202
+ end_time: int | None = None,
87
203
  ) -> list[UserProfile]:
88
204
  if status_filter is None:
89
205
  status_filter = [None]
90
206
  current_ts = _epoch_now()
91
207
  frag, params = _build_status_sql(status_filter)
92
- sql = f"SELECT * FROM profiles WHERE user_id = ? AND expiration_timestamp >= ? AND {frag}"
208
+ conditions = ["user_id = ?", "expiration_timestamp >= ?", frag]
93
209
  all_params: list[Any] = [user_id, current_ts, *params]
210
+ if profile_id:
211
+ conditions.append("LOWER(profile_id) = LOWER(?)")
212
+ all_params.append(profile_id)
213
+ if query:
214
+ like = f"%{_escape_like_pattern(query.lower())}%"
215
+ conditions.append(
216
+ "(LOWER(content) LIKE ? ESCAPE '\\' OR LOWER(profile_id) LIKE ? ESCAPE '\\' OR LOWER(user_id) LIKE ? ESCAPE '\\')"
217
+ )
218
+ all_params.extend([like, like, like])
219
+ if source is not None:
220
+ conditions.append("source = ?")
221
+ all_params.append(source)
222
+ if profile_time_to_live:
223
+ conditions.append("profile_time_to_live = ?")
224
+ all_params.append(profile_time_to_live)
225
+ if start_time is not None:
226
+ conditions.append("last_modified_timestamp >= ?")
227
+ all_params.append(start_time)
228
+ if end_time is not None:
229
+ conditions.append("last_modified_timestamp <= ?")
230
+ all_params.append(end_time)
231
+ tag_frag, tag_params = _build_tags_sql("profiles", tags)
232
+ if tag_frag:
233
+ conditions.append(tag_frag)
234
+ all_params.extend(tag_params)
235
+ sql = f"SELECT * FROM profiles WHERE {' AND '.join(conditions)}"
94
236
  return [_row_to_profile(r) for r in self._fetchall(sql, all_params)]
95
237
 
96
238
  @SQLiteStorageBase.handle_exceptions
97
239
  def add_user_profile(self, user_id: str, user_profiles: list[UserProfile]) -> None: # noqa: ARG002
98
240
  for profile in user_profiles:
241
+ subject_ref = self._subject_ref_for_user_id(profile.user_id)
242
+ with self._lock:
243
+ self._assert_subject_writable_locked(subject_ref)
99
244
  embedding_text = "\n".join([profile.content, str(profile.custom_features)])
100
245
  if self._should_expand_documents():
101
246
  with ThreadPoolExecutor(max_workers=2) as executor:
@@ -106,34 +251,48 @@ class ProfileMixin:
106
251
  else:
107
252
  profile.embedding = self._get_embedding(embedding_text)
108
253
  embedding = profile.embedding
109
- self._execute(
110
- """INSERT OR REPLACE INTO profiles
111
- (profile_id, user_id, content, last_modified_timestamp,
112
- generated_from_request_id, profile_time_to_live,
113
- expiration_timestamp, custom_features, embedding, source,
114
- status, extractor_names, expanded_terms,
115
- source_span, notes, reader_angle, created_at)
116
- VALUES (?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?)""",
117
- (
118
- profile.profile_id,
119
- profile.user_id,
120
- profile.content,
121
- profile.last_modified_timestamp,
122
- profile.generated_from_request_id,
123
- profile.profile_time_to_live.value,
124
- profile.expiration_timestamp,
125
- _json_dumps(profile.custom_features),
126
- _json_dumps(profile.embedding),
127
- profile.source,
128
- profile.status.value if profile.status else None,
129
- _json_dumps(profile.extractor_names),
130
- profile.expanded_terms,
131
- profile.source_span,
132
- profile.notes,
133
- profile.reader_angle,
134
- _iso_now(),
135
- ),
136
- )
254
+ with self._lock:
255
+ try:
256
+ self.conn.execute("BEGIN IMMEDIATE")
257
+ self._assert_subject_writable_locked(subject_ref)
258
+ self.conn.execute(
259
+ """INSERT OR REPLACE INTO profiles
260
+ (profile_id, user_id, content, last_modified_timestamp,
261
+ generated_from_request_id, profile_time_to_live,
262
+ expiration_timestamp, custom_features, embedding, source,
263
+ status, extractor_names, expanded_terms,
264
+ source_span, notes, reader_angle, tags, source_interaction_ids, created_at,
265
+ merged_into, superseded_by, governance_subject_ref)
266
+ VALUES (?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?)""",
267
+ (
268
+ profile.profile_id,
269
+ profile.user_id,
270
+ profile.content,
271
+ profile.last_modified_timestamp,
272
+ profile.generated_from_request_id,
273
+ profile.profile_time_to_live.value,
274
+ profile.expiration_timestamp,
275
+ _json_dumps(profile.custom_features),
276
+ _json_dumps(profile.embedding),
277
+ profile.source,
278
+ profile.status.value if profile.status else None,
279
+ _json_dumps(profile.extractor_names),
280
+ profile.expanded_terms,
281
+ profile.source_span,
282
+ profile.notes,
283
+ profile.reader_angle,
284
+ _json_dumps(profile.tags),
285
+ _json_dumps(profile.source_interaction_ids),
286
+ _iso_now(),
287
+ profile.merged_into,
288
+ profile.superseded_by,
289
+ subject_ref,
290
+ ),
291
+ )
292
+ self.conn.commit()
293
+ except Exception:
294
+ self.conn.rollback()
295
+ raise
137
296
  fts_parts = [profile.content or ""]
138
297
  if profile.custom_features:
139
298
  fts_parts.extend(str(v) for v in profile.custom_features.values() if v)
@@ -152,88 +311,188 @@ class ProfileMixin:
152
311
  def update_user_profile_by_id(
153
312
  self, user_id: str, profile_id: str, new_profile: UserProfile
154
313
  ) -> None:
155
- current_ts = _epoch_now()
156
- row = self._fetchone(
157
- "SELECT profile_id FROM profiles WHERE user_id = ? AND profile_id = ? AND expiration_timestamp >= ?",
158
- (user_id, profile_id, current_ts),
159
- )
160
- if not row:
161
- import logging
314
+ """Replace a profile's content in-place and emit a revise lineage event.
162
315
 
163
- logger = logging.getLogger(__name__)
164
- logger.warning("User profile not found for user id: %s", user_id)
165
- return
316
+ Each call generates a fresh request_id so every edit is a distinct audit
317
+ event (not collapsed by the idempotency key). The UPDATE, lineage event,
318
+ FTS sync, and vec sync are all executed inside a single lock acquisition;
319
+ self._lock is an RLock so the inner _fts_upsert_profile/_vec_upsert calls
320
+ that re-acquire it are safe.
321
+ """
322
+ current_ts = _epoch_now()
323
+ with self._lock:
324
+ row = self.conn.execute(
325
+ "SELECT user_id, governance_subject_ref FROM profiles WHERE user_id = ? AND profile_id = ? AND expiration_timestamp >= ?",
326
+ (user_id, profile_id, current_ts),
327
+ ).fetchone()
328
+ if not row:
329
+ logger.warning("User profile not found for user id: %s", user_id)
330
+ return
331
+ self._assert_subject_writable_locked(
332
+ self._subject_ref_from_profile_row(row)
333
+ )
166
334
  embedding = self._get_embedding(
167
335
  "\n".join([new_profile.content, str(new_profile.custom_features)])
168
336
  )
169
337
  new_profile.embedding = embedding
170
- self._execute(
171
- """UPDATE profiles SET content=?, last_modified_timestamp=?,
172
- generated_from_request_id=?, profile_time_to_live=?,
173
- expiration_timestamp=?, custom_features=?, embedding=?,
174
- source=?, status=?, extractor_names=?, expanded_terms=?,
175
- source_span=?, notes=?, reader_angle=?
176
- WHERE profile_id=?""",
177
- (
178
- new_profile.content,
179
- new_profile.last_modified_timestamp,
180
- new_profile.generated_from_request_id,
181
- new_profile.profile_time_to_live.value,
182
- new_profile.expiration_timestamp,
183
- _json_dumps(new_profile.custom_features),
184
- _json_dumps(new_profile.embedding),
185
- new_profile.source,
186
- new_profile.status.value if new_profile.status else None,
187
- _json_dumps(new_profile.extractor_names),
188
- new_profile.expanded_terms,
189
- new_profile.source_span,
190
- new_profile.notes,
191
- new_profile.reader_angle,
192
- profile_id,
193
- ),
194
- )
195
- fts_parts = [new_profile.content or ""]
196
- if new_profile.custom_features:
197
- fts_parts.extend(str(v) for v in new_profile.custom_features.values() if v)
198
- if new_profile.expanded_terms:
199
- fts_parts.append(new_profile.expanded_terms)
200
- self._fts_upsert_profile(profile_id, " ".join(fts_parts))
201
- rowid_row = self._fetchone(
202
- "SELECT rowid FROM profiles WHERE profile_id = ?", (profile_id,)
203
- )
204
- if rowid_row and embedding:
205
- self._vec_upsert("profiles_vec", rowid_row["rowid"], embedding)
338
+ with self._lock:
339
+ if (
340
+ self._assert_profile_writable_locked(profile_id, user_id=user_id)
341
+ is None
342
+ ):
343
+ logger.warning("User profile not found for user id: %s", user_id)
344
+ return
345
+ cur = self.conn.execute(
346
+ """UPDATE profiles SET content=?, last_modified_timestamp=?,
347
+ generated_from_request_id=?, profile_time_to_live=?,
348
+ expiration_timestamp=?, custom_features=?, embedding=?,
349
+ source=?, status=?, extractor_names=?, expanded_terms=?,
350
+ source_span=?, notes=?, reader_angle=?, tags=?, source_interaction_ids=?
351
+ WHERE profile_id=?""",
352
+ (
353
+ new_profile.content,
354
+ new_profile.last_modified_timestamp,
355
+ new_profile.generated_from_request_id,
356
+ new_profile.profile_time_to_live.value,
357
+ new_profile.expiration_timestamp,
358
+ _json_dumps(new_profile.custom_features),
359
+ _json_dumps(new_profile.embedding),
360
+ new_profile.source,
361
+ new_profile.status.value if new_profile.status else None,
362
+ _json_dumps(new_profile.extractor_names),
363
+ new_profile.expanded_terms,
364
+ new_profile.source_span,
365
+ new_profile.notes,
366
+ new_profile.reader_angle,
367
+ _json_dumps(new_profile.tags),
368
+ _json_dumps(new_profile.source_interaction_ids),
369
+ profile_id,
370
+ ),
371
+ )
372
+ if cur.rowcount > 0:
373
+ _append_event_stmt(
374
+ self.conn,
375
+ org_id=self.org_id,
376
+ entity_type="profile",
377
+ entity_id=str(profile_id),
378
+ op="revise",
379
+ prov="wasRevisionOf",
380
+ source_ids=[],
381
+ actor="api",
382
+ request_id=uuid.uuid4().hex,
383
+ reason="in-place update",
384
+ )
385
+ self.conn.commit()
386
+ fts_parts = [new_profile.content or ""]
387
+ if new_profile.custom_features:
388
+ fts_parts.extend(
389
+ str(v) for v in new_profile.custom_features.values() if v
390
+ )
391
+ if new_profile.expanded_terms:
392
+ fts_parts.append(new_profile.expanded_terms)
393
+ self._fts_upsert_profile(profile_id, " ".join(fts_parts))
394
+ rowid_row = self._fetchone(
395
+ "SELECT rowid FROM profiles WHERE profile_id = ?", (profile_id,)
396
+ )
397
+ if rowid_row and embedding:
398
+ self._vec_upsert("profiles_vec", rowid_row["rowid"], embedding)
399
+
400
+ @SQLiteStorageBase.handle_exceptions
401
+ def update_user_profile_tags(
402
+ self, user_id: str, profile_id: str, tags: list[str]
403
+ ) -> None:
404
+ with self._lock:
405
+ if (
406
+ self._assert_profile_writable_locked(profile_id, user_id=user_id)
407
+ is None
408
+ ):
409
+ return
410
+ self.conn.execute(
411
+ "UPDATE profiles SET tags=? WHERE user_id=? AND profile_id=?",
412
+ (_json_dumps(tags), user_id, profile_id),
413
+ )
414
+ self.conn.commit()
206
415
 
207
416
  @SQLiteStorageBase.handle_exceptions
208
417
  def delete_user_profile(self, request: DeleteUserProfileRequest) -> None:
209
- rowid_row = self._fetchone(
210
- "SELECT rowid FROM profiles WHERE profile_id = ?",
211
- (request.profile_id,),
212
- )
213
- self._fts_delete_profile(request.profile_id)
214
- if rowid_row:
215
- self._vec_delete("profiles_vec", rowid_row["rowid"])
216
- self._execute(
217
- "DELETE FROM profiles WHERE user_id = ? AND profile_id = ?",
218
- (request.user_id, request.profile_id),
219
- )
418
+ # Atomic: fts + vec + row + lineage in ONE lock/commit to prevent rowid reuse
419
+ # race. profiles uses implicit (reusable) rowid keyed by TEXT PK a cleanup
420
+ # running after commit could race with a concurrent INSERT reusing the freed
421
+ # rowid and delete the NEW profile's vec row. (#196)
422
+ with self._lock:
423
+ rowid_row = self.conn.execute(
424
+ "SELECT rowid FROM profiles WHERE user_id = ? AND profile_id = ?",
425
+ (request.user_id, request.profile_id),
426
+ ).fetchone()
427
+ if rowid_row is None:
428
+ return
429
+ self.conn.execute(
430
+ "DELETE FROM profiles_fts WHERE profile_id = ?",
431
+ (request.profile_id,),
432
+ )
433
+ if self._has_sqlite_vec and rowid_row:
434
+ self.conn.execute(
435
+ "DELETE FROM profiles_vec WHERE rowid = ?",
436
+ (rowid_row["rowid"],),
437
+ )
438
+ cur = self.conn.execute(
439
+ "DELETE FROM profiles WHERE user_id = ? AND profile_id = ?",
440
+ (request.user_id, request.profile_id),
441
+ )
442
+ if cur.rowcount > 0:
443
+ _emit_hard_delete_profile(
444
+ self.conn,
445
+ org_id=self.org_id,
446
+ entity_id=str(request.profile_id),
447
+ request_id=uuid.uuid4().hex,
448
+ )
449
+ self.conn.commit()
220
450
 
221
451
  @SQLiteStorageBase.handle_exceptions
222
452
  def delete_all_profiles_for_user(self, user_id: str) -> None:
223
- pids = [
224
- r["profile_id"]
225
- for r in self._fetchall(
226
- "SELECT profile_id FROM profiles WHERE user_id = ?", (user_id,)
227
- )
228
- ]
229
- for pid in pids:
230
- self._fts_delete_profile(pid)
231
- self._execute("DELETE FROM profiles WHERE user_id = ?", (user_id,))
453
+ # Atomic: fts + vec + row + lineage in ONE lock/commit — rowid reuse race
454
+ # prevention (see delete_user_profile comment, #196).
455
+ batch_request_id = uuid.uuid4().hex
456
+ with self._lock:
457
+ rows = self.conn.execute(
458
+ "SELECT rowid, profile_id FROM profiles WHERE user_id = ?", (user_id,)
459
+ ).fetchall()
460
+ if not rows:
461
+ return
462
+ pids = [r["profile_id"] for r in rows]
463
+ rowids = [r["rowid"] for r in rows]
464
+ self._delete_in_chunks("profiles_fts", "profile_id", pids)
465
+ if self._has_sqlite_vec and rowids:
466
+ self._delete_in_chunks("profiles_vec", "rowid", rowids)
467
+ self.conn.execute("DELETE FROM profiles WHERE user_id = ?", (user_id,))
468
+ for pid in pids:
469
+ _emit_hard_delete_profile(
470
+ self.conn,
471
+ org_id=self.org_id,
472
+ entity_id=str(pid),
473
+ request_id=batch_request_id,
474
+ )
475
+ self.conn.commit()
232
476
 
233
477
  @SQLiteStorageBase.handle_exceptions
234
478
  def delete_all_profiles(self) -> None:
479
+ # Also wipe profiles_vec (full-wipe variant of the rowid-race fix, #196).
480
+ batch_request_id = uuid.uuid4().hex
235
481
  with self._lock:
482
+ pids = [
483
+ r["profile_id"]
484
+ for r in self.conn.execute("SELECT profile_id FROM profiles").fetchall()
485
+ ]
486
+ for pid in pids:
487
+ _emit_hard_delete_profile(
488
+ self.conn,
489
+ org_id=self.org_id,
490
+ entity_id=str(pid),
491
+ request_id=batch_request_id,
492
+ )
236
493
  self.conn.execute("DELETE FROM profiles_fts")
494
+ if self._has_sqlite_vec:
495
+ self.conn.execute("DELETE FROM profiles_vec")
237
496
  self.conn.execute("DELETE FROM profiles")
238
497
  self.conn.commit()
239
498
 
@@ -251,25 +510,64 @@ class ProfileMixin:
251
510
  ) -> int:
252
511
  new_val = new_status.value if new_status else None
253
512
  now_ts = _epoch_now()
254
- params: list[Any] = [new_val, now_ts]
513
+ old_val_str = old_status.value if old_status else "None"
514
+ new_val_str = new_status.value if new_status else "None"
515
+ reason = f"{old_val_str}->{new_val_str}"
255
516
 
256
517
  if old_status is None or (
257
518
  hasattr(old_status, "value") and old_status.value is None
258
519
  ):
259
520
  where = "status IS NULL"
521
+ select_params: list[Any] = []
260
522
  else:
261
523
  where = "status = ?"
262
- params.append(old_status.value)
524
+ select_params = [old_status.value]
263
525
 
526
+ extra_params: list[Any] = []
264
527
  if user_ids is not None:
265
528
  placeholders = ",".join("?" for _ in user_ids)
266
529
  where += f" AND user_id IN ({placeholders})"
267
- params.extend(user_ids)
530
+ extra_params.extend(user_ids)
268
531
 
269
- cur = self._execute(
270
- f"UPDATE profiles SET status = ?, last_modified_timestamp = ? WHERE {where}",
271
- params,
272
- )
532
+ # Set retired_at = now when transitioning to a GC-eligible status; clear to NULL otherwise.
533
+ retired_at_val = now_ts if new_val in _GC_ELIGIBLE_STATUSES else None
534
+
535
+ batch_request_id = uuid.uuid4().hex
536
+ with self._lock:
537
+ affected = list(
538
+ self.conn.execute(
539
+ f"SELECT profile_id, user_id, governance_subject_ref FROM profiles WHERE {where}",
540
+ select_params + extra_params,
541
+ ).fetchall()
542
+ )
543
+ for row in affected:
544
+ self._assert_subject_writable_locked(
545
+ self._subject_ref_from_profile_row(row)
546
+ )
547
+ cur = self.conn.execute(
548
+ f"UPDATE profiles SET status = ?, last_modified_timestamp = ?, retired_at = ? WHERE {where}",
549
+ [new_val, now_ts, retired_at_val] + select_params + extra_params,
550
+ )
551
+ from_val = old_status.value if old_status else None
552
+ to_val = new_status.value if new_status else None
553
+ for row in affected:
554
+ pid = row["profile_id"]
555
+ _append_event_stmt(
556
+ self.conn,
557
+ org_id=self.org_id,
558
+ entity_type="profile",
559
+ entity_id=str(pid),
560
+ op="status_change",
561
+ prov="wasInvalidatedBy",
562
+ source_ids=[],
563
+ actor="api",
564
+ request_id=batch_request_id,
565
+ reason=reason,
566
+ from_status=from_val,
567
+ to_status=to_val,
568
+ status_namespace="lifecycle_status",
569
+ )
570
+ self.conn.commit()
273
571
  return cur.rowcount
274
572
 
275
573
  @SQLiteStorageBase.handle_exceptions
@@ -295,26 +593,214 @@ class ProfileMixin:
295
593
  return [_row_to_profile(r) for r in self._fetchall(sql, params)]
296
594
 
297
595
  @SQLiteStorageBase.handle_exceptions
298
- def archive_profile_by_id(self, user_id: str, profile_id: str) -> bool:
299
- cur = self._execute(
300
- "UPDATE profiles SET status = ?, last_modified_timestamp = ? "
301
- "WHERE profile_id = ? AND user_id = ? AND status IS NULL",
302
- (Status.ARCHIVED.value, _epoch_now(), profile_id, user_id),
596
+ def get_profile_by_id(
597
+ self, profile_id: str, *, include_tombstones: bool = False
598
+ ) -> UserProfile | None:
599
+ """Fetch a single profile by primary key.
600
+
601
+ Args:
602
+ profile_id: The profile's primary key.
603
+ include_tombstones: When False (default), MERGED/SUPERSEDED profiles
604
+ return None. Set to True for lineage resolution (resolve_current).
605
+
606
+ Returns:
607
+ The UserProfile if found and not filtered, otherwise None.
608
+ """
609
+ sql = "SELECT * FROM profiles WHERE profile_id = ?"
610
+ if not include_tombstones:
611
+ sql += " AND (status IS NULL OR status NOT IN (?, ?))"
612
+ row = self._fetchone(sql, (profile_id, *_TOMBSTONE_STATUS_VALUES))
613
+ else:
614
+ row = self._fetchone(sql, (profile_id,))
615
+ return _row_to_profile(row) if row else None
616
+
617
+ @SQLiteStorageBase.handle_exceptions
618
+ def get_distinct_generated_from_request_ids(self) -> list[str]:
619
+ """Return DISTINCT non-empty generated_from_request_id values, including tombstones.
620
+
621
+ Returns:
622
+ list[str]: Distinct non-empty ``generated_from_request_id`` values.
623
+ """
624
+ rows = self._fetchall(
625
+ "SELECT DISTINCT generated_from_request_id FROM profiles"
626
+ " WHERE generated_from_request_id IS NOT NULL"
627
+ " AND generated_from_request_id != ''",
628
+ (),
303
629
  )
630
+ return [row[0] for row in rows]
631
+
632
+ @SQLiteStorageBase.handle_exceptions
633
+ def get_profiles_by_generated_from_request_id(
634
+ self,
635
+ request_id: str,
636
+ ) -> list[UserProfile]:
637
+ """Return all profiles for a generated_from_request_id, including tombstones.
638
+
639
+ Args:
640
+ request_id (str): The generated_from_request_id to filter on.
641
+
642
+ Returns:
643
+ list[UserProfile]: All matching profiles (any status).
644
+ """
645
+ rows = self._fetchall(
646
+ "SELECT * FROM profiles WHERE generated_from_request_id = ?",
647
+ (request_id,),
648
+ )
649
+ return [_row_to_profile(r) for r in rows]
650
+
651
+ def get_all_generated_profiles(self) -> list[UserProfile]:
652
+ """All profiles (any status) with a non-empty generated_from_request_id."""
653
+ rows = self._fetchall(
654
+ "SELECT * FROM profiles "
655
+ "WHERE generated_from_request_id IS NOT NULL "
656
+ "AND generated_from_request_id <> ''",
657
+ )
658
+ return [_row_to_profile(r) for r in rows]
659
+
660
+ @SQLiteStorageBase.handle_exceptions
661
+ def archive_profile_by_id(self, user_id: str, profile_id: str) -> bool:
662
+ with self._lock:
663
+ if (
664
+ self._assert_profile_writable_locked(profile_id, user_id=user_id)
665
+ is None
666
+ ):
667
+ return False
668
+ now_ts = _epoch_now()
669
+ cur = self.conn.execute(
670
+ "UPDATE profiles SET status = ?, last_modified_timestamp = ?, retired_at = ? "
671
+ "WHERE profile_id = ? AND user_id = ? AND status IS NULL",
672
+ (Status.ARCHIVED.value, now_ts, now_ts, profile_id, user_id),
673
+ )
674
+ if cur.rowcount > 0:
675
+ _append_event_stmt(
676
+ self.conn,
677
+ org_id=self.org_id,
678
+ entity_type="profile",
679
+ entity_id=str(profile_id),
680
+ op="status_change",
681
+ prov="wasInvalidatedBy",
682
+ source_ids=[],
683
+ actor="api",
684
+ request_id=uuid.uuid4().hex,
685
+ reason="None->archived",
686
+ from_status=None,
687
+ to_status="archived",
688
+ status_namespace="lifecycle_status",
689
+ )
690
+ self.conn.commit()
304
691
  return cur.rowcount > 0
305
692
 
693
+ @SQLiteStorageBase.handle_exceptions
694
+ def supersede_profiles_by_ids(
695
+ self,
696
+ user_id: str,
697
+ profile_ids: list[str],
698
+ request_id: str,
699
+ ) -> list[str]:
700
+ """Soft-delete profiles by setting status to SUPERSEDED, emitting set-based lineage.
701
+
702
+ For each matching id (user_id scoped, currently CURRENT), updates status to
703
+ SUPERSEDED and emits one ``status_change`` event under the shared ``request_id``.
704
+ Atomic: one ``conn.commit()`` at the end, guarded on rowcount per id.
705
+ FTS/vec rows are NOT removed — reads exclude tombstones by status filter.
706
+
707
+ Args:
708
+ user_id (str): Owning user id.
709
+ profile_ids (list[str]): Profile ids to supersede.
710
+ request_id (str): Shared request id for all emitted lineage events.
711
+
712
+ Returns:
713
+ list[str]: The profile ids actually superseded by this call, in input order
714
+ (already-superseded or absent ids are omitted).
715
+ """
716
+ if not profile_ids:
717
+ return []
718
+ if not request_id:
719
+ raise ValueError("request_id must be non-empty for supersede")
720
+ now_ts = _epoch_now()
721
+ # Eligibility: CURRENT (NULL) or PENDING — the two live statuses dedup can target.
722
+ eligible = (None, Status.PENDING.value)
723
+ committed_ids: list[str] = []
724
+ with self._lock:
725
+ for pid in profile_ids:
726
+ # Read current status for from_status derivation (user_id scoped)
727
+ row = self.conn.execute(
728
+ "SELECT status, user_id, governance_subject_ref FROM profiles WHERE profile_id = ? AND user_id = ?",
729
+ (pid, user_id),
730
+ ).fetchone()
731
+ if row is None:
732
+ continue
733
+ self._assert_subject_writable_locked(
734
+ self._subject_ref_from_profile_row(row)
735
+ )
736
+ old_status_val = (
737
+ row[0] if isinstance(row, (tuple, list)) else row["status"]
738
+ )
739
+ if old_status_val not in eligible:
740
+ continue
741
+ cur = self.conn.execute(
742
+ "UPDATE profiles SET status = ?, last_modified_timestamp = ?, retired_at = ? "
743
+ "WHERE profile_id = ? AND user_id = ? "
744
+ "AND (status IS NULL OR status = ?)",
745
+ (
746
+ Status.SUPERSEDED.value,
747
+ now_ts,
748
+ now_ts,
749
+ pid,
750
+ user_id,
751
+ Status.PENDING.value,
752
+ ),
753
+ )
754
+ if cur.rowcount > 0:
755
+ _append_event_stmt(
756
+ self.conn,
757
+ org_id=self.org_id,
758
+ entity_type="profile",
759
+ entity_id=str(pid),
760
+ op="status_change",
761
+ prov="wasInvalidatedBy",
762
+ source_ids=[],
763
+ actor="dedup",
764
+ request_id=request_id,
765
+ reason=f"{old_status_val}->superseded",
766
+ from_status=old_status_val,
767
+ to_status=Status.SUPERSEDED.value,
768
+ status_namespace="lifecycle_status",
769
+ )
770
+ committed_ids.append(pid)
771
+ self.conn.commit()
772
+ return committed_ids
773
+
306
774
  @SQLiteStorageBase.handle_exceptions
307
775
  def delete_all_profiles_by_status(self, status: Status) -> int:
308
- # Clean up FTS for profiles being deleted
309
- pids = [
310
- r["profile_id"]
311
- for r in self._fetchall(
312
- "SELECT profile_id FROM profiles WHERE status = ?", (status.value,)
776
+ # Atomic: fts + vec + row + lineage in ONE lock/commit — rowid reuse race
777
+ # prevention (see delete_user_profile comment, #196).
778
+ batch_request_id = uuid.uuid4().hex
779
+ with self._lock:
780
+ rows = self.conn.execute(
781
+ "SELECT rowid, profile_id FROM profiles WHERE status = ?",
782
+ (status.value,),
783
+ ).fetchall()
784
+ if not rows:
785
+ return 0
786
+ pids = [r["profile_id"] for r in rows]
787
+ rowids = [r["rowid"] for r in rows]
788
+ self._delete_in_chunks("profiles_fts", "profile_id", pids)
789
+ if self._has_sqlite_vec and rowids:
790
+ self._delete_in_chunks("profiles_vec", "rowid", rowids)
791
+ ph = ",".join("?" for _ in pids)
792
+ cur = self.conn.execute(
793
+ f"DELETE FROM profiles WHERE profile_id IN ({ph})",
794
+ pids, # noqa: S608
313
795
  )
314
- ]
315
- for pid in pids:
316
- self._fts_delete_profile(pid)
317
- cur = self._execute("DELETE FROM profiles WHERE status = ?", (status.value,))
796
+ for pid in pids:
797
+ _emit_hard_delete_profile(
798
+ self.conn,
799
+ org_id=self.org_id,
800
+ entity_id=str(pid),
801
+ request_id=batch_request_id,
802
+ )
803
+ self.conn.commit()
318
804
  return cur.rowcount
319
805
 
320
806
  @SQLiteStorageBase.handle_exceptions
@@ -331,15 +817,41 @@ class ProfileMixin:
331
817
  return [r["user_id"] for r in rows]
332
818
 
333
819
  @SQLiteStorageBase.handle_exceptions
334
- def delete_profiles_by_ids(self, profile_ids: list[str]) -> int:
820
+ def delete_profiles_by_ids(
821
+ self, profile_ids: list[str], *, emit_hard_delete: bool = True
822
+ ) -> int:
335
823
  if not profile_ids:
336
824
  return 0
337
- for pid in profile_ids:
338
- self._fts_delete_profile(pid)
825
+ # Atomic: fts + vec + row + lineage in ONE lock/commit — rowid reuse race
826
+ # prevention (see delete_user_profile comment, #196).
339
827
  ph = ",".join("?" for _ in profile_ids)
340
- cur = self._execute(
341
- f"DELETE FROM profiles WHERE profile_id IN ({ph})", profile_ids
342
- )
828
+ batch_request_id = uuid.uuid4().hex
829
+ with self._lock:
830
+ pre_rows = self.conn.execute(
831
+ f"SELECT rowid, profile_id FROM profiles WHERE profile_id IN ({ph})",
832
+ profile_ids,
833
+ ).fetchall()
834
+ if not pre_rows:
835
+ return 0
836
+ existing = [r["profile_id"] for r in pre_rows]
837
+ rowids = [r["rowid"] for r in pre_rows]
838
+ self._delete_in_chunks("profiles_fts", "profile_id", existing)
839
+ if self._has_sqlite_vec and rowids:
840
+ self._delete_in_chunks("profiles_vec", "rowid", rowids)
841
+ cur = self.conn.execute(
842
+ f"DELETE FROM profiles WHERE profile_id IN ({ph})",
843
+ profile_ids, # noqa: S608
844
+ )
845
+ if emit_hard_delete:
846
+ for pid in existing:
847
+ _emit_hard_delete_profile(
848
+ self.conn,
849
+ org_id=self.org_id,
850
+ entity_id=str(pid),
851
+ request_id=batch_request_id,
852
+ actor="system",
853
+ )
854
+ self.conn.commit()
343
855
  return cur.rowcount
344
856
 
345
857
  # ------------------------------------------------------------------
@@ -370,60 +882,79 @@ class ProfileMixin:
370
882
 
371
883
  def _insert_interaction(self, interaction: Interaction) -> int:
372
884
  created_at_iso = _epoch_to_iso(interaction.created_at)
885
+ subject_ref = self._subject_ref_for_user_id(interaction.user_id)
373
886
  with self._lock:
374
- if interaction.interaction_id:
375
- self.conn.execute(
376
- """INSERT OR REPLACE INTO interactions
377
- (interaction_id, user_id, content, request_id, created_at,
378
- role, user_action, user_action_description,
379
- interacted_image_url, shadow_content, expert_content,
380
- tools_used, citations, embedding)
381
- VALUES (?,?,?,?,?,?,?,?,?,?,?,?,?,?)""",
382
- (
383
- interaction.interaction_id,
384
- interaction.user_id,
385
- interaction.content,
386
- interaction.request_id,
387
- created_at_iso,
388
- interaction.role,
389
- interaction.user_action.value,
390
- interaction.user_action_description,
391
- interaction.interacted_image_url,
392
- interaction.shadow_content,
393
- interaction.expert_content,
394
- _json_dumps([t.model_dump() for t in interaction.tools_used]),
395
- _json_dumps([c.model_dump() for c in interaction.citations]),
396
- _json_dumps(interaction.embedding),
397
- ),
398
- )
399
- iid = interaction.interaction_id
400
- else:
401
- cur = self.conn.execute(
402
- """INSERT INTO interactions
403
- (user_id, content, request_id, created_at,
404
- role, user_action, user_action_description,
405
- interacted_image_url, shadow_content, expert_content,
406
- tools_used, citations, embedding)
407
- VALUES (?,?,?,?,?,?,?,?,?,?,?,?,?)""",
408
- (
409
- interaction.user_id,
410
- interaction.content,
411
- interaction.request_id,
412
- created_at_iso,
413
- interaction.role,
414
- interaction.user_action.value,
415
- interaction.user_action_description,
416
- interaction.interacted_image_url,
417
- interaction.shadow_content,
418
- interaction.expert_content,
419
- _json_dumps([t.model_dump() for t in interaction.tools_used]),
420
- _json_dumps([c.model_dump() for c in interaction.citations]),
421
- _json_dumps(interaction.embedding),
422
- ),
423
- )
424
- iid = cur.lastrowid or 0
425
- interaction.interaction_id = iid
426
- self.conn.commit()
887
+ try:
888
+ self.conn.execute("BEGIN IMMEDIATE")
889
+ self._assert_subject_writable_locked(subject_ref)
890
+ if interaction.interaction_id:
891
+ self.conn.execute(
892
+ """INSERT OR REPLACE INTO interactions
893
+ (interaction_id, user_id, content, request_id, created_at,
894
+ role, user_action, user_action_description,
895
+ interacted_image_url, image_encoding, shadow_content,
896
+ expert_content, tools_used, citations, embedding, governance_subject_ref)
897
+ VALUES (?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?)""",
898
+ (
899
+ interaction.interaction_id,
900
+ interaction.user_id,
901
+ interaction.content,
902
+ interaction.request_id,
903
+ created_at_iso,
904
+ interaction.role,
905
+ interaction.user_action.value,
906
+ interaction.user_action_description,
907
+ interaction.interacted_image_url,
908
+ interaction.image_encoding,
909
+ interaction.shadow_content,
910
+ interaction.expert_content,
911
+ _json_dumps(
912
+ [t.model_dump() for t in interaction.tools_used]
913
+ ),
914
+ _json_dumps(
915
+ [c.model_dump() for c in interaction.citations]
916
+ ),
917
+ _json_dumps(interaction.embedding),
918
+ subject_ref,
919
+ ),
920
+ )
921
+ iid = interaction.interaction_id
922
+ else:
923
+ cur = self.conn.execute(
924
+ """INSERT INTO interactions
925
+ (user_id, content, request_id, created_at,
926
+ role, user_action, user_action_description,
927
+ interacted_image_url, image_encoding, shadow_content,
928
+ expert_content, tools_used, citations, embedding, governance_subject_ref)
929
+ VALUES (?,?,?,?,?,?,?,?,?,?,?,?,?,?,?)""",
930
+ (
931
+ interaction.user_id,
932
+ interaction.content,
933
+ interaction.request_id,
934
+ created_at_iso,
935
+ interaction.role,
936
+ interaction.user_action.value,
937
+ interaction.user_action_description,
938
+ interaction.interacted_image_url,
939
+ interaction.image_encoding,
940
+ interaction.shadow_content,
941
+ interaction.expert_content,
942
+ _json_dumps(
943
+ [t.model_dump() for t in interaction.tools_used]
944
+ ),
945
+ _json_dumps(
946
+ [c.model_dump() for c in interaction.citations]
947
+ ),
948
+ _json_dumps(interaction.embedding),
949
+ subject_ref,
950
+ ),
951
+ )
952
+ iid = cur.lastrowid or 0
953
+ interaction.interaction_id = iid
954
+ self.conn.commit()
955
+ except Exception:
956
+ self.conn.rollback()
957
+ raise
427
958
  # Update FTS and vec
428
959
  self._fts_upsert(
429
960
  "interactions_fts",
@@ -464,34 +995,49 @@ class ProfileMixin:
464
995
 
465
996
  @SQLiteStorageBase.handle_exceptions
466
997
  def delete_user_interaction(self, request: DeleteUserInteractionRequest) -> None:
467
- self._fts_delete("interactions_fts", request.interaction_id)
468
- self._execute(
469
- "DELETE FROM interactions WHERE user_id = ? AND interaction_id = ?",
470
- (request.user_id, request.interaction_id),
471
- )
998
+ with self._lock:
999
+ row = self.conn.execute(
1000
+ "SELECT interaction_id FROM interactions WHERE user_id = ? AND interaction_id = ?",
1001
+ (request.user_id, request.interaction_id),
1002
+ ).fetchone()
1003
+ if row is None:
1004
+ return
1005
+ self.conn.execute(
1006
+ "DELETE FROM interactions_fts WHERE rowid = ?",
1007
+ (request.interaction_id,),
1008
+ )
1009
+ if self._has_sqlite_vec:
1010
+ self.conn.execute(
1011
+ "DELETE FROM interactions_vec WHERE rowid = ?",
1012
+ (request.interaction_id,),
1013
+ )
1014
+ self.conn.execute(
1015
+ "DELETE FROM interactions WHERE user_id = ? AND interaction_id = ?",
1016
+ (request.user_id, request.interaction_id),
1017
+ )
1018
+ self.conn.commit()
472
1019
 
473
1020
  @SQLiteStorageBase.handle_exceptions
474
1021
  def delete_all_interactions_for_user(self, user_id: str) -> None:
475
- # Delete FTS entries for this user's interactions
476
- ids = [
477
- r["interaction_id"]
478
- for r in self._fetchall(
1022
+ with self._lock:
1023
+ rows = self.conn.execute(
479
1024
  "SELECT interaction_id FROM interactions WHERE user_id = ?", (user_id,)
480
- )
481
- ]
482
- if ids:
483
- placeholders = ",".join("?" for _ in ids)
484
- with self._lock:
485
- self.conn.execute(
486
- f"DELETE FROM interactions_fts WHERE rowid IN ({placeholders})", ids
487
- )
488
- self.conn.commit()
489
- self._execute("DELETE FROM interactions WHERE user_id = ?", (user_id,))
1025
+ ).fetchall()
1026
+ if not rows:
1027
+ return
1028
+ ids = [r["interaction_id"] for r in rows]
1029
+ self._delete_in_chunks("interactions_fts", "rowid", ids)
1030
+ if self._has_sqlite_vec:
1031
+ self._delete_in_chunks("interactions_vec", "rowid", ids)
1032
+ self.conn.execute("DELETE FROM interactions WHERE user_id = ?", (user_id,))
1033
+ self.conn.commit()
490
1034
 
491
1035
  @SQLiteStorageBase.handle_exceptions
492
1036
  def delete_all_interactions(self) -> None:
493
1037
  with self._lock:
494
1038
  self.conn.execute("DELETE FROM interactions_fts")
1039
+ if self._has_sqlite_vec:
1040
+ self.conn.execute("DELETE FROM interactions_vec")
495
1041
  self.conn.execute("DELETE FROM interactions")
496
1042
  self.conn.commit()
497
1043
 
@@ -504,22 +1050,18 @@ class ProfileMixin:
504
1050
  def delete_oldest_interactions(self, count: int) -> int:
505
1051
  if count <= 0:
506
1052
  return 0
507
- rows = self._fetchall(
508
- "SELECT interaction_id FROM interactions ORDER BY created_at ASC LIMIT ?",
509
- (count,),
510
- )
511
- if not rows:
512
- return 0
513
- ids = [r["interaction_id"] for r in rows]
514
- placeholders = ",".join("?" for _ in ids)
515
1053
  with self._lock:
516
- self.conn.execute(
517
- f"DELETE FROM interactions_fts WHERE rowid IN ({placeholders})", ids
518
- )
519
- self.conn.execute(
520
- f"DELETE FROM interactions WHERE interaction_id IN ({placeholders})",
521
- ids,
522
- )
1054
+ rows = self.conn.execute(
1055
+ "SELECT interaction_id FROM interactions ORDER BY created_at ASC LIMIT ?",
1056
+ (count,),
1057
+ ).fetchall()
1058
+ if not rows:
1059
+ return 0
1060
+ ids = [r["interaction_id"] for r in rows]
1061
+ self._delete_in_chunks("interactions_fts", "rowid", ids)
1062
+ if self._has_sqlite_vec:
1063
+ self._delete_in_chunks("interactions_vec", "rowid", ids)
1064
+ self._delete_in_chunks("interactions", "interaction_id", ids)
523
1065
  self.conn.commit()
524
1066
  return len(ids)
525
1067
 
@@ -657,6 +1199,10 @@ class ProfileMixin:
657
1199
  frag, sparams = _build_status_sql(status_filter)
658
1200
  conditions.append(frag)
659
1201
  params.extend(sparams)
1202
+ tag_frag, tag_params = _build_tags_sql("p", req.tags)
1203
+ if tag_frag:
1204
+ conditions.append(tag_frag)
1205
+ params.extend(tag_params)
660
1206
 
661
1207
  where_clause = " AND ".join(conditions)
662
1208
  overfetch = match_count * 5 if mode != SearchMode.FTS else match_count