@k2b/cloud 0.6.0-rc.0

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 (540) hide show
  1. package/README.md +98 -0
  2. package/package.json +128 -0
  3. package/public/logo.svg +1 -0
  4. package/scripts/app-favicon.test.ts +49 -0
  5. package/scripts/app-favicon.ts +51 -0
  6. package/scripts/build.ts +205 -0
  7. package/scripts/preload.ts +164 -0
  8. package/scripts/sync-recovery-smoke.ts +200 -0
  9. package/src/_internal/app-favicon.ts +2 -0
  10. package/src/_internal/app-presentation.ts +61 -0
  11. package/src/_internal/app-workspace-state.ts +24 -0
  12. package/src/_internal/bounded-json.ts +47 -0
  13. package/src/_internal/build-metadata.ts +9 -0
  14. package/src/_internal/capabilities.ts +1118 -0
  15. package/src/_internal/define-app.ts +801 -0
  16. package/src/_internal/heartbeat.ts +167 -0
  17. package/src/_internal/help-catalog.ts +241 -0
  18. package/src/_internal/help.ts +95 -0
  19. package/src/_internal/nats-connection.ts +34 -0
  20. package/src/_internal/page-responses.ts +40 -0
  21. package/src/_internal/process-sync.ts +117 -0
  22. package/src/_internal/readiness.ts +3 -0
  23. package/src/_internal/registry-validation.ts +136 -0
  24. package/src/_internal/registry-watch.ts +52 -0
  25. package/src/_internal/registry.ts +338 -0
  26. package/src/_internal/runtime-compatibility.ts +64 -0
  27. package/src/_internal/runtime-context.ts +84 -0
  28. package/src/_internal/runtime-watcher.ts +78 -0
  29. package/src/_internal/static-assets.ts +86 -0
  30. package/src/_internal/status-preserving-ssr.ts +49 -0
  31. package/src/access/PermissionEditor.tsx +486 -0
  32. package/src/access/ResourceApiKeys.tsx +271 -0
  33. package/src/access/messages.ts +121 -0
  34. package/src/access/ui.ts +16 -0
  35. package/src/account/Avatar.tsx +28 -0
  36. package/src/account/EntitySearch.tsx +363 -0
  37. package/src/account/avatar-upload-dialog.tsx +197 -0
  38. package/src/account/avatar-upload.ts +149 -0
  39. package/src/account/ui.ts +3 -0
  40. package/src/ai/admin.ts +47 -0
  41. package/src/ai/approval-routes.ts +130 -0
  42. package/src/ai/approvals.ts +139 -0
  43. package/src/ai/assistant-models.ts +61 -0
  44. package/src/ai/attachments.ts +40 -0
  45. package/src/ai/browser.ts +92 -0
  46. package/src/ai/capabilities.ts +714 -0
  47. package/src/ai/capability-execution.ts +151 -0
  48. package/src/ai/chat/blocks.render.test.tsx +1108 -0
  49. package/src/ai/chat/blocks.tsx +730 -0
  50. package/src/ai/chat/builtin-tools.tsx +428 -0
  51. package/src/ai/chat/capability-table.tsx +87 -0
  52. package/src/ai/chat/composer-adapter.ts +169 -0
  53. package/src/ai/chat/file-tools.tsx +81 -0
  54. package/src/ai/chat/message-actions.tsx +370 -0
  55. package/src/ai/chat/message-utils.ts +479 -0
  56. package/src/ai/chat/messages.ts +43 -0
  57. package/src/ai/chat/presentation.tsx +395 -0
  58. package/src/ai/chat/primitives.tsx +3 -0
  59. package/src/ai/chat/tool-disclosure.tsx +41 -0
  60. package/src/ai/chat/turn-navigator-utils.ts +28 -0
  61. package/src/ai/chat/turn-navigator.tsx +164 -0
  62. package/src/ai/chat/user-message.tsx +149 -0
  63. package/src/ai/chat/visual-tools.tsx +435 -0
  64. package/src/ai/chat/web-tools.tsx +202 -0
  65. package/src/ai/chat-task-contracts.ts +113 -0
  66. package/src/ai/chat-tasks.ts +952 -0
  67. package/src/ai/client/controller.ts +1211 -0
  68. package/src/ai/client/file-source.ts +95 -0
  69. package/src/ai/client/live-connection.ts +140 -0
  70. package/src/ai/client/projection.ts +178 -0
  71. package/src/ai/client/transport.ts +111 -0
  72. package/src/ai/compaction.ts +182 -0
  73. package/src/ai/credentials.ts +124 -0
  74. package/src/ai/default-tool-contracts.ts +108 -0
  75. package/src/ai/default-tools.ts +127 -0
  76. package/src/ai/enrich.ts +280 -0
  77. package/src/ai/executor.ts +1624 -0
  78. package/src/ai/fetch-file-tool.ts +313 -0
  79. package/src/ai/file-context.ts +92 -0
  80. package/src/ai/file-media-type.ts +28 -0
  81. package/src/ai/file-mount.ts +16 -0
  82. package/src/ai/file-tools.ts +566 -0
  83. package/src/ai/files-store.ts +390 -0
  84. package/src/ai/firecrawl-tools.ts +305 -0
  85. package/src/ai/grids-skill.ts +126 -0
  86. package/src/ai/http.ts +265 -0
  87. package/src/ai/index.ts +337 -0
  88. package/src/ai/kit-skill.ts +22 -0
  89. package/src/ai/limits.ts +5 -0
  90. package/src/ai/live-events.ts +289 -0
  91. package/src/ai/live-messages.ts +45 -0
  92. package/src/ai/live-outbox.ts +102 -0
  93. package/src/ai/live-routes.ts +412 -0
  94. package/src/ai/live.ts +2 -0
  95. package/src/ai/maintenance.ts +302 -0
  96. package/src/ai/markdown-pdf-tool.ts +95 -0
  97. package/src/ai/memories.ts +605 -0
  98. package/src/ai/memory-learning-runs.ts +222 -0
  99. package/src/ai/memory-learning.ts +619 -0
  100. package/src/ai/memory-tool.ts +120 -0
  101. package/src/ai/memory-workflow-evidence.ts +158 -0
  102. package/src/ai/migrate.ts +2082 -0
  103. package/src/ai/model-access-migrate.ts +30 -0
  104. package/src/ai/model-access.ts +191 -0
  105. package/src/ai/personal-agent.ts +6 -0
  106. package/src/ai/prefs.ts +72 -0
  107. package/src/ai/project-tool.ts +94 -0
  108. package/src/ai/projects-routes.ts +277 -0
  109. package/src/ai/projects.ts +902 -0
  110. package/src/ai/protocol.ts +279 -0
  111. package/src/ai/provider.ts +73 -0
  112. package/src/ai/public-projection.ts +25 -0
  113. package/src/ai/resource-markers.ts +31 -0
  114. package/src/ai/resource-refs.ts +70 -0
  115. package/src/ai/routes.ts +1131 -0
  116. package/src/ai/runtime-tools.ts +39 -0
  117. package/src/ai/runtime.ts +544 -0
  118. package/src/ai/settings.ts +529 -0
  119. package/src/ai/short-id.ts +45 -0
  120. package/src/ai/skill-catalog.ts +73 -0
  121. package/src/ai/skill-format.ts +145 -0
  122. package/src/ai/skill-seeds.ts +430 -0
  123. package/src/ai/skill-tool.ts +81 -0
  124. package/src/ai/skills-routes.ts +143 -0
  125. package/src/ai/skills.ts +934 -0
  126. package/src/ai/solid.ts +27 -0
  127. package/src/ai/store.ts +3113 -0
  128. package/src/ai/stream.ts +308 -0
  129. package/src/ai/structured-runs.ts +66 -0
  130. package/src/ai/structured.ts +186 -0
  131. package/src/ai/system-prompt.ts +170 -0
  132. package/src/ai/task-prompt.ts +22 -0
  133. package/src/ai/timeline.ts +148 -0
  134. package/src/ai/tool-audit.ts +177 -0
  135. package/src/ai/tool-result-budget.ts +16 -0
  136. package/src/ai/tools.ts +155 -0
  137. package/src/ai/types.ts +967 -0
  138. package/src/ai/ui.tsx +39 -0
  139. package/src/ai/usage-migrate.ts +81 -0
  140. package/src/ai/usage.ts +315 -0
  141. package/src/ai/validate.ts +54 -0
  142. package/src/ai/vision-tool.ts +79 -0
  143. package/src/api/accounts-entities.ts +174 -0
  144. package/src/api/admin-ai-projects.ts +74 -0
  145. package/src/api/admin-ai-skills.ts +76 -0
  146. package/src/api/admin-ai-usage.ts +42 -0
  147. package/src/api/admin-core-settings.ts +508 -0
  148. package/src/api/admin-identity.ts +148 -0
  149. package/src/api/admin-lifecycle.ts +206 -0
  150. package/src/api/admin-linux-identities.ts +66 -0
  151. package/src/api/announcements.ts +131 -0
  152. package/src/api/app-approval.ts +129 -0
  153. package/src/api/apps.ts +51 -0
  154. package/src/api/auth/schemas.ts +54 -0
  155. package/src/api/auth.ts +370 -0
  156. package/src/api/capabilities.ts +658 -0
  157. package/src/api/help-messages.ts +19 -0
  158. package/src/api/help.ts +78 -0
  159. package/src/api/index.ts +108 -0
  160. package/src/api/mcp.ts +744 -0
  161. package/src/api/me-rail.ts +45 -0
  162. package/src/api/me.ts +753 -0
  163. package/src/api/search/schemas.ts +79 -0
  164. package/src/api/search.bench.ts +185 -0
  165. package/src/api/search.ts +407 -0
  166. package/src/api/sync-ops.ts +79 -0
  167. package/src/api/widgets.ts +143 -0
  168. package/src/browser/CloudResourceSearch.behavior.test.tsx +74 -0
  169. package/src/browser/CloudResourceSearch.tsx +791 -0
  170. package/src/browser/app-approval-vault.ts +305 -0
  171. package/src/browser/app-approval.ts +234 -0
  172. package/src/browser/live-websocket.ts +268 -0
  173. package/src/browser/locale-preference.ts +25 -0
  174. package/src/browser/notification-target.ts +27 -0
  175. package/src/browser/notification-timeout.ts +14 -0
  176. package/src/browser/notifications.ts +181 -0
  177. package/src/browser/resource-clipboard.ts +70 -0
  178. package/src/browser/resource-picker.tsx +39 -0
  179. package/src/browser/resource-search-messages.ts +55 -0
  180. package/src/browser/resource-search.ts +27 -0
  181. package/src/capabilities/client.ts +149 -0
  182. package/src/capabilities/response.ts +39 -0
  183. package/src/capabilities/server.ts +215 -0
  184. package/src/capabilities/signals.ts +4 -0
  185. package/src/capabilities/testing.ts +12 -0
  186. package/src/capabilities/types.ts +32 -0
  187. package/src/cli/access.ts +499 -0
  188. package/src/cli/account.ts +382 -0
  189. package/src/cli/admin/account-administration.ts +45 -0
  190. package/src/cli/admin/account-categories.ts +49 -0
  191. package/src/cli/admin/ai-usage.ts +118 -0
  192. package/src/cli/admin/app-sign-in.ts +49 -0
  193. package/src/cli/admin/data.ts +343 -0
  194. package/src/cli/admin/documentation.ts +34 -0
  195. package/src/cli/admin/gateway.ts +155 -0
  196. package/src/cli/admin/index.ts +61 -0
  197. package/src/cli/admin/instance.ts +250 -0
  198. package/src/cli/admin/jobs.ts +223 -0
  199. package/src/cli/admin/legal.ts +122 -0
  200. package/src/cli/admin/linux.ts +53 -0
  201. package/src/cli/admin/logs.ts +324 -0
  202. package/src/cli/admin/metrics.ts +142 -0
  203. package/src/cli/admin/nats.ts +167 -0
  204. package/src/cli/admin/notifications.ts +516 -0
  205. package/src/cli/admin/shared.ts +113 -0
  206. package/src/cli/admin/sync.ts +188 -0
  207. package/src/cli/admin/telemetry.ts +314 -0
  208. package/src/cli/admin/webhooks.ts +222 -0
  209. package/src/cli/admin/workflows.ts +367 -0
  210. package/src/cli/apps.ts +40 -0
  211. package/src/cli/capabilities.ts +239 -0
  212. package/src/cli/commands.ts +604 -0
  213. package/src/cli/index.ts +61 -0
  214. package/src/cli/locale.ts +31 -0
  215. package/src/cli/output.ts +58 -0
  216. package/src/clients/core.ts +20 -0
  217. package/src/config/env.ts +54 -0
  218. package/src/config/index.ts +6 -0
  219. package/src/contracts/account-categories.ts +62 -0
  220. package/src/contracts/announcements.ts +121 -0
  221. package/src/contracts/app-approval.ts +197 -0
  222. package/src/contracts/app.ts +162 -0
  223. package/src/contracts/capabilities.ts +484 -0
  224. package/src/contracts/index.ts +14 -0
  225. package/src/contracts/notification-live.ts +66 -0
  226. package/src/contracts/notification-types.ts +235 -0
  227. package/src/contracts/posix.ts +69 -0
  228. package/src/contracts/profile.ts +85 -0
  229. package/src/contracts/rail-preferences.ts +53 -0
  230. package/src/contracts/registry.ts +110 -0
  231. package/src/contracts/resource-clipboard.ts +33 -0
  232. package/src/contracts/settings-types.ts +98 -0
  233. package/src/contracts/shared.ts +408 -0
  234. package/src/contracts/user-notifications.ts +101 -0
  235. package/src/contracts/widgets.ts +183 -0
  236. package/src/desktop/index.ts +704 -0
  237. package/src/desktop/solid.tsx +938 -0
  238. package/src/index.ts +58 -0
  239. package/src/server/actor.ts +41 -0
  240. package/src/server/api/index.ts +2 -0
  241. package/src/server/api/respond.ts +96 -0
  242. package/src/server/api-client.ts +54 -0
  243. package/src/server/app-context.ts +39 -0
  244. package/src/server/help.ts +187 -0
  245. package/src/server/index.ts +89 -0
  246. package/src/server/locale.ts +55 -0
  247. package/src/server/middleware/auth.ts +386 -0
  248. package/src/server/middleware/index.ts +28 -0
  249. package/src/server/middleware/invocation.ts +55 -0
  250. package/src/server/middleware/middleware.ts +65 -0
  251. package/src/server/middleware/openapi.ts +126 -0
  252. package/src/server/middleware/rate-limit.ts +125 -0
  253. package/src/server/middleware/request-logger.ts +41 -0
  254. package/src/server/middleware/route-template.ts +46 -0
  255. package/src/server/middleware/runtime.ts +22 -0
  256. package/src/server/middleware/settings.ts +55 -0
  257. package/src/server/middleware/validator.ts +49 -0
  258. package/src/server/middleware/workload.ts +22 -0
  259. package/src/server/ratelimit.ts +151 -0
  260. package/src/server/request-cookies.ts +18 -0
  261. package/src/server/services/access.ts +644 -0
  262. package/src/server/services/freeipa/client.ts +137 -0
  263. package/src/server/services/freeipa/index.ts +9 -0
  264. package/src/server/services/freeipa/session.ts +123 -0
  265. package/src/server/services/freeipa/test-certificates.ts +44 -0
  266. package/src/server/services/freeipa/tls.ts +44 -0
  267. package/src/server/services/freeipa/transport.ts +116 -0
  268. package/src/server/services/freeipa/util.ts +59 -0
  269. package/src/server/services/geo.ts +198 -0
  270. package/src/server/services/index.ts +48 -0
  271. package/src/server/services/pagination.ts +22 -0
  272. package/src/server/services/services.ts +13 -0
  273. package/src/server/time.ts +33 -0
  274. package/src/services/account-category-policy.ts +51 -0
  275. package/src/services/account-lifecycle/audit.ts +41 -0
  276. package/src/services/account-lifecycle/index.ts +940 -0
  277. package/src/services/account-lifecycle/ipa-backfill.ts +152 -0
  278. package/src/services/account-lifecycle/notification-sender.ts +13 -0
  279. package/src/services/account-lifecycle/scheduler.ts +506 -0
  280. package/src/services/account-model.ts +22 -0
  281. package/src/services/accounts/app.ts +1671 -0
  282. package/src/services/accounts/authz.ts +44 -0
  283. package/src/services/accounts/avatar.ts +120 -0
  284. package/src/services/accounts/base-group.ts +11 -0
  285. package/src/services/accounts/base-user.ts +46 -0
  286. package/src/services/accounts/duplicate-emails.ts +82 -0
  287. package/src/services/accounts/entities.ts +622 -0
  288. package/src/services/accounts/group-sql.ts +106 -0
  289. package/src/services/accounts/groups.ts +272 -0
  290. package/src/services/accounts/index.ts +14 -0
  291. package/src/services/accounts/ipa-data.ts +66 -0
  292. package/src/services/accounts/lifecycle.ts +2 -0
  293. package/src/services/accounts/local-groups.ts +499 -0
  294. package/src/services/accounts/model.ts +129 -0
  295. package/src/services/accounts/notification-sender.ts +31 -0
  296. package/src/services/accounts/posix.ts +386 -0
  297. package/src/services/accounts/request-policy.ts +11 -0
  298. package/src/services/accounts/switching.ts +126 -0
  299. package/src/services/accounts/users.ts +745 -0
  300. package/src/services/announcements/index.ts +224 -0
  301. package/src/services/app-approval.ts +498 -0
  302. package/src/services/audit/index.ts +438 -0
  303. package/src/services/auth-flows/index.ts +15 -0
  304. package/src/services/auth-flows/ipa.ts +197 -0
  305. package/src/services/auth-flows/magic-link.ts +185 -0
  306. package/src/services/auth-flows/notification-sender.ts +10 -0
  307. package/src/services/auth-flows/password-reset.ts +268 -0
  308. package/src/services/auth-flows/proxy-return.ts +49 -0
  309. package/src/services/document-extraction.ts +148 -0
  310. package/src/services/freeipa-config.ts +179 -0
  311. package/src/services/gateway.ts +186 -0
  312. package/src/services/identity/constants.ts +18 -0
  313. package/src/services/identity/index.ts +52 -0
  314. package/src/services/identity/invocation-actor.ts +120 -0
  315. package/src/services/identity/invocation-authority.ts +53 -0
  316. package/src/services/identity/invocation-operations.ts +15 -0
  317. package/src/services/identity/invocation-token.ts +276 -0
  318. package/src/services/identity/key-config.ts +41 -0
  319. package/src/services/identity/key-ring.ts +530 -0
  320. package/src/services/identity/metrics.ts +29 -0
  321. package/src/services/identity/routes.ts +18 -0
  322. package/src/services/identity/runtime-config.ts +88 -0
  323. package/src/services/identity/session-token.ts +152 -0
  324. package/src/services/identity/signer-refresh.ts +44 -0
  325. package/src/services/identity/workload-auth.ts +64 -0
  326. package/src/services/index.ts +187 -0
  327. package/src/services/ipa/auth.ts +216 -0
  328. package/src/services/ipa/connection.ts +57 -0
  329. package/src/services/ipa/effective-groups.ts +70 -0
  330. package/src/services/ipa/groups.ts +722 -0
  331. package/src/services/ipa/guard.ts +17 -0
  332. package/src/services/ipa/index.ts +17 -0
  333. package/src/services/ipa/posix.ts +19 -0
  334. package/src/services/ipa/profile.ts +132 -0
  335. package/src/services/ipa/runtime.ts +28 -0
  336. package/src/services/ipa/search.ts +154 -0
  337. package/src/services/ipa/service-account.ts +15 -0
  338. package/src/services/ipa/sync-planning.ts +100 -0
  339. package/src/services/ipa/sync.ts +862 -0
  340. package/src/services/ipa/users.ts +806 -0
  341. package/src/services/legal-consent.ts +76 -0
  342. package/src/services/logging/index.ts +477 -0
  343. package/src/services/logging/redaction.ts +20 -0
  344. package/src/services/logging/trace.ts +1231 -0
  345. package/src/services/mandates/index.ts +855 -0
  346. package/src/services/mandates/policy.ts +85 -0
  347. package/src/services/network-security.ts +31 -0
  348. package/src/services/notifications/batches.ts +722 -0
  349. package/src/services/notifications/browser.ts +252 -0
  350. package/src/services/notifications/catalog.ts +113 -0
  351. package/src/services/notifications/channels.ts +104 -0
  352. package/src/services/notifications/dispatcher.ts +139 -0
  353. package/src/services/notifications/email.ts +118 -0
  354. package/src/services/notifications/index.ts +633 -0
  355. package/src/services/notifications/live.ts +50 -0
  356. package/src/services/notifications/observability.ts +407 -0
  357. package/src/services/notifications/platform.ts +421 -0
  358. package/src/services/notifications/runtime.ts +120 -0
  359. package/src/services/notifications/user.ts +248 -0
  360. package/src/services/notifications/web-push-transport.ts +135 -0
  361. package/src/services/oauth-tokens.ts +209 -0
  362. package/src/services/outbox.ts +187 -0
  363. package/src/services/pdf/gotenberg.ts +290 -0
  364. package/src/services/pdf/index.ts +43 -0
  365. package/src/services/pdf/markdown.ts +209 -0
  366. package/src/services/pdf/template-preview.ts +122 -0
  367. package/src/services/postgres.ts +76 -0
  368. package/src/services/providers/index.ts +27 -0
  369. package/src/services/providers/local/auth.ts +51 -0
  370. package/src/services/providers/local/index.ts +4 -0
  371. package/src/services/providers/local/users.ts +263 -0
  372. package/src/services/rail-preferences.ts +27 -0
  373. package/src/services/runtime-lifecycle.ts +191 -0
  374. package/src/services/secrets.ts +10 -0
  375. package/src/services/service-account-credentials.ts +891 -0
  376. package/src/services/service-accounts.ts +184 -0
  377. package/src/services/session/index.ts +236 -0
  378. package/src/services/session/test-fixture.ts +18 -0
  379. package/src/services/session/user.ts +144 -0
  380. package/src/services/settings/api.ts +61 -0
  381. package/src/services/settings/app.ts +86 -0
  382. package/src/services/settings/core-settings.ts +796 -0
  383. package/src/services/settings/crypto.ts +69 -0
  384. package/src/services/settings/defaults.ts +374 -0
  385. package/src/services/settings/index.ts +215 -0
  386. package/src/services/settings/namespace.ts +9 -0
  387. package/src/services/settings/snapshot.ts +49 -0
  388. package/src/services/settings/store.ts +241 -0
  389. package/src/services/settings/templates.ts +4 -0
  390. package/src/services/sync-ops.ts +403 -0
  391. package/src/services/topic-cursor.ts +22 -0
  392. package/src/services/weather/forecast.ts +323 -0
  393. package/src/services/weather/geo.ts +119 -0
  394. package/src/services/weather/index.ts +67 -0
  395. package/src/services/weather/location.ts +24 -0
  396. package/src/services/weather/locations.ts +133 -0
  397. package/src/services/weather/migrate.ts +45 -0
  398. package/src/services/weather/settings.ts +66 -0
  399. package/src/services/weather/short-id.ts +57 -0
  400. package/src/services/weather/types.ts +61 -0
  401. package/src/services/weather/ui.ts +50 -0
  402. package/src/services/webauthn.ts +385 -0
  403. package/src/shared/account-action-notice.ts +70 -0
  404. package/src/shared/account-display.ts +15 -0
  405. package/src/shared/account-session.ts +15 -0
  406. package/src/shared/ai-platform-prompt.ts +134 -0
  407. package/src/shared/ai-usage.ts +49 -0
  408. package/src/shared/app-presentation.ts +48 -0
  409. package/src/shared/app-url.ts +12 -0
  410. package/src/shared/branding.ts +14 -0
  411. package/src/shared/capability-messages.ts +70 -0
  412. package/src/shared/email-html.ts +54 -0
  413. package/src/shared/format.ts +91 -0
  414. package/src/shared/help.ts +68 -0
  415. package/src/shared/icons.ts +400 -0
  416. package/src/shared/index.ts +54 -0
  417. package/src/shared/locale.ts +48 -0
  418. package/src/shared/login-method.ts +44 -0
  419. package/src/shared/markdown/client.ts +130 -0
  420. package/src/shared/markdown/extensions/code.ts +105 -0
  421. package/src/shared/markdown/extensions/guided-help.ts +100 -0
  422. package/src/shared/markdown/extensions/images.ts +79 -0
  423. package/src/shared/markdown/extensions/info-blocks.ts +97 -0
  424. package/src/shared/markdown/extensions/katex.ts +120 -0
  425. package/src/shared/markdown/extensions/links.ts +43 -0
  426. package/src/shared/markdown/extensions/mark.ts +48 -0
  427. package/src/shared/markdown/extensions/sub-sup.ts +60 -0
  428. package/src/shared/markdown/extensions/tables.ts +109 -0
  429. package/src/shared/markdown/extensions/task-list.ts +53 -0
  430. package/src/shared/markdown/formula.ts +1187 -0
  431. package/src/shared/markdown/index.ts +193 -0
  432. package/src/shared/markdown/shared.ts +36 -0
  433. package/src/shared/mock-cover.ts +130 -0
  434. package/src/shared/network-address.ts +106 -0
  435. package/src/shared/redirect.ts +56 -0
  436. package/src/shared/template-rendering.ts +140 -0
  437. package/src/shared/theme.ts +68 -0
  438. package/src/shared/time.ts +13 -0
  439. package/src/ssr/AdminLayout.tsx +53 -0
  440. package/src/ssr/AdminSidebar.tsx +50 -0
  441. package/src/ssr/AppLaunchpad.island.tsx +236 -0
  442. package/src/ssr/AppWorkspaceController.island.tsx +21 -0
  443. package/src/ssr/BrowserNotifications.island.tsx +135 -0
  444. package/src/ssr/GlobalAnnouncements.island.tsx +142 -0
  445. package/src/ssr/GlobalSearchDialog.behavior.test.tsx +96 -0
  446. package/src/ssr/GlobalSearchDialog.tsx +35 -0
  447. package/src/ssr/GlobalSearchHelpDialog.tsx +104 -0
  448. package/src/ssr/GlobalSearchTrigger.island.tsx +55 -0
  449. package/src/ssr/HotkeysHelpRail.island.tsx +55 -0
  450. package/src/ssr/Layout.render.test.tsx +148 -0
  451. package/src/ssr/Layout.tsx +292 -0
  452. package/src/ssr/LayoutBreadcrumbs.island.tsx +44 -0
  453. package/src/ssr/LayoutFooter.tsx +22 -0
  454. package/src/ssr/LayoutHeader.tsx +81 -0
  455. package/src/ssr/LayoutHelp.tsx +869 -0
  456. package/src/ssr/LayoutPreferences.island.tsx +36 -0
  457. package/src/ssr/LayoutRail.tsx +58 -0
  458. package/src/ssr/MinimalLayout.tsx +46 -0
  459. package/src/ssr/PageError.tsx +29 -0
  460. package/src/ssr/ProfilePreferences.island.tsx +92 -0
  461. package/src/ssr/RailApps.island.tsx +61 -0
  462. package/src/ssr/RailEditor.tsx +293 -0
  463. package/src/ssr/RegisteredHelpDocuments.island.tsx +3 -0
  464. package/src/ssr/TimezoneCookie.island.tsx +23 -0
  465. package/src/ssr/admin-active-link.ts +47 -0
  466. package/src/ssr/admin-navigation.ts +78 -0
  467. package/src/ssr/app-appearance.ts +23 -0
  468. package/src/ssr/app-navigation.ts +41 -0
  469. package/src/ssr/help-messages.ts +104 -0
  470. package/src/ssr/index.ts +14 -0
  471. package/src/ssr/islands/SearchBar.island.tsx +80 -0
  472. package/src/ssr/islands/index.ts +16 -0
  473. package/src/ssr/layout-context.ts +17 -0
  474. package/src/ssr/layout-help-markdown.ts +9 -0
  475. package/src/ssr/layout-help-navigation.ts +24 -0
  476. package/src/ssr/layout-help-url.ts +5 -0
  477. package/src/ssr/layout-runtime.ts +22 -0
  478. package/src/ssr/page-error-messages.ts +25 -0
  479. package/src/ssr/platform-messages.ts +135 -0
  480. package/src/ssr/preference-controller.ts +44 -0
  481. package/src/ssr/profile-preferences-messages.ts +29 -0
  482. package/src/ssr/rail-context.ts +21 -0
  483. package/src/ssr/rail-messages.ts +66 -0
  484. package/src/ssr/rail-navigation.ts +60 -0
  485. package/src/ssr/runtime.ts +31 -0
  486. package/src/ssr/url-filter.ts +127 -0
  487. package/src/styles/base-popover.css +36 -0
  488. package/src/styles/effects.css +851 -0
  489. package/src/styles/global.css +268 -0
  490. package/src/styles/input.css +58 -0
  491. package/src/styles/tokens.css +230 -0
  492. package/src/styles/utilities-buttons.css +405 -0
  493. package/src/styles/utilities-data.css +33 -0
  494. package/src/styles/utilities-feedback.css +232 -0
  495. package/src/styles/utilities-layout.css +603 -0
  496. package/src/styles/utilities-markdown-editor.css +525 -0
  497. package/src/styles/utilities-markdown-table.css +234 -0
  498. package/src/styles/utilities-navigation.css +1054 -0
  499. package/src/types/ambient.d.ts +24 -0
  500. package/src/workflows/ai/index.ts +17 -0
  501. package/src/workflows/ai/runtime.ts +286 -0
  502. package/src/workflows/ai/store.ts +314 -0
  503. package/src/workflows/ai/types.ts +111 -0
  504. package/src/workflows/ai-actions.ts +269 -0
  505. package/src/workflows/authoring.ts +88 -0
  506. package/src/workflows/builtins.ts +193 -0
  507. package/src/workflows/contracts.ts +280 -0
  508. package/src/workflows/definition.ts +334 -0
  509. package/src/workflows/editor.ts +113 -0
  510. package/src/workflows/index.ts +71 -0
  511. package/src/workflows/language/binder.ts +45 -0
  512. package/src/workflows/language/canonical.ts +29 -0
  513. package/src/workflows/language/compiler.ts +396 -0
  514. package/src/workflows/language/expressions.ts +34 -0
  515. package/src/workflows/language/index.ts +20 -0
  516. package/src/workflows/language/references.ts +55 -0
  517. package/src/workflows/language/schema.ts +134 -0
  518. package/src/workflows/language/strict-yaml.ts +131 -0
  519. package/src/workflows/manifest.ts +24 -0
  520. package/src/workflows/module.ts +12 -0
  521. package/src/workflows/runtime/coordinator.ts +115 -0
  522. package/src/workflows/runtime/executor.ts +1003 -0
  523. package/src/workflows/runtime/index.ts +60 -0
  524. package/src/workflows/runtime/instant.ts +8 -0
  525. package/src/workflows/runtime/ports.ts +213 -0
  526. package/src/workflows/runtime/schedule.ts +203 -0
  527. package/src/workflows/runtime/trigger.ts +37 -0
  528. package/src/workflows/runtime/wake.ts +13 -0
  529. package/src/workflows/store/actions.ts +438 -0
  530. package/src/workflows/store/budget.ts +145 -0
  531. package/src/workflows/store/definitions.ts +345 -0
  532. package/src/workflows/store/events.ts +383 -0
  533. package/src/workflows/store/index.ts +107 -0
  534. package/src/workflows/store/observability.ts +594 -0
  535. package/src/workflows/store/runs.ts +975 -0
  536. package/src/workflows/store/transaction.ts +23 -0
  537. package/src/workflows/store/worker.ts +439 -0
  538. package/src/workflows/testing/conformance.ts +72 -0
  539. package/src/workflows/testing/index.ts +11 -0
  540. package/src/workflows/testing/process-fixtures.ts +275 -0
@@ -0,0 +1,3113 @@
1
+ import type { DoneReason, InboundEvent, LoopAggregate, Message, SessionStore, StoreEntry } from "@k2b/nessi";
2
+ import type { Usage } from "@k2b/nessi/ai";
3
+ import { sql } from "bun";
4
+ import { type CapabilityActionReview, CapabilityActionReviewSchema } from "../contracts/capabilities";
5
+ import { logger } from "../services/logging";
6
+ import { toPgTextArray } from "../services/postgres";
7
+ import type { AiTurnBlock } from "./protocol";
8
+ import { withAiShortId, withAiShortIdForDb } from "./short-id";
9
+ import type {
10
+ AiConversation,
11
+ AiConversationDraft,
12
+ AiConversationPage,
13
+ AiConversationResourceOccurrence,
14
+ AiConversationResourceRef,
15
+ AiConversationService,
16
+ AiConversationSource,
17
+ AiConversationTimelineEntry,
18
+ AiEnrichmentOverview,
19
+ AiEnrichmentOverviewRun,
20
+ AiEnrichmentRun,
21
+ AiFrontendToolMode,
22
+ AiInterChatMessage,
23
+ AiPendingTurnAction,
24
+ AiPendingTurnActionRecord,
25
+ AiStoredMessage,
26
+ AiToolPresentation,
27
+ AiTurn,
28
+ AiTurnClaim,
29
+ AiTurnRunConfig,
30
+ AiTurnStatus,
31
+ AiTurnSteer,
32
+ AiTurnSweepResult,
33
+ } from "./types";
34
+
35
+ /** A queued turn this old without a claim is considered lost and re-enqueued by the sweep. */
36
+ const SWEEP_STALE_QUEUED_MS = 15_000;
37
+ /** A queued turn this old that never started is failed outright. */
38
+ const SWEEP_DEAD_QUEUED_MS = 30 * 60_000;
39
+ const SEARCH_TEXT_MAX_CHARS = 50_000;
40
+ const CONVERSATION_BM25_INDEX = "ai.conversations_search_bm25_idx";
41
+ const MESSAGE_BM25_INDEX = "ai.messages_search_bm25_idx";
42
+ const SEARCH_CAPABILITY_ERROR_CODES = new Set(["0A000", "42704", "42883", "55000"]);
43
+ const log = logger("ai:conversation-search");
44
+
45
+ export const resolveAiTurnShortIds = async (input: {
46
+ conversationId: string;
47
+ turnIds: readonly string[];
48
+ }): Promise<Map<string, string>> => {
49
+ const turnIds = [...new Set(input.turnIds.filter(Boolean))];
50
+ if (turnIds.length === 0) return new Map();
51
+ const rows = await sql<{ id: string; short_id: string }[]>`
52
+ SELECT id::text AS id, short_id
53
+ FROM ai.turns
54
+ WHERE conversation_id = ${input.conversationId}::uuid
55
+ AND id::text = ANY(${toPgTextArray(turnIds)}::text[])
56
+ `;
57
+ return new Map(rows.map((row) => [row.id, row.short_id]));
58
+ };
59
+
60
+ type ConversationSearchBackend = "native" | "bm25";
61
+ let conversationSearchBackendPromise: Promise<ConversationSearchBackend> | null = null;
62
+
63
+ const detectConversationSearchBackend = async (): Promise<ConversationSearchBackend> => {
64
+ const [row] = await sql<{ available: boolean }[]>`
65
+ SELECT EXISTS (SELECT 1 FROM pg_extension WHERE extname = 'pg_textsearch')
66
+ AND EXISTS (
67
+ SELECT 1 FROM pg_class c JOIN pg_am am ON am.oid = c.relam
68
+ WHERE c.oid = to_regclass(${CONVERSATION_BM25_INDEX}) AND am.amname = 'bm25'
69
+ )
70
+ AND EXISTS (
71
+ SELECT 1 FROM pg_class c JOIN pg_am am ON am.oid = c.relam
72
+ WHERE c.oid = to_regclass(${MESSAGE_BM25_INDEX}) AND am.amname = 'bm25'
73
+ ) AS available
74
+ `;
75
+ return row?.available ? "bm25" : "native";
76
+ };
77
+
78
+ const getConversationSearchBackend = (): Promise<ConversationSearchBackend> => {
79
+ conversationSearchBackendPromise ??= detectConversationSearchBackend().catch((error) => {
80
+ log.warn("Conversation search backend detection failed; using native PostgreSQL FTS", {
81
+ error: error instanceof Error ? error.message : String(error),
82
+ });
83
+ return "native";
84
+ });
85
+ return conversationSearchBackendPromise;
86
+ };
87
+
88
+ const isSearchCapabilityError = (error: unknown): boolean =>
89
+ typeof error === "object" && error !== null && "code" in error && SEARCH_CAPABILITY_ERROR_CODES.has(String(error.code));
90
+
91
+ type ConversationRow = {
92
+ id: string;
93
+ short_id: string;
94
+ title: string;
95
+ title_source: string | null;
96
+ description: string | null;
97
+ description_source: string | null;
98
+ keywords: string[] | null;
99
+ pinned_at: Date | string | null;
100
+ archived_at: Date | string | null;
101
+ last_viewed_at: Date | string | null;
102
+ latest_turn_status?: AiTurnStatus | null;
103
+ latest_turn_error?: string | null;
104
+ latest_turn_completed_at?: Date | string | null;
105
+ enrich_fail_count: number | null;
106
+ project_id: string | null;
107
+ draft_content: unknown;
108
+ draft_revision: number | string;
109
+ draft_updated_at: Date | string | null;
110
+ created_by_user_id: string | null;
111
+ launched_by_app_id: string | null;
112
+ allowed_tools: string[] | null;
113
+ created_at: Date | string;
114
+ updated_at: Date | string;
115
+ };
116
+
117
+ type CountRow = {
118
+ total: number | string;
119
+ };
120
+
121
+ type EnrichmentRunRow = {
122
+ id: string;
123
+ conversation_id: string;
124
+ conversation_title?: string;
125
+ status: "ok" | "failed" | "skipped";
126
+ trigger: "scheduled" | "manual";
127
+ model_profile_id: string | null;
128
+ mode: string | null;
129
+ duration_ms: number | string | null;
130
+ title_updated: boolean;
131
+ keywords_count: number | string | null;
132
+ error: string | null;
133
+ created_at: Date | string;
134
+ };
135
+
136
+ type MessageRow = {
137
+ id: string;
138
+ short_id: string;
139
+ conversation_id: string;
140
+ seq: number;
141
+ kind: "message" | "summary";
142
+ message: unknown;
143
+ loop_id: string | null;
144
+ model_profile_id: string | null;
145
+ provider_model: string | null;
146
+ usage: unknown;
147
+ stop_reason: string | null;
148
+ loop_aggregate: unknown;
149
+ loop_done_reason: DoneReason | null;
150
+ compacted_at: Date | string | null;
151
+ meta: unknown;
152
+ feedback_rating: number | string | null;
153
+ feedback_reasons: string[] | null;
154
+ feedback_comment: string | null;
155
+ feedback_updated_at: Date | string | null;
156
+ created_at: Date | string;
157
+ };
158
+
159
+ type TurnRow = {
160
+ id: string;
161
+ short_id: string;
162
+ conversation_id: string;
163
+ status: AiTurnStatus;
164
+ attempt: number;
165
+ model_profile_id: string | null;
166
+ created_at: Date | string;
167
+ completed_at: Date | string | null;
168
+ error: string | null;
169
+ run_config?: unknown;
170
+ live_blocks?: unknown;
171
+ live_seq?: number | string;
172
+ lease_owner?: string | null;
173
+ lease_expires_at?: Date | string | null;
174
+ };
175
+
176
+ type PendingActionRow = {
177
+ turn_id: string;
178
+ conversation_id: string;
179
+ call_id: string;
180
+ kind: "approval" | "custom_approval" | "client_tool";
181
+ status: "pending" | "resolved" | "aborted";
182
+ tool_name: string;
183
+ args: unknown;
184
+ message: string | null;
185
+ review: unknown | null;
186
+ approval_scope: string;
187
+ allow_always: boolean;
188
+ frontend_mode: AiFrontendToolMode | null;
189
+ resolved_event: unknown | null;
190
+ };
191
+
192
+ type TurnSteerRow = {
193
+ id: string;
194
+ conversation_id: string;
195
+ turn_id: string;
196
+ seq: number;
197
+ client_request_id: string;
198
+ text: string;
199
+ status: "pending" | "consumed" | "discarded";
200
+ message_id: string | null;
201
+ created_at: Date | string;
202
+ consumed_at: Date | string | null;
203
+ };
204
+
205
+ type ConversationResourceRefRow = {
206
+ resource_type: string;
207
+ resource_id: string;
208
+ title: string | null;
209
+ preview: string | null;
210
+ icon: string | null;
211
+ href: string | null;
212
+ source_turn_id: string | null;
213
+ source_call_id: string | null;
214
+ first_seen_at: Date | string;
215
+ last_seen_at: Date | string;
216
+ };
217
+
218
+ type ConversationResourceOccurrenceRow = ConversationResourceRefRow & {
219
+ conversation_short_id: string;
220
+ conversation_title: string;
221
+ conversation_updated_at: Date | string;
222
+ };
223
+
224
+ type ConversationSourceRow = {
225
+ source_kind: AiConversationSource["kind"];
226
+ source_key: string;
227
+ title: string;
228
+ preview: string | null;
229
+ icon: string | null;
230
+ href: string | null;
231
+ path: string | null;
232
+ media_type: string | null;
233
+ size: number | string | null;
234
+ resource_type: string | null;
235
+ resource_id: string | null;
236
+ occurrences: number | string;
237
+ source_turn_id: string | null;
238
+ source_call_id: string | null;
239
+ first_seen_at: Date | string;
240
+ last_seen_at: Date | string;
241
+ };
242
+
243
+ type InterChatMessageRow = {
244
+ id: string;
245
+ short_id: string;
246
+ source_conversation_id: string;
247
+ source_chat_id: string;
248
+ source_title: string;
249
+ source_turn_id: string;
250
+ source_turn_short_id: string;
251
+ source_call_id: string;
252
+ target_conversation_id: string;
253
+ target_chat_id: string;
254
+ target_title: string;
255
+ actor_user_id: string;
256
+ text: string;
257
+ status: "pending" | "delivered" | "failed";
258
+ target_turn_id: string | null;
259
+ target_turn_short_id: string | null;
260
+ target_message_id: string | null;
261
+ error: string | null;
262
+ created_at: Date | string;
263
+ delivered_at: Date | string | null;
264
+ };
265
+
266
+ type TimelineRow = {
267
+ id: string;
268
+ seq: number;
269
+ loop_id: string | null;
270
+ user_preview: string | null;
271
+ assistant_preview: string | null;
272
+ is_steer: boolean;
273
+ input_file_count: number | string | null;
274
+ output_file_count: number | string | null;
275
+ tool_count: number | string | null;
276
+ created_at: Date | string;
277
+ };
278
+
279
+ const iso = (value: Date | string): string => (value instanceof Date ? value.toISOString() : new Date(value).toISOString());
280
+
281
+ const rowToConversationResourceRef = (row: ConversationResourceRefRow): AiConversationResourceRef => ({
282
+ ref: { type: row.resource_type, id: row.resource_id },
283
+ title: row.title,
284
+ preview: row.preview,
285
+ icon: row.icon,
286
+ href: row.href,
287
+ sourceTurnId: row.source_turn_id,
288
+ sourceCallId: row.source_call_id,
289
+ firstSeenAt: iso(row.first_seen_at),
290
+ lastSeenAt: iso(row.last_seen_at),
291
+ });
292
+
293
+ type ResourceCursor = { at: string; type: string; id: string };
294
+ const encodeResourceCursor = (row: ConversationResourceRefRow): string =>
295
+ encodeURIComponent(JSON.stringify({ at: iso(row.last_seen_at), type: row.resource_type, id: row.resource_id } satisfies ResourceCursor));
296
+ const encodeSourceCursor = (row: ConversationSourceRow): string =>
297
+ encodeURIComponent(JSON.stringify({ at: iso(row.last_seen_at), type: row.source_kind, id: row.source_key } satisfies ResourceCursor));
298
+ const decodeResourceCursor = (value: string | undefined): ResourceCursor | null => {
299
+ if (!value) return null;
300
+ try {
301
+ const parsed = JSON.parse(decodeURIComponent(value)) as Partial<ResourceCursor>;
302
+ return typeof parsed.at === "string" &&
303
+ !Number.isNaN(Date.parse(parsed.at)) &&
304
+ typeof parsed.type === "string" &&
305
+ typeof parsed.id === "string"
306
+ ? { at: parsed.at, type: parsed.type, id: parsed.id }
307
+ : null;
308
+ } catch {
309
+ return null;
310
+ }
311
+ };
312
+
313
+ const rowToConversationResourceOccurrence = (row: ConversationResourceOccurrenceRow): AiConversationResourceOccurrence => ({
314
+ ...rowToConversationResourceRef(row),
315
+ chat: {
316
+ shortId: row.conversation_short_id,
317
+ title: row.conversation_title,
318
+ updatedAt: iso(row.conversation_updated_at),
319
+ },
320
+ });
321
+
322
+ const rowToConversationSource = (row: ConversationSourceRow): AiConversationSource => ({
323
+ kind: row.source_kind,
324
+ key: row.source_key,
325
+ title: row.title,
326
+ preview: row.preview,
327
+ icon: row.icon || "ti ti-link",
328
+ href: row.href,
329
+ path: row.path,
330
+ mediaType: row.media_type,
331
+ size: row.size === null ? null : Number(row.size),
332
+ ref: row.resource_type && row.resource_id ? { type: row.resource_type, id: row.resource_id } : null,
333
+ occurrences: Number(row.occurrences),
334
+ firstSeenAt: iso(row.first_seen_at),
335
+ lastSeenAt: iso(row.last_seen_at),
336
+ sourceTurnId: row.source_turn_id,
337
+ sourceCallId: row.source_call_id,
338
+ });
339
+
340
+ const rowToInterChatMessage = (row: InterChatMessageRow): AiInterChatMessage => ({
341
+ id: row.id,
342
+ shortId: row.short_id,
343
+ sourceConversationId: row.source_conversation_id,
344
+ sourceChatId: row.source_chat_id,
345
+ sourceTitle: row.source_title,
346
+ sourceTurnId: row.source_turn_id,
347
+ sourceTurnShortId: row.source_turn_short_id,
348
+ sourceCallId: row.source_call_id,
349
+ targetConversationId: row.target_conversation_id,
350
+ targetChatId: row.target_chat_id,
351
+ targetTitle: row.target_title,
352
+ actorUserId: row.actor_user_id,
353
+ text: row.text,
354
+ status: row.status,
355
+ targetTurnId: row.target_turn_id,
356
+ targetTurnShortId: row.target_turn_short_id,
357
+ targetMessageId: row.target_message_id,
358
+ error: row.error,
359
+ createdAt: iso(row.created_at),
360
+ deliveredAt: row.delivered_at ? iso(row.delivered_at) : null,
361
+ });
362
+
363
+ const interChatMessageSelect = sql`
364
+ SELECT message.id, message.short_id, message.source_conversation_id,
365
+ source.short_id AS source_chat_id, source.title AS source_title,
366
+ message.source_turn_id, source_turn.short_id AS source_turn_short_id, message.source_call_id,
367
+ message.target_conversation_id, target.short_id AS target_chat_id, target.title AS target_title,
368
+ message.actor_user_id, message.text, message.status, message.target_turn_id,
369
+ target_turn.short_id AS target_turn_short_id, message.target_message_id, message.error,
370
+ message.created_at, message.delivered_at
371
+ FROM ai.inter_chat_messages message
372
+ JOIN ai.conversations source ON source.id = message.source_conversation_id
373
+ JOIN ai.turns source_turn ON source_turn.id = message.source_turn_id
374
+ JOIN ai.conversations target ON target.id = message.target_conversation_id
375
+ LEFT JOIN ai.turns target_turn ON target_turn.id = message.target_turn_id
376
+ `;
377
+
378
+ type ResourceOccurrenceCursor = ResourceCursor & { chat: string };
379
+ const encodeResourceOccurrenceCursor = (row: ConversationResourceOccurrenceRow): string =>
380
+ encodeURIComponent(
381
+ JSON.stringify({
382
+ at: iso(row.last_seen_at),
383
+ type: row.resource_type,
384
+ id: row.resource_id,
385
+ chat: row.conversation_short_id,
386
+ } satisfies ResourceOccurrenceCursor),
387
+ );
388
+ const decodeResourceOccurrenceCursor = (value: string | undefined): ResourceOccurrenceCursor | null => {
389
+ if (!value) return null;
390
+ try {
391
+ const parsed = JSON.parse(decodeURIComponent(value)) as Partial<ResourceOccurrenceCursor>;
392
+ return typeof parsed.at === "string" &&
393
+ !Number.isNaN(Date.parse(parsed.at)) &&
394
+ typeof parsed.type === "string" &&
395
+ typeof parsed.id === "string" &&
396
+ typeof parsed.chat === "string"
397
+ ? { at: parsed.at, type: parsed.type, id: parsed.id, chat: parsed.chat }
398
+ : null;
399
+ } catch {
400
+ return null;
401
+ }
402
+ };
403
+
404
+ const sanitizePagination = (input: { page: number; perPage: number }) => {
405
+ const page = Number.isInteger(input.page) && input.page > 0 ? input.page : 1;
406
+ const perPage = Number.isInteger(input.perPage) && input.perPage > 0 ? Math.min(input.perPage, 100) : 20;
407
+ return { page, perPage, offset: (page - 1) * perPage };
408
+ };
409
+
410
+ const searchPattern = (value: string | undefined): string | null => {
411
+ const trimmed = value?.trim().toLowerCase();
412
+ return trimmed ? `%${trimmed}%` : null;
413
+ };
414
+
415
+ const conversationSearchRank = (backend: ConversationSearchBackend, query: string) => {
416
+ const exactBoost = sql`
417
+ CASE
418
+ WHEN LOWER(conversation.title) = LOWER(${query}) THEN 40
419
+ WHEN LOWER(conversation.title) LIKE ${`%${query.toLowerCase()}%`} THEN 20
420
+ WHEN LOWER(conversation.search_summary) LIKE ${`%${query.toLowerCase()}%`} THEN 8
421
+ WHEN LOWER(conversation.description) LIKE ${`%${query.toLowerCase()}%`} THEN 4
422
+ ELSE 0
423
+ END
424
+ `;
425
+ if (backend === "bm25") {
426
+ return sql`${exactBoost}
427
+ - (conversation.search_text <@> to_bm25query(${query}, ${CONVERSATION_BM25_INDEX}))
428
+ + COALESCE((
429
+ SELECT MAX(-(message.search_text <@> to_bm25query(${query}, ${MESSAGE_BM25_INDEX})))
430
+ FROM ai.messages message
431
+ WHERE message.conversation_id = conversation.id AND message.search_text <> ''
432
+ ), 0)`;
433
+ }
434
+ return sql`${exactBoost}
435
+ + ts_rank_cd(conversation.search_document, websearch_to_tsquery('simple', ${query})) * 10
436
+ + COALESCE((
437
+ SELECT MAX(ts_rank_cd(message.search_document, websearch_to_tsquery('simple', ${query})))
438
+ FROM ai.messages message
439
+ WHERE message.conversation_id = conversation.id
440
+ ), 0)`;
441
+ };
442
+
443
+ const withConversationSearchBackend = async <T>(
444
+ query: string | null,
445
+ run: (backend: ConversationSearchBackend) => Promise<T>,
446
+ ): Promise<T> => {
447
+ const backend = query ? await getConversationSearchBackend() : "native";
448
+ try {
449
+ return await run(backend);
450
+ } catch (error) {
451
+ if (backend !== "bm25" || !isSearchCapabilityError(error)) throw error;
452
+ log.warn("Conversation BM25 query failed; falling back to native PostgreSQL FTS", {
453
+ error: error instanceof Error ? error.message : String(error),
454
+ });
455
+ conversationSearchBackendPromise = Promise.resolve("native");
456
+ return run("native");
457
+ }
458
+ };
459
+
460
+ const parseJsonValue = <T>(value: unknown): T => {
461
+ if (typeof value === "string") return JSON.parse(value) as T;
462
+ return value as T;
463
+ };
464
+
465
+ const parseCapabilityActionReview = (value: unknown): CapabilityActionReview | undefined => {
466
+ if (value === null || value === undefined) return undefined;
467
+ const parsed = CapabilityActionReviewSchema.safeParse(parseJsonValue(value));
468
+ return parsed.success ? parsed.data : undefined;
469
+ };
470
+
471
+ const fieldSource = (value: string | null): AiConversation["titleSource"] => (value === "auto" || value === "user" ? value : "default");
472
+
473
+ const conversationRunStatus = (status: AiTurnStatus | null | undefined): AiConversation["runStatus"] => {
474
+ if (status === "queued") return "queued";
475
+ if (status === "running") return "running";
476
+ if (status === "waiting_for_action") return "needs_attention";
477
+ if (status === "failed") return "failed";
478
+ return "idle";
479
+ };
480
+
481
+ const rowToConversation = (row: ConversationRow): AiConversation => ({
482
+ id: row.id,
483
+ shortId: row.short_id,
484
+ title: row.title,
485
+ titleSource: fieldSource(row.title_source),
486
+ description: row.description ?? "",
487
+ descriptionSource: fieldSource(row.description_source),
488
+ keywords: row.keywords ?? [],
489
+ pinnedAt: row.pinned_at ? iso(row.pinned_at) : null,
490
+ archivedAt: row.archived_at ? iso(row.archived_at) : null,
491
+ runStatus: conversationRunStatus(row.latest_turn_status),
492
+ runError: row.latest_turn_status === "failed" ? row.latest_turn_error?.trim() || "Assistant response failed." : null,
493
+ unreadCompletion:
494
+ row.latest_turn_status === "completed" &&
495
+ Boolean(row.latest_turn_completed_at) &&
496
+ (!row.last_viewed_at || new Date(row.latest_turn_completed_at!).getTime() > new Date(row.last_viewed_at).getTime()),
497
+ projectId: row.project_id,
498
+ draft: {
499
+ content: parseJsonValue<AiConversationDraft["content"]>(row.draft_content ?? []),
500
+ revision: Number(row.draft_revision ?? 0),
501
+ updatedAt: row.draft_updated_at ? iso(row.draft_updated_at) : null,
502
+ },
503
+ createdByUserId: row.created_by_user_id,
504
+ launchedByAppId: row.launched_by_app_id,
505
+ allowedTools: row.allowed_tools ?? null,
506
+ createdAt: iso(row.created_at),
507
+ updatedAt: iso(row.updated_at),
508
+ });
509
+
510
+ const rowToEnrichmentRun = (row: EnrichmentRunRow): AiEnrichmentRun => ({
511
+ id: row.id,
512
+ conversationId: row.conversation_id,
513
+ status: row.status,
514
+ trigger: row.trigger,
515
+ modelProfileId: row.model_profile_id,
516
+ mode: row.mode,
517
+ durationMs: row.duration_ms === null ? null : Number(row.duration_ms),
518
+ titleUpdated: row.title_updated,
519
+ keywordsCount: Number(row.keywords_count ?? 0),
520
+ error: row.error,
521
+ createdAt: iso(row.created_at),
522
+ });
523
+
524
+ const rowToEnrichmentOverviewRun = (row: EnrichmentRunRow): AiEnrichmentOverviewRun => ({
525
+ ...rowToEnrichmentRun(row),
526
+ conversationTitle: row.conversation_title ?? "",
527
+ });
528
+
529
+ const numberOrNull = (value: number | string | null | undefined): number | null => {
530
+ if (value === null || value === undefined) return null;
531
+ const parsed = Number(value);
532
+ return Number.isFinite(parsed) ? parsed : null;
533
+ };
534
+
535
+ const intValue = (value: number | string | null | undefined): number => Math.max(0, Math.trunc(numberOrNull(value) ?? 0));
536
+
537
+ const rowToMessage = (row: MessageRow): AiStoredMessage => {
538
+ const message = parseJsonValue<Message>(row.message);
539
+ return {
540
+ id: row.id,
541
+ shortId: row.short_id,
542
+ conversationId: row.conversation_id,
543
+ seq: row.seq,
544
+ kind: row.kind,
545
+ message,
546
+ loopId: row.loop_id,
547
+ modelProfileId: row.model_profile_id,
548
+ providerModel: row.provider_model,
549
+ usage: row.usage ? parseJsonValue<Usage>(row.usage) : null,
550
+ stopReason: row.stop_reason,
551
+ loopAggregate: row.loop_aggregate ? parseJsonValue<LoopAggregate>(row.loop_aggregate) : null,
552
+ loopDoneReason: row.loop_done_reason,
553
+ compactedAt: row.compacted_at ? iso(row.compacted_at) : null,
554
+ meta: row.meta ? parseJsonValue<AiStoredMessage["meta"]>(row.meta) : null,
555
+ feedback:
556
+ Number(row.feedback_rating) === 1 || Number(row.feedback_rating) === -1
557
+ ? {
558
+ rating: Number(row.feedback_rating) === 1 ? "up" : "down",
559
+ reasons: (row.feedback_reasons ?? []) as NonNullable<AiStoredMessage["feedback"]>["reasons"],
560
+ comment: row.feedback_comment,
561
+ updatedAt: iso(row.feedback_updated_at!),
562
+ }
563
+ : null,
564
+ createdAt: iso(row.created_at),
565
+ };
566
+ };
567
+
568
+ const rowToTurn = (row: TurnRow): AiTurn => ({
569
+ id: row.id,
570
+ shortId: row.short_id,
571
+ conversationId: row.conversation_id,
572
+ status: row.status,
573
+ attempt: Number(row.attempt ?? 0),
574
+ modelProfileId: row.model_profile_id,
575
+ createdAt: iso(row.created_at),
576
+ completedAt: row.completed_at ? iso(row.completed_at) : null,
577
+ error: row.error,
578
+ });
579
+
580
+ const rowToLiveBlocks = (row: TurnRow): AiTurnBlock[] => {
581
+ if (!row.live_blocks) return [];
582
+ const parsed = parseJsonValue<AiTurnBlock[]>(row.live_blocks);
583
+ return Array.isArray(parsed) ? parsed : [];
584
+ };
585
+
586
+ const pendingActionToPublicEvent = (row: PendingActionRow): AiPendingTurnAction =>
587
+ row.kind === "client_tool"
588
+ ? {
589
+ type: "frontend_tool",
590
+ turnId: row.turn_id,
591
+ conversationId: row.conversation_id,
592
+ callId: row.call_id,
593
+ name: row.tool_name,
594
+ args: parseJsonValue(row.args),
595
+ mode: row.frontend_mode ?? "client",
596
+ }
597
+ : {
598
+ type: "approval_request",
599
+ turnId: row.turn_id,
600
+ conversationId: row.conversation_id,
601
+ callId: row.call_id,
602
+ name: row.tool_name,
603
+ args: parseJsonValue(row.args),
604
+ message: row.message ?? undefined,
605
+ review: parseCapabilityActionReview(row.review),
606
+ allowAlways: row.allow_always,
607
+ };
608
+
609
+ const rowToPendingActionRecord = (row: PendingActionRow): AiPendingTurnActionRecord => ({
610
+ turnId: row.turn_id,
611
+ conversationId: row.conversation_id,
612
+ callId: row.call_id,
613
+ kind: row.kind,
614
+ status: row.status,
615
+ name: row.tool_name,
616
+ args: parseJsonValue(row.args),
617
+ message: row.message ?? undefined,
618
+ review: parseCapabilityActionReview(row.review),
619
+ approvalScope: row.approval_scope,
620
+ allowAlways: row.allow_always,
621
+ frontendMode: row.frontend_mode ?? undefined,
622
+ resolvedEvent: row.resolved_event ? parseJsonValue<InboundEvent>(row.resolved_event) : null,
623
+ });
624
+
625
+ const rowToTurnSteer = (row: TurnSteerRow): AiTurnSteer => ({
626
+ id: row.id,
627
+ conversationId: row.conversation_id,
628
+ turnId: row.turn_id,
629
+ seq: Number(row.seq),
630
+ clientRequestId: row.client_request_id,
631
+ text: row.text,
632
+ status: row.status,
633
+ messageId: row.message_id,
634
+ createdAt: iso(row.created_at),
635
+ consumedAt: row.consumed_at ? iso(row.consumed_at) : null,
636
+ });
637
+
638
+ const boundedMs = (value: number, fallback: number, min: number, max: number): number => {
639
+ if (!Number.isFinite(value)) return fallback;
640
+ return Math.min(Math.max(Math.floor(value), min), max);
641
+ };
642
+
643
+ const loadConversationSummary = async (input: {
644
+ conversationId?: string;
645
+ shortId?: string;
646
+ ownerUserId?: string;
647
+ archived?: boolean;
648
+ }): Promise<AiConversation | null> => {
649
+ const rows = await sql<ConversationRow[]>`
650
+ SELECT
651
+ conversation.*,
652
+ latest.status AS latest_turn_status,
653
+ latest.error AS latest_turn_error,
654
+ latest.completed_at AS latest_turn_completed_at
655
+ FROM ai.conversations conversation
656
+ LEFT JOIN LATERAL (
657
+ SELECT status, error, completed_at
658
+ FROM ai.turns
659
+ WHERE conversation_id = conversation.id
660
+ ORDER BY created_at DESC, id DESC
661
+ LIMIT 1
662
+ ) latest ON TRUE
663
+ WHERE (${input.conversationId ?? null}::uuid IS NULL OR conversation.id = ${input.conversationId ?? null}::uuid)
664
+ AND (${input.shortId ?? null}::text IS NULL OR conversation.short_id = ${input.shortId ?? null})
665
+ AND (${input.ownerUserId ?? null}::uuid IS NULL OR conversation.created_by_user_id = ${input.ownerUserId ?? null})
666
+ AND (${Boolean(input.archived)}::boolean = (conversation.archived_at IS NOT NULL))
667
+ LIMIT 1
668
+ `;
669
+ return rows[0] ? rowToConversation(rows[0]) : null;
670
+ };
671
+
672
+ const firstText = (message: Message): string => {
673
+ if (message.role !== "user") return "";
674
+ const part = message.content.find(
675
+ (entry): entry is string | { type: "text"; text: string } => typeof entry === "string" || entry.type === "text",
676
+ );
677
+ if (!part) return "";
678
+ const text = typeof part === "string" ? part : part.text;
679
+ return text.trim().replace(/\s+/g, " ").slice(0, 80);
680
+ };
681
+
682
+ const messageColumns = (message: Message) => ({
683
+ usage: message.role === "assistant" ? (message.usage ?? null) : null,
684
+ providerModel: message.role === "assistant" ? (message.model ?? null) : null,
685
+ stopReason: message.role === "assistant" ? (message.stopReason ?? null) : null,
686
+ });
687
+
688
+ const messageSearchText = (message: Message): string => {
689
+ if (message.role === "tool_result") return "";
690
+ const text = message.content
691
+ .map((part) => {
692
+ if (typeof part === "string") return part;
693
+ if (part.type === "text") return part.text;
694
+ return "";
695
+ })
696
+ .filter(Boolean)
697
+ .join("\n")
698
+ .trim();
699
+ return text.slice(0, SEARCH_TEXT_MAX_CHARS);
700
+ };
701
+
702
+ /** Insert a message inside an open conversation-lock transaction and bump the conversation. */
703
+ const insertMessageLocked = async (
704
+ input: {
705
+ conversationId: string;
706
+ message: Message;
707
+ kind?: "message" | "summary";
708
+ seq?: number;
709
+ loopId?: string | null;
710
+ modelProfileId?: string | null;
711
+ meta?: AiStoredMessage["meta"];
712
+ },
713
+ db: typeof sql = sql,
714
+ ): Promise<MessageRow> => {
715
+ const { usage, providerModel, stopReason } = messageColumns(input.message);
716
+ const seqRows = input.seq
717
+ ? [{ seq: input.seq }]
718
+ : await db<
719
+ { seq: number }[]
720
+ >`SELECT COALESCE(MAX(seq), 0) + 1 AS seq FROM ai.messages WHERE conversation_id = ${input.conversationId} AND seq > 0`;
721
+ const seq = seqRows[0]?.seq ?? 1;
722
+
723
+ const rows = await withAiShortIdForDb(
724
+ db,
725
+ "idx_ai_messages_conversation_short_id",
726
+ (attempt, shortId) => attempt<MessageRow[]>`
727
+ INSERT INTO ai.messages (
728
+ short_id,
729
+ conversation_id,
730
+ seq,
731
+ kind,
732
+ role,
733
+ message,
734
+ search_text,
735
+ loop_id,
736
+ model_profile_id,
737
+ provider_model,
738
+ usage,
739
+ stop_reason,
740
+ meta
741
+ )
742
+ VALUES (
743
+ ${shortId},
744
+ ${input.conversationId},
745
+ ${seq},
746
+ ${input.kind ?? "message"},
747
+ ${input.message.role},
748
+ ${JSON.stringify(input.message)}::jsonb,
749
+ ${messageSearchText(input.message)},
750
+ ${input.loopId ?? null},
751
+ ${input.modelProfileId ?? null},
752
+ ${providerModel},
753
+ ${usage ? JSON.stringify(usage) : null}::jsonb,
754
+ ${stopReason},
755
+ ${input.meta ? JSON.stringify(input.meta) : null}::jsonb
756
+ )
757
+ RETURNING *
758
+ `,
759
+ );
760
+
761
+ const title = firstText(input.message);
762
+ if (seq === 1 && title) {
763
+ // First-message snapshot title stays title_source 'default': it is a
764
+ // placeholder ("Hi", …) that the enrichment job may replace freely.
765
+ // 'auto' is reserved for enrichment-set titles.
766
+ await db`
767
+ UPDATE ai.conversations
768
+ SET title = ${title}, updated_at = now()
769
+ WHERE id = ${input.conversationId}
770
+ `;
771
+ } else {
772
+ await db`UPDATE ai.conversations SET updated_at = now() WHERE id = ${input.conversationId}`;
773
+ }
774
+ return rows[0]!;
775
+ };
776
+
777
+ /** Append a turn-owned (assistant/tool_result/summary) message, guarded by lease ownership in one statement. */
778
+ const appendTurnOwnedMessage = async (input: {
779
+ conversationId: string;
780
+ turnId: string;
781
+ leaseOwner: string;
782
+ message: Message;
783
+ kind?: "message" | "summary";
784
+ seq?: number;
785
+ loopId: string | null;
786
+ modelProfileId?: string | null;
787
+ meta?: AiStoredMessage["meta"];
788
+ }): Promise<boolean> => {
789
+ const { usage, providerModel, stopReason } = messageColumns(input.message);
790
+ const rows = await withAiShortId(
791
+ "idx_ai_messages_conversation_short_id",
792
+ (shortId) => sql<{ id: string }[]>`
793
+ INSERT INTO ai.messages (
794
+ short_id,
795
+ conversation_id,
796
+ seq,
797
+ kind,
798
+ role,
799
+ message,
800
+ loop_id,
801
+ model_profile_id,
802
+ provider_model,
803
+ usage,
804
+ stop_reason,
805
+ meta
806
+ )
807
+ SELECT
808
+ ${shortId},
809
+ ${input.conversationId},
810
+ CASE
811
+ WHEN ${input.seq ?? null}::int IS NOT NULL AND ${input.seq ?? null}::int > 0 THEN ${input.seq ?? null}::int
812
+ ELSE (SELECT COALESCE(MAX(seq), 0) + 1 FROM ai.messages WHERE conversation_id = ${input.conversationId} AND seq > 0)
813
+ END,
814
+ ${input.kind ?? "message"},
815
+ ${input.message.role},
816
+ ${JSON.stringify(input.message)}::jsonb,
817
+ ${input.loopId},
818
+ ${input.modelProfileId ?? null},
819
+ ${providerModel},
820
+ ${usage ? JSON.stringify(usage) : null}::jsonb,
821
+ ${stopReason},
822
+ ${input.meta ? JSON.stringify(input.meta) : null}::jsonb
823
+ WHERE EXISTS (
824
+ SELECT 1
825
+ FROM ai.turns
826
+ WHERE id = ${input.turnId}
827
+ AND conversation_id = ${input.conversationId}
828
+ AND status IN ('running', 'waiting_for_action')
829
+ AND lease_owner = ${input.leaseOwner}
830
+ )
831
+ RETURNING id
832
+ `,
833
+ );
834
+ if (rows[0]) {
835
+ await sql`UPDATE ai.conversations SET updated_at = now() WHERE id = ${input.conversationId}`;
836
+ return true;
837
+ }
838
+ return false;
839
+ };
840
+
841
+ const toolMessageMeta = (
842
+ message: Message,
843
+ presentations: ReadonlyMap<string, AiToolPresentation> | undefined,
844
+ rejectedToolCallIds: ReadonlySet<string> | undefined,
845
+ ): AiStoredMessage["meta"] => {
846
+ if (message.role === "tool_result" && rejectedToolCallIds?.has(message.callId)) {
847
+ return { toolOutcomes: { [message.callId]: "rejected" } };
848
+ }
849
+ if (message.role !== "assistant" || !presentations || presentations.size === 0) return null;
850
+ const toolPresentations = Object.fromEntries(
851
+ message.content.flatMap((block) => {
852
+ if (block.type !== "tool_call") return [];
853
+ const presentation = presentations.get(block.name);
854
+ return presentation ? ([[block.id, presentation]] as const) : [];
855
+ }),
856
+ );
857
+ return Object.keys(toolPresentations).length > 0 ? { toolPresentations } : null;
858
+ };
859
+
860
+ export const aiConversations: AiConversationService = {
861
+ createConversation: async (input) => {
862
+ const rows = await withAiShortId(
863
+ "idx_ai_conversations_short_id",
864
+ (shortId) => sql<ConversationRow[]>`
865
+ INSERT INTO ai.conversations (
866
+ short_id,
867
+ title,
868
+ description,
869
+ project_id,
870
+ draft_content,
871
+ draft_revision,
872
+ loaded_tools,
873
+ draft_updated_at,
874
+ created_by_user_id,
875
+ launched_by_app_id,
876
+ allowed_tools
877
+ )
878
+ SELECT
879
+ ${shortId},
880
+ ${input.title?.trim() || "New chat"},
881
+ ${input.description?.trim() ?? ""},
882
+ ${input.projectId ?? null}::uuid,
883
+ ${JSON.stringify(input.draft ?? [])}::jsonb,
884
+ ${input.draft?.length ? 1 : 0},
885
+ ${toPgTextArray(input.preloadTools ?? [])}::text[],
886
+ ${input.draft?.length ? new Date() : null},
887
+ ${input.ownerUserId},
888
+ ${input.launchedByAppId?.trim() || null},
889
+ ${input.allowedTools === undefined ? null : toPgTextArray(input.allowedTools)}::text[]
890
+ WHERE ${input.projectId ?? null}::uuid IS NULL
891
+ OR EXISTS (SELECT 1 FROM ai.projects project WHERE project.id = ${input.projectId ?? null}::uuid)
892
+ RETURNING *
893
+ `,
894
+ );
895
+ if (!rows[0]) throw new Error("Project does not exist.");
896
+ return rowToConversation(rows[0]);
897
+ },
898
+
899
+ forkConversation: async (input) => {
900
+ return sql.begin(async (tx) => {
901
+ const source = await tx<ConversationRow[]>`
902
+ SELECT * FROM ai.conversations WHERE id = ${input.sourceConversationId}::uuid FOR SHARE
903
+ `;
904
+ if (!source[0]) throw new Error("Source conversation no longer exists.");
905
+ const rows = await withAiShortIdForDb(
906
+ tx,
907
+ "idx_ai_conversations_short_id",
908
+ (attempt, shortId) => attempt<ConversationRow[]>`
909
+ INSERT INTO ai.conversations (
910
+ short_id, title, description, project_id, created_by_user_id, allowed_tools
911
+ )
912
+ SELECT
913
+ ${shortId}, ${input.title?.trim() || source[0]!.title}, description, project_id, ${input.ownerUserId}::uuid, allowed_tools
914
+ FROM ai.conversations WHERE id = ${input.sourceConversationId}::uuid
915
+ RETURNING *
916
+ `,
917
+ );
918
+ const target = rows[0];
919
+ if (!target) throw new Error("Failed to fork conversation.");
920
+ await tx`
921
+ INSERT INTO ai.messages (
922
+ short_id, conversation_id, seq, kind, role, message, search_text, loop_id,
923
+ model_profile_id, provider_model, usage, stop_reason, loop_aggregate, loop_done_reason
924
+ )
925
+ SELECT
926
+ short_id, ${target.id}::uuid, seq, kind, role, message, search_text, loop_id,
927
+ model_profile_id, provider_model, usage, stop_reason, loop_aggregate, loop_done_reason
928
+ FROM ai.messages
929
+ WHERE conversation_id = ${input.sourceConversationId}::uuid
930
+ AND compacted_at IS NULL
931
+ AND seq <= ${Math.floor(input.throughSeq)}
932
+ ORDER BY seq ASC
933
+ `;
934
+ await tx`
935
+ INSERT INTO ai.files (conversation_id, path, bytes, media_type, size, origin, updated_at)
936
+ SELECT ${target.id}::uuid, path, bytes, media_type, size, origin, updated_at
937
+ FROM ai.files WHERE conversation_id = ${input.sourceConversationId}::uuid
938
+ `;
939
+ return rowToConversation(target);
940
+ });
941
+ },
942
+
943
+ listConversations: async (input) => {
944
+ const pattern = searchPattern(input.search);
945
+ const query = input.search?.trim() || null;
946
+ const archived = Boolean(input.archived);
947
+ const status = input.status ?? null;
948
+ const limit = input.limit && input.limit > 0 ? Math.min(input.limit, 500) : 100;
949
+ const refs = input.refs ?? [];
950
+ const rows = await withConversationSearchBackend(query, async (backend) => {
951
+ const order = query
952
+ ? sql`${conversationSearchRank(backend, query)} DESC, conversation.pinned_at DESC NULLS LAST, conversation.updated_at DESC, conversation.created_at DESC`
953
+ : sql`conversation.pinned_at DESC NULLS LAST, conversation.updated_at DESC, conversation.created_at DESC`;
954
+ return sql<ConversationRow[]>`
955
+ SELECT
956
+ conversation.*,
957
+ latest.status AS latest_turn_status,
958
+ latest.error AS latest_turn_error,
959
+ latest.completed_at AS latest_turn_completed_at
960
+ FROM ai.conversations conversation
961
+ LEFT JOIN LATERAL (
962
+ SELECT status, error, completed_at
963
+ FROM ai.turns
964
+ WHERE conversation_id = conversation.id
965
+ ORDER BY created_at DESC, id DESC
966
+ LIMIT 1
967
+ ) latest ON TRUE
968
+ WHERE conversation.created_by_user_id = ${input.ownerUserId}
969
+ AND (${archived}::boolean = (conversation.archived_at IS NOT NULL))
970
+ AND (${input.projectId ?? null}::uuid IS NULL OR conversation.project_id = ${input.projectId ?? null}::uuid)
971
+ AND (NOT ${Boolean(input.unassigned)}::boolean OR conversation.project_id IS NULL)
972
+ AND (${refs.length === 0}::boolean OR NOT EXISTS (
973
+ SELECT 1
974
+ FROM jsonb_to_recordset((${JSON.stringify(refs)}::text)::jsonb) requested(type text, id text)
975
+ WHERE NOT EXISTS (
976
+ SELECT 1
977
+ FROM ai.conversation_resource_refs indexed
978
+ WHERE indexed.conversation_id = conversation.id
979
+ AND indexed.resource_type = requested.type
980
+ AND indexed.resource_id = requested.id
981
+ )
982
+ ))
983
+ AND (${pattern}::text IS NULL
984
+ OR LOWER(conversation.title) LIKE ${pattern}
985
+ OR LOWER(conversation.description) LIKE ${pattern}
986
+ OR LOWER(conversation.search_summary) LIKE ${pattern}
987
+ OR LOWER(array_to_string(conversation.keywords, ' ')) LIKE ${pattern}
988
+ OR conversation.search_document @@ websearch_to_tsquery('simple', ${query ?? ""})
989
+ OR EXISTS (
990
+ SELECT 1 FROM ai.messages message
991
+ WHERE message.conversation_id = conversation.id
992
+ AND (LOWER(message.search_text) LIKE ${pattern}
993
+ OR message.search_document @@ websearch_to_tsquery('simple', ${query ?? ""}))
994
+ ))
995
+ AND (${status}::text IS NULL
996
+ OR (${status} = 'running' AND latest.status IN ('queued', 'running'))
997
+ OR (${status} = 'needs_attention' AND latest.status = 'waiting_for_action')
998
+ OR (${status} = 'failed' AND latest.status = 'failed')
999
+ OR (${status} = 'unread' AND latest.status = 'completed' AND latest.completed_at > COALESCE(conversation.last_viewed_at, '-infinity')))
1000
+ ORDER BY ${order}
1001
+ LIMIT ${limit}
1002
+ `;
1003
+ });
1004
+ return rows.map(rowToConversation);
1005
+ },
1006
+
1007
+ listSidebarConversations: async (input) => {
1008
+ const unassignedLimit = Math.min(Math.max(input.unassignedLimit ?? 15, 1), 50);
1009
+ const perProjectLimit = Math.min(Math.max(input.perProjectLimit ?? 10, 1), 50);
1010
+ const rows = await sql<ConversationRow[]>`
1011
+ WITH ranked AS (
1012
+ SELECT
1013
+ conversation.*,
1014
+ latest.status AS latest_turn_status,
1015
+ latest.error AS latest_turn_error,
1016
+ latest.completed_at AS latest_turn_completed_at,
1017
+ row_number() OVER (
1018
+ PARTITION BY conversation.project_id
1019
+ ORDER BY conversation.pinned_at DESC NULLS LAST, conversation.updated_at DESC, conversation.created_at DESC, conversation.id
1020
+ ) AS sidebar_rank
1021
+ FROM ai.conversations conversation
1022
+ LEFT JOIN LATERAL (
1023
+ SELECT status, error, completed_at
1024
+ FROM ai.turns
1025
+ WHERE conversation_id = conversation.id
1026
+ ORDER BY created_at DESC, id DESC
1027
+ LIMIT 1
1028
+ ) latest ON TRUE
1029
+ WHERE conversation.created_by_user_id = ${input.ownerUserId}::uuid
1030
+ AND conversation.archived_at IS NULL
1031
+ )
1032
+ SELECT *
1033
+ FROM ranked
1034
+ WHERE (project_id IS NULL AND sidebar_rank <= ${unassignedLimit})
1035
+ OR (project_id IS NOT NULL AND sidebar_rank <= ${perProjectLimit})
1036
+ ORDER BY pinned_at DESC NULLS LAST, updated_at DESC, created_at DESC, id
1037
+ `;
1038
+ return rows.map(rowToConversation);
1039
+ },
1040
+
1041
+ listConversationsPage: async (input): Promise<AiConversationPage> => {
1042
+ const pattern = searchPattern(input.search);
1043
+ const query = input.search?.trim() || null;
1044
+ const archived = Boolean(input.archived);
1045
+ const status = input.status ?? null;
1046
+ const { page, perPage, offset } = sanitizePagination(input);
1047
+ const rows = await withConversationSearchBackend(query, async (backend) => {
1048
+ const order = query
1049
+ ? sql`${conversationSearchRank(backend, query)} DESC, conversation.pinned_at DESC NULLS LAST, conversation.updated_at DESC, conversation.created_at DESC`
1050
+ : sql`conversation.pinned_at DESC NULLS LAST, conversation.updated_at DESC, conversation.created_at DESC`;
1051
+ return sql<ConversationRow[]>`
1052
+ SELECT
1053
+ conversation.*,
1054
+ latest.status AS latest_turn_status,
1055
+ latest.error AS latest_turn_error,
1056
+ latest.completed_at AS latest_turn_completed_at
1057
+ FROM ai.conversations conversation
1058
+ LEFT JOIN LATERAL (
1059
+ SELECT status, error, completed_at
1060
+ FROM ai.turns
1061
+ WHERE conversation_id = conversation.id
1062
+ ORDER BY created_at DESC, id DESC
1063
+ LIMIT 1
1064
+ ) latest ON TRUE
1065
+ WHERE conversation.created_by_user_id = ${input.ownerUserId}
1066
+ AND (${archived}::boolean = (conversation.archived_at IS NOT NULL))
1067
+ AND (${input.projectId ?? null}::uuid IS NULL OR conversation.project_id = ${input.projectId ?? null}::uuid)
1068
+ AND (NOT ${Boolean(input.unassigned)}::boolean OR conversation.project_id IS NULL)
1069
+ AND (${pattern}::text IS NULL
1070
+ OR LOWER(conversation.title) LIKE ${pattern}
1071
+ OR LOWER(conversation.description) LIKE ${pattern}
1072
+ OR LOWER(conversation.search_summary) LIKE ${pattern}
1073
+ OR LOWER(array_to_string(conversation.keywords, ' ')) LIKE ${pattern}
1074
+ OR conversation.search_document @@ websearch_to_tsquery('simple', ${query ?? ""})
1075
+ OR EXISTS (
1076
+ SELECT 1 FROM ai.messages message
1077
+ WHERE message.conversation_id = conversation.id
1078
+ AND (LOWER(message.search_text) LIKE ${pattern}
1079
+ OR message.search_document @@ websearch_to_tsquery('simple', ${query ?? ""}))
1080
+ ))
1081
+ AND (${status}::text IS NULL
1082
+ OR (${status} = 'running' AND latest.status IN ('queued', 'running'))
1083
+ OR (${status} = 'needs_attention' AND latest.status = 'waiting_for_action')
1084
+ OR (${status} = 'failed' AND latest.status = 'failed')
1085
+ OR (${status} = 'unread' AND latest.status = 'completed' AND latest.completed_at > COALESCE(conversation.last_viewed_at, '-infinity')))
1086
+ ORDER BY ${order}
1087
+ LIMIT ${perPage}
1088
+ OFFSET ${offset}
1089
+ `;
1090
+ });
1091
+ const countRows = await sql<CountRow[]>`
1092
+ SELECT COUNT(*) AS total
1093
+ FROM ai.conversations conversation
1094
+ LEFT JOIN LATERAL (
1095
+ SELECT status, completed_at
1096
+ FROM ai.turns
1097
+ WHERE conversation_id = conversation.id
1098
+ ORDER BY created_at DESC, id DESC
1099
+ LIMIT 1
1100
+ ) latest ON TRUE
1101
+ WHERE conversation.created_by_user_id = ${input.ownerUserId}
1102
+ AND (${archived}::boolean = (conversation.archived_at IS NOT NULL))
1103
+ AND (${input.projectId ?? null}::uuid IS NULL OR conversation.project_id = ${input.projectId ?? null}::uuid)
1104
+ AND (NOT ${Boolean(input.unassigned)}::boolean OR conversation.project_id IS NULL)
1105
+ AND (${pattern}::text IS NULL
1106
+ OR LOWER(conversation.title) LIKE ${pattern}
1107
+ OR LOWER(conversation.description) LIKE ${pattern}
1108
+ OR LOWER(conversation.search_summary) LIKE ${pattern}
1109
+ OR LOWER(array_to_string(conversation.keywords, ' ')) LIKE ${pattern}
1110
+ OR conversation.search_document @@ websearch_to_tsquery('simple', ${query ?? ""})
1111
+ OR EXISTS (
1112
+ SELECT 1 FROM ai.messages message
1113
+ WHERE message.conversation_id = conversation.id
1114
+ AND (LOWER(message.search_text) LIKE ${pattern}
1115
+ OR message.search_document @@ websearch_to_tsquery('simple', ${query ?? ""}))
1116
+ ))
1117
+ AND (${status}::text IS NULL
1118
+ OR (${status} = 'running' AND latest.status IN ('queued', 'running'))
1119
+ OR (${status} = 'needs_attention' AND latest.status = 'waiting_for_action')
1120
+ OR (${status} = 'failed' AND latest.status = 'failed')
1121
+ OR (${status} = 'unread' AND latest.status = 'completed' AND latest.completed_at > COALESCE(conversation.last_viewed_at, '-infinity')))
1122
+ `;
1123
+ const total = Number(countRows[0]?.total ?? 0);
1124
+ return {
1125
+ items: rows.map(rowToConversation),
1126
+ total,
1127
+ page,
1128
+ perPage,
1129
+ hasNext: page * perPage < total,
1130
+ };
1131
+ },
1132
+
1133
+ getConversation: async (input) => {
1134
+ return loadConversationSummary(input);
1135
+ },
1136
+
1137
+ getConversationByShortId: async (input) => {
1138
+ return loadConversationSummary(input);
1139
+ },
1140
+
1141
+ saveDraft: async (input) =>
1142
+ sql.begin(async (tx) => {
1143
+ const [conversation] = await tx<
1144
+ { draft_content: unknown; draft_revision: number | string; draft_updated_at: Date | string | null }[]
1145
+ >`
1146
+ SELECT draft_content, draft_revision, draft_updated_at
1147
+ FROM ai.conversations
1148
+ WHERE id = ${input.conversationId}::uuid
1149
+ AND created_by_user_id = ${input.ownerUserId}::uuid
1150
+ AND archived_at IS NULL
1151
+ FOR UPDATE
1152
+ `;
1153
+ if (!conversation) return { ok: false as const, reason: "not_found" as const };
1154
+ if (Number(conversation.draft_revision) !== input.expectedRevision) {
1155
+ return { ok: false as const, reason: "conflict" as const };
1156
+ }
1157
+ const [sameDraft] = await tx<{ same: boolean }[]>`
1158
+ SELECT ${JSON.stringify(input.content)}::jsonb = ${JSON.stringify(parseJsonValue(conversation.draft_content))}::jsonb AS same
1159
+ `;
1160
+ if (sameDraft?.same) {
1161
+ return {
1162
+ ok: true as const,
1163
+ draft: {
1164
+ content: input.content,
1165
+ revision: Number(conversation.draft_revision),
1166
+ updatedAt: conversation.draft_updated_at ? iso(conversation.draft_updated_at) : null,
1167
+ },
1168
+ };
1169
+ }
1170
+
1171
+ for (const part of input.content) {
1172
+ if (part.type !== "file") continue;
1173
+ const [file] = await tx<{ exists: boolean }[]>`
1174
+ SELECT EXISTS (
1175
+ SELECT 1 FROM ai.files
1176
+ WHERE conversation_id = ${input.conversationId}::uuid
1177
+ AND path = ${part.path}
1178
+ AND media_type = ${part.mediaType}
1179
+ AND size = ${part.size}
1180
+ AND version = ${part.version}
1181
+ ) AS exists
1182
+ `;
1183
+ if (!file?.exists) throw new Error(`Conversation file changed or no longer exists: ${part.path}`);
1184
+ }
1185
+
1186
+ const [saved] = await tx<{ draft_content: unknown; draft_revision: number | string; draft_updated_at: Date | string }[]>`
1187
+ UPDATE ai.conversations
1188
+ SET draft_content = ${JSON.stringify(input.content)}::jsonb,
1189
+ draft_revision = draft_revision + 1,
1190
+ draft_updated_at = now()
1191
+ WHERE id = ${input.conversationId}::uuid
1192
+ RETURNING draft_content, draft_revision, draft_updated_at
1193
+ `;
1194
+ return {
1195
+ ok: true as const,
1196
+ draft: {
1197
+ content: parseJsonValue<AiConversationDraft["content"]>(saved!.draft_content),
1198
+ revision: Number(saved!.draft_revision),
1199
+ updatedAt: iso(saved!.draft_updated_at),
1200
+ },
1201
+ };
1202
+ }),
1203
+
1204
+ getLoadedTools: async (input) => {
1205
+ const rows = await sql<{ loaded_tools: string[] | null }[]>`
1206
+ SELECT loaded_tools
1207
+ FROM ai.conversations
1208
+ WHERE id = ${input.conversationId}::uuid
1209
+ AND archived_at IS NULL
1210
+ `;
1211
+ if (!rows[0]) throw new Error(`AI conversation ${input.conversationId} not found`);
1212
+ return rows[0].loaded_tools ?? [];
1213
+ },
1214
+
1215
+ loadTools: async (input) =>
1216
+ sql.begin(async (tx) => {
1217
+ const rows = await tx<{ loaded_tools: string[] | null }[]>`
1218
+ SELECT loaded_tools
1219
+ FROM ai.conversations
1220
+ WHERE id = ${input.conversationId}::uuid
1221
+ AND archived_at IS NULL
1222
+ FOR UPDATE
1223
+ `;
1224
+ if (!rows[0]) throw new Error(`AI conversation ${input.conversationId} not found`);
1225
+
1226
+ const current = rows[0].loaded_tools ?? [];
1227
+ const requested = [...new Set(input.names.map((name) => name.trim()).filter(Boolean))];
1228
+ const currentSet = new Set(current);
1229
+ const alreadyLoaded = requested.filter((name) => currentSet.has(name));
1230
+ const added = requested.filter((name) => !currentSet.has(name));
1231
+ const combined = [...current, ...added];
1232
+ const configuredLimit = Math.floor(input.maxLoadedTools ?? 0);
1233
+ const limit = Number.isFinite(configuredLimit) && configuredLimit > 0 ? configuredLimit : 0;
1234
+ const evicted = limit > 0 ? combined.slice(0, Math.max(0, combined.length - limit)) : [];
1235
+ const retained = evicted.length > 0 ? combined.slice(evicted.length) : combined;
1236
+ const retainedSet = new Set(retained);
1237
+ const loaded = added.filter((name) => retainedSet.has(name));
1238
+
1239
+ await tx`
1240
+ UPDATE ai.conversations
1241
+ SET loaded_tools = ${toPgTextArray(retained)}::text[]
1242
+ WHERE id = ${input.conversationId}::uuid
1243
+ `;
1244
+ return { loaded, alreadyLoaded, evicted };
1245
+ }),
1246
+
1247
+ indexConversationResources: async (input) => {
1248
+ const resources = [...new Map(input.resources.map((resource) => [`${resource.ref.type}\0${resource.ref.id}`, resource])).values()];
1249
+ if (!resources.length) return;
1250
+ await sql.begin(async (tx) => {
1251
+ for (const resource of resources) {
1252
+ await tx`
1253
+ INSERT INTO ai.conversation_resource_refs (
1254
+ conversation_id, resource_type, resource_id, title, preview, icon, href, source_turn_id, source_call_id
1255
+ ) VALUES (
1256
+ ${input.conversationId}, ${resource.ref.type}, ${resource.ref.id}, ${resource.title ?? null}, ${resource.preview ?? null},
1257
+ ${resource.icon ?? null}, ${resource.href ?? null}, ${input.turnId ?? null}, ${input.callId ?? null}
1258
+ )
1259
+ ON CONFLICT (conversation_id, resource_type, resource_id)
1260
+ DO UPDATE SET
1261
+ title = COALESCE(EXCLUDED.title, ai.conversation_resource_refs.title),
1262
+ preview = COALESCE(EXCLUDED.preview, ai.conversation_resource_refs.preview),
1263
+ icon = COALESCE(EXCLUDED.icon, ai.conversation_resource_refs.icon),
1264
+ href = COALESCE(EXCLUDED.href, ai.conversation_resource_refs.href),
1265
+ source_turn_id = COALESCE(EXCLUDED.source_turn_id, ai.conversation_resource_refs.source_turn_id),
1266
+ source_call_id = COALESCE(EXCLUDED.source_call_id, ai.conversation_resource_refs.source_call_id),
1267
+ last_seen_at = now()
1268
+ `;
1269
+ }
1270
+ });
1271
+ },
1272
+
1273
+ listConversationResources: async (input) => {
1274
+ const limit = Math.min(Math.max(Math.floor(input.limit ?? 20), 1), 100);
1275
+ const pattern = searchPattern(input.search);
1276
+ const cursor = decodeResourceCursor(input.before);
1277
+ const rows = await sql<ConversationResourceRefRow[]>`
1278
+ SELECT indexed.resource_type, indexed.resource_id, indexed.title, indexed.preview, indexed.icon, indexed.href,
1279
+ source_turn.short_id AS source_turn_id, indexed.source_call_id, indexed.first_seen_at, indexed.last_seen_at
1280
+ FROM ai.conversation_resource_refs indexed
1281
+ LEFT JOIN ai.turns source_turn ON source_turn.id = indexed.source_turn_id
1282
+ WHERE indexed.conversation_id = ${input.conversationId}::uuid
1283
+ AND (${pattern}::text IS NULL OR LOWER(COALESCE(title, '') || ' ' || resource_type || ' ' || resource_id) LIKE ${pattern})
1284
+ AND (${cursor?.at ?? null}::timestamptz IS NULL OR (last_seen_at, resource_type, resource_id) <
1285
+ (${cursor?.at ?? null}::timestamptz, ${cursor?.type ?? null}::text, ${cursor?.id ?? null}::text))
1286
+ ORDER BY last_seen_at DESC, resource_type DESC, resource_id DESC
1287
+ LIMIT ${limit + 1}
1288
+ `;
1289
+ const page = rows.slice(0, limit);
1290
+ return {
1291
+ resources: page.map(rowToConversationResourceRef),
1292
+ ...(rows.length > limit && page.at(-1) ? { nextCursor: encodeResourceCursor(page.at(-1)!) } : {}),
1293
+ };
1294
+ },
1295
+
1296
+ listUserConversationResources: async (input) => {
1297
+ const limit = Math.min(Math.max(Math.floor(input.limit ?? 20), 1), 100);
1298
+ const pattern = searchPattern(input.search);
1299
+ const cursor = decodeResourceOccurrenceCursor(input.before);
1300
+ const rows = await sql<ConversationResourceOccurrenceRow[]>`
1301
+ SELECT indexed.resource_type, indexed.resource_id, indexed.title, indexed.preview, indexed.icon, indexed.href,
1302
+ source_turn.short_id AS source_turn_id, indexed.source_call_id, indexed.first_seen_at, indexed.last_seen_at,
1303
+ conversation.short_id AS conversation_short_id, conversation.title AS conversation_title,
1304
+ conversation.updated_at AS conversation_updated_at
1305
+ FROM ai.conversation_resource_refs indexed
1306
+ JOIN ai.conversations conversation ON conversation.id = indexed.conversation_id
1307
+ LEFT JOIN ai.turns source_turn ON source_turn.id = indexed.source_turn_id
1308
+ WHERE conversation.created_by_user_id = ${input.ownerUserId}::uuid
1309
+ AND conversation.archived_at IS NULL
1310
+ AND (${pattern}::text IS NULL OR LOWER(COALESCE(indexed.title, '') || ' ' || indexed.resource_type || ' ' || indexed.resource_id || ' ' || conversation.title) LIKE ${pattern})
1311
+ AND (${cursor?.at ?? null}::timestamptz IS NULL OR (indexed.last_seen_at, indexed.resource_type, indexed.resource_id, conversation.short_id) <
1312
+ (${cursor?.at ?? null}::timestamptz, ${cursor?.type ?? null}::text, ${cursor?.id ?? null}::text, ${cursor?.chat ?? null}::text))
1313
+ ORDER BY indexed.last_seen_at DESC, indexed.resource_type DESC, indexed.resource_id DESC, conversation.short_id DESC
1314
+ LIMIT ${limit + 1}
1315
+ `;
1316
+ const page = rows.slice(0, limit);
1317
+ return {
1318
+ resources: page.map(rowToConversationResourceOccurrence),
1319
+ ...(rows.length > limit && page.at(-1) ? { nextCursor: encodeResourceOccurrenceCursor(page.at(-1)!) } : {}),
1320
+ };
1321
+ },
1322
+
1323
+ indexConversationSource: async (input) => {
1324
+ const source = input.source;
1325
+ await sql`
1326
+ INSERT INTO ai.conversation_sources (
1327
+ conversation_id, kind, source_key, title, preview, icon, href, source_turn_id, source_call_id
1328
+ ) VALUES (
1329
+ ${input.conversationId}::uuid, ${source.kind}, ${source.key}, ${source.title}, ${source.preview ?? null},
1330
+ ${source.icon ?? null}, ${source.href ?? null}, ${input.turnId ?? null}::uuid, ${input.callId ?? null}
1331
+ )
1332
+ ON CONFLICT (conversation_id, kind, source_key)
1333
+ DO UPDATE SET
1334
+ title = EXCLUDED.title,
1335
+ preview = COALESCE(EXCLUDED.preview, ai.conversation_sources.preview),
1336
+ icon = COALESCE(EXCLUDED.icon, ai.conversation_sources.icon),
1337
+ href = COALESCE(EXCLUDED.href, ai.conversation_sources.href),
1338
+ occurrences = ai.conversation_sources.occurrences +
1339
+ CASE WHEN ai.conversation_sources.source_call_id IS DISTINCT FROM EXCLUDED.source_call_id THEN 1 ELSE 0 END,
1340
+ source_turn_id = COALESCE(EXCLUDED.source_turn_id, ai.conversation_sources.source_turn_id),
1341
+ source_call_id = COALESCE(EXCLUDED.source_call_id, ai.conversation_sources.source_call_id),
1342
+ last_seen_at = now()
1343
+ `;
1344
+ },
1345
+
1346
+ listConversationSources: async (input) => {
1347
+ const limit = Math.min(Math.max(Math.floor(input.limit ?? 20), 1), 100);
1348
+ const pattern = searchPattern(input.search);
1349
+ const cursor = decodeResourceCursor(input.before);
1350
+ const rows = await sql<ConversationSourceRow[]>`
1351
+ SELECT *
1352
+ FROM (
1353
+ SELECT source.kind AS source_kind, source.source_key, source.title, source.preview,
1354
+ source.icon, source.href, NULL::text AS path, NULL::text AS media_type, NULL::bigint AS size,
1355
+ NULL::text AS resource_type, NULL::text AS resource_id, source.occurrences,
1356
+ turn.short_id AS source_turn_id, source.source_call_id, source.first_seen_at, source.last_seen_at
1357
+ FROM ai.conversation_sources source
1358
+ LEFT JOIN ai.turns turn ON turn.id = source.source_turn_id
1359
+ WHERE source.conversation_id = ${input.conversationId}::uuid
1360
+
1361
+ UNION ALL
1362
+
1363
+ SELECT 'file' AS source_kind, file.path AS source_key,
1364
+ regexp_replace(file.path, '^.*/', '') AS title, 'Attached to the conversation' AS preview,
1365
+ CASE WHEN file.media_type LIKE 'image/%' THEN 'ti ti-photo' ELSE 'ti ti-file' END AS icon,
1366
+ NULL::text AS href, file.path, file.media_type, file.size,
1367
+ NULL::text AS resource_type, NULL::text AS resource_id, 1 AS occurrences,
1368
+ NULL::text AS source_turn_id, NULL::text AS source_call_id,
1369
+ file.updated_at AS first_seen_at, file.updated_at AS last_seen_at
1370
+ FROM ai.files file
1371
+ WHERE file.conversation_id = ${input.conversationId}::uuid
1372
+
1373
+ UNION ALL
1374
+
1375
+ SELECT 'resource' AS source_kind, indexed.resource_type || ':' || indexed.resource_id AS source_key,
1376
+ COALESCE(indexed.title, indexed.resource_type || ' ' || indexed.resource_id) AS title,
1377
+ indexed.preview, indexed.icon, indexed.href, NULL::text AS path, NULL::text AS media_type, NULL::bigint AS size,
1378
+ indexed.resource_type, indexed.resource_id, 1 AS occurrences,
1379
+ turn.short_id AS source_turn_id, indexed.source_call_id, indexed.first_seen_at, indexed.last_seen_at
1380
+ FROM ai.conversation_resource_refs indexed
1381
+ LEFT JOIN ai.turns turn ON turn.id = indexed.source_turn_id
1382
+ WHERE indexed.conversation_id = ${input.conversationId}::uuid
1383
+ ) source
1384
+ WHERE (${pattern}::text IS NULL OR LOWER(source.title || ' ' || COALESCE(source.preview, '') || ' ' || source.source_key) LIKE ${pattern})
1385
+ AND (${cursor?.at ?? null}::timestamptz IS NULL OR (source.last_seen_at, source.source_kind, source.source_key) <
1386
+ (${cursor?.at ?? null}::timestamptz, ${cursor?.type ?? null}::text, ${cursor?.id ?? null}::text))
1387
+ ORDER BY source.last_seen_at DESC, source.source_kind DESC, source.source_key DESC
1388
+ LIMIT ${limit + 1}
1389
+ `;
1390
+ const page = rows.slice(0, limit);
1391
+ return {
1392
+ sources: page.map(rowToConversationSource),
1393
+ ...(rows.length > limit && page.at(-1) ? { nextCursor: encodeSourceCursor(page.at(-1)!) } : {}),
1394
+ };
1395
+ },
1396
+
1397
+ getCapabilityInvocationOrigin: async (input) => {
1398
+ const rows = await sql<
1399
+ { conversation_id: string; conversation_short_id: string; turn_id: string; turn_short_id: string; call_id: string }[]
1400
+ >`
1401
+ SELECT call.conversation_id, conversation.short_id AS conversation_short_id,
1402
+ call.turn_id, turn.short_id AS turn_short_id, call.call_id
1403
+ FROM ai.tool_calls call
1404
+ JOIN ai.conversations conversation ON conversation.id = call.conversation_id
1405
+ JOIN ai.turns turn ON turn.id = call.turn_id
1406
+ WHERE call.idempotency_key = ${input.idempotencyKey}
1407
+ AND call.tool_name = ${input.toolName}
1408
+ LIMIT 1
1409
+ `;
1410
+ const row = rows[0];
1411
+ return row
1412
+ ? {
1413
+ conversationId: row.conversation_id,
1414
+ conversationShortId: row.conversation_short_id,
1415
+ turnId: row.turn_id,
1416
+ turnShortId: row.turn_short_id,
1417
+ callId: row.call_id,
1418
+ }
1419
+ : null;
1420
+ },
1421
+
1422
+ createInterChatMessage: async (input) => {
1423
+ const existing = await sql<InterChatMessageRow[]>`${interChatMessageSelect}
1424
+ WHERE message.idempotency_key = ${input.idempotencyKey}
1425
+ AND message.source_conversation_id = ${input.sourceConversationId}::uuid
1426
+ AND message.actor_user_id = ${input.actorUserId}::uuid
1427
+ LIMIT 1`;
1428
+ if (existing[0]) return { ok: true, message: rowToInterChatMessage(existing[0]) };
1429
+
1430
+ const [pair] = await sql<{ source_id: string; target_id: string }[]>`
1431
+ SELECT source.id AS source_id, target.id AS target_id
1432
+ FROM ai.conversations source
1433
+ JOIN ai.turns source_turn ON source_turn.conversation_id = source.id AND source_turn.id = ${input.sourceTurnId}::uuid
1434
+ JOIN ai.conversations target ON target.short_id = ${input.targetChatId}
1435
+ WHERE source.id = ${input.sourceConversationId}::uuid
1436
+ AND source.created_by_user_id = ${input.actorUserId}::uuid
1437
+ AND target.created_by_user_id = ${input.actorUserId}::uuid
1438
+ AND source.archived_at IS NULL
1439
+ AND target.archived_at IS NULL
1440
+ LIMIT 1
1441
+ `;
1442
+ if (!pair) return { ok: false, reason: "not_found" };
1443
+ if (pair.source_id === pair.target_id) return { ok: false, reason: "same_chat" };
1444
+ const [recursive] = await sql<{ recursive: boolean }[]>`
1445
+ SELECT EXISTS (
1446
+ SELECT 1 FROM ai.messages
1447
+ WHERE conversation_id = ${input.sourceConversationId}::uuid
1448
+ AND loop_id = ${input.sourceTurnId}
1449
+ AND meta ? 'agentMessage'
1450
+ ) AS recursive
1451
+ `;
1452
+ if (recursive?.recursive) return { ok: false, reason: "recursive" };
1453
+
1454
+ const inserted = await withAiShortId(
1455
+ "ai_inter_chat_messages_short_id_unique",
1456
+ (shortId) => sql<{ id: string }[]>`
1457
+ INSERT INTO ai.inter_chat_messages (
1458
+ short_id, source_conversation_id, source_turn_id, source_call_id,
1459
+ target_conversation_id, actor_user_id, text, idempotency_key
1460
+ ) VALUES (
1461
+ ${shortId}, ${input.sourceConversationId}, ${input.sourceTurnId}, ${input.sourceCallId},
1462
+ ${pair.target_id}, ${input.actorUserId}, ${input.text.trim()}, ${input.idempotencyKey}
1463
+ )
1464
+ ON CONFLICT (idempotency_key) DO UPDATE SET idempotency_key = EXCLUDED.idempotency_key
1465
+ RETURNING id
1466
+ `,
1467
+ );
1468
+ const rows = await sql<InterChatMessageRow[]>`${interChatMessageSelect} WHERE message.id = ${inserted[0]!.id}::uuid LIMIT 1`;
1469
+ return { ok: true, message: rowToInterChatMessage(rows[0]!) };
1470
+ },
1471
+
1472
+ listPendingInterChatMessages: async (input = {}) => {
1473
+ const limit = Math.min(Math.max(Math.floor(input.limit ?? 50), 1), 100);
1474
+ const rows = await sql<InterChatMessageRow[]>`
1475
+ ${interChatMessageSelect}
1476
+ WHERE message.status = 'pending'
1477
+ AND (${input.targetConversationId ?? null}::uuid IS NULL OR message.target_conversation_id = ${input.targetConversationId ?? null}::uuid)
1478
+ AND (${input.targetConversationId ?? null}::uuid IS NOT NULL OR NOT EXISTS (
1479
+ SELECT 1
1480
+ FROM ai.turns active_turn
1481
+ WHERE active_turn.conversation_id = message.target_conversation_id
1482
+ AND active_turn.status IN ('queued', 'running', 'waiting_for_action')
1483
+ ))
1484
+ AND (${input.targetConversationId ?? null}::uuid IS NOT NULL OR message.id = (
1485
+ SELECT oldest.id
1486
+ FROM ai.inter_chat_messages oldest
1487
+ WHERE oldest.target_conversation_id = message.target_conversation_id
1488
+ AND oldest.status = 'pending'
1489
+ ORDER BY oldest.created_at ASC, oldest.id ASC
1490
+ LIMIT 1
1491
+ ))
1492
+ ORDER BY message.created_at ASC, message.id ASC
1493
+ LIMIT ${limit}
1494
+ `;
1495
+ return rows.map(rowToInterChatMessage);
1496
+ },
1497
+
1498
+ failInterChatMessage: async (input) => {
1499
+ const rows = await sql<{ id: string }[]>`
1500
+ UPDATE ai.inter_chat_messages
1501
+ SET status = 'failed', error = ${input.error.slice(0, 2_000)}
1502
+ WHERE id = ${input.messageId}::uuid AND status = 'pending'
1503
+ RETURNING id
1504
+ `;
1505
+ return Boolean(rows[0]);
1506
+ },
1507
+
1508
+ deliverInterChatMessage: async (input) => {
1509
+ const delivered = await sql.begin(async (tx) => {
1510
+ const rows = await tx<InterChatMessageRow[]>`
1511
+ ${interChatMessageSelect}
1512
+ WHERE message.id = ${input.messageId}::uuid
1513
+ FOR UPDATE OF message
1514
+ `;
1515
+ const message = rows[0];
1516
+ if (!message) return { delivered: false as const, reason: "not_found" as const };
1517
+ if (message.status === "delivered") {
1518
+ const turn = message.target_turn_id
1519
+ ? await tx<TurnRow[]>`SELECT * FROM ai.turns WHERE id = ${message.target_turn_id}::uuid LIMIT 1`
1520
+ : [];
1521
+ return turn[0]
1522
+ ? { delivered: true as const, message: rowToInterChatMessage(message), turn: rowToTurn(turn[0]) }
1523
+ : { delivered: false as const, reason: "failed" as const };
1524
+ }
1525
+ if (message.status === "failed") return { delivered: false as const, reason: "failed" as const };
1526
+
1527
+ const [target] = await tx<{ archived_at: Date | string | null }[]>`
1528
+ SELECT archived_at FROM ai.conversations WHERE id = ${message.target_conversation_id}::uuid FOR UPDATE
1529
+ `;
1530
+ if (!target || target.archived_at) {
1531
+ await tx`UPDATE ai.inter_chat_messages SET status = 'failed', error = 'Target chat is unavailable' WHERE id = ${message.id}::uuid`;
1532
+ return { delivered: false as const, reason: "failed" as const };
1533
+ }
1534
+ const [active] = await tx<{ id: string }[]>`
1535
+ SELECT id FROM ai.turns
1536
+ WHERE conversation_id = ${message.target_conversation_id}::uuid
1537
+ AND status IN ('queued', 'running', 'waiting_for_action')
1538
+ LIMIT 1
1539
+ `;
1540
+ if (active) return { delivered: false as const, reason: "busy" as const };
1541
+
1542
+ const turnRows = await withAiShortIdForDb(
1543
+ tx,
1544
+ "idx_ai_turns_conversation_short_id",
1545
+ (attempt, shortId) => attempt<TurnRow[]>`
1546
+ INSERT INTO ai.turns (short_id, conversation_id, model_profile_id, status, run_config)
1547
+ VALUES (${shortId}, ${message.target_conversation_id}, ${input.modelProfileId}, 'queued', (${JSON.stringify(input.runConfig)}::text)::jsonb)
1548
+ RETURNING *
1549
+ `,
1550
+ );
1551
+ const turn = rowToTurn(turnRows[0]!);
1552
+ const targetMeta: NonNullable<AiStoredMessage["meta"]> = {
1553
+ agentMessage: {
1554
+ id: message.short_id,
1555
+ sourceChatId: message.source_chat_id,
1556
+ sourceTurnId: message.source_turn_short_id,
1557
+ sourceTitle: message.source_title,
1558
+ sourceHref: input.sourceHref,
1559
+ },
1560
+ };
1561
+ const messageRows = await withAiShortIdForDb(
1562
+ tx,
1563
+ "idx_ai_messages_conversation_short_id",
1564
+ (attempt, shortId) => attempt<MessageRow[]>`
1565
+ INSERT INTO ai.messages (short_id, conversation_id, seq, kind, role, message, search_text, loop_id, meta)
1566
+ VALUES (
1567
+ ${shortId}, ${message.target_conversation_id},
1568
+ (SELECT COALESCE(MAX(seq), 0) + 1 FROM ai.messages WHERE conversation_id = ${message.target_conversation_id} AND seq > 0),
1569
+ 'message', ${input.userMessage.role}, (${JSON.stringify(input.userMessage)}::text)::jsonb,
1570
+ ${messageSearchText(input.userMessage)}, ${turn.id}, (${JSON.stringify(targetMeta)}::text)::jsonb
1571
+ )
1572
+ RETURNING *
1573
+ `,
1574
+ );
1575
+ const messageRow = messageRows[0]!;
1576
+ await tx`UPDATE ai.conversations SET updated_at = now() WHERE id = ${message.target_conversation_id}::uuid`;
1577
+ await tx`
1578
+ UPDATE ai.inter_chat_messages
1579
+ SET status = 'delivered', target_turn_id = ${turn.id}, target_message_id = ${messageRow.id}, delivered_at = now()
1580
+ WHERE id = ${message.id}::uuid
1581
+ `;
1582
+ const updated = await tx<InterChatMessageRow[]>`${interChatMessageSelect} WHERE message.id = ${message.id}::uuid LIMIT 1`;
1583
+ return { delivered: true as const, message: rowToInterChatMessage(updated[0]!), turn };
1584
+ });
1585
+ return delivered;
1586
+ },
1587
+
1588
+ updateConversationMetadata: async (input) => {
1589
+ const title = input.title.trim() || "New chat";
1590
+ const description = input.description?.trim() ?? "";
1591
+ const pinned = input.pinned ?? null;
1592
+ const rows = await sql<{ id: string }[]>`
1593
+ UPDATE ai.conversations
1594
+ SET title = ${title},
1595
+ title_source = CASE WHEN title IS DISTINCT FROM ${title} THEN 'user' ELSE title_source END,
1596
+ description = ${description},
1597
+ description_source = CASE WHEN description IS DISTINCT FROM ${description} THEN 'user' ELSE description_source END,
1598
+ pinned_at = CASE
1599
+ WHEN ${pinned}::boolean IS NULL THEN pinned_at
1600
+ WHEN ${pinned} THEN COALESCE(pinned_at, now())
1601
+ ELSE NULL
1602
+ END,
1603
+ updated_at = CASE
1604
+ WHEN title IS DISTINCT FROM ${title} OR description IS DISTINCT FROM ${description} THEN now()
1605
+ ELSE updated_at
1606
+ END
1607
+ WHERE id = ${input.conversationId}
1608
+ AND (${input.ownerUserId ?? null}::uuid IS NULL OR created_by_user_id = ${input.ownerUserId ?? null})
1609
+ AND archived_at IS NULL
1610
+ RETURNING id
1611
+ `;
1612
+ return rows[0] ? loadConversationSummary(input) : null;
1613
+ },
1614
+
1615
+ setConversationProject: async (input) => {
1616
+ return sql.begin(async (tx) => {
1617
+ const conversations = await tx<ConversationRow[]>`
1618
+ SELECT *
1619
+ FROM ai.conversations
1620
+ WHERE id = ${input.conversationId}::uuid
1621
+ AND (${input.ownerUserId ?? null}::uuid IS NULL OR created_by_user_id = ${input.ownerUserId ?? null})
1622
+ AND archived_at IS NULL
1623
+ FOR UPDATE
1624
+ `;
1625
+ if (!conversations[0]) return { ok: false as const, reason: "not_found" as const };
1626
+
1627
+ const [activity] = await tx<{ exists: boolean }[]>`
1628
+ SELECT EXISTS (
1629
+ SELECT 1 FROM ai.turns
1630
+ WHERE conversation_id = ${input.conversationId}::uuid
1631
+ AND status IN ('queued', 'running', 'waiting_for_action')
1632
+ ) AS exists
1633
+ `;
1634
+ if (activity?.exists) return { ok: false as const, reason: "active_turn" as const };
1635
+
1636
+ const updated = await tx<ConversationRow[]>`
1637
+ UPDATE ai.conversations
1638
+ SET project_id = ${input.projectId}::uuid,
1639
+ updated_at = CASE WHEN project_id IS DISTINCT FROM ${input.projectId}::uuid THEN now() ELSE updated_at END
1640
+ WHERE id = ${input.conversationId}::uuid
1641
+ AND (${input.projectId}::uuid IS NULL OR EXISTS (SELECT 1 FROM ai.projects WHERE id = ${input.projectId}::uuid))
1642
+ RETURNING *
1643
+ `;
1644
+ return updated[0]
1645
+ ? { ok: true as const, conversation: rowToConversation(updated[0]) }
1646
+ : { ok: false as const, reason: "not_found" as const };
1647
+ });
1648
+ },
1649
+
1650
+ setConversationPinned: async (input) => {
1651
+ const rows = await sql<{ id: string }[]>`
1652
+ UPDATE ai.conversations
1653
+ SET pinned_at = CASE WHEN ${input.pinned} THEN COALESCE(pinned_at, now()) ELSE NULL END
1654
+ WHERE id = ${input.conversationId}
1655
+ AND (${input.ownerUserId ?? null}::uuid IS NULL OR created_by_user_id = ${input.ownerUserId ?? null})
1656
+ AND archived_at IS NULL
1657
+ RETURNING id
1658
+ `;
1659
+ return rows[0] ? loadConversationSummary(input) : null;
1660
+ },
1661
+
1662
+ archiveConversation: async (input) => {
1663
+ const rows = await sql<{ id: string }[]>`
1664
+ UPDATE ai.conversations
1665
+ SET archived_at = now(), pinned_at = NULL
1666
+ WHERE id = ${input.conversationId}
1667
+ AND (${input.ownerUserId ?? null}::uuid IS NULL OR created_by_user_id = ${input.ownerUserId ?? null})
1668
+ AND archived_at IS NULL
1669
+ AND NOT EXISTS (
1670
+ SELECT 1
1671
+ FROM ai.turns
1672
+ WHERE conversation_id = ai.conversations.id
1673
+ AND status IN ('queued', 'running', 'waiting_for_action')
1674
+ )
1675
+ RETURNING id
1676
+ `;
1677
+ return Boolean(rows[0]);
1678
+ },
1679
+
1680
+ restoreConversation: async (input) => {
1681
+ const rows = await sql<{ id: string }[]>`
1682
+ UPDATE ai.conversations
1683
+ SET archived_at = NULL
1684
+ WHERE id = ${input.conversationId}
1685
+ AND (${input.ownerUserId ?? null}::uuid IS NULL OR created_by_user_id = ${input.ownerUserId ?? null})
1686
+ AND archived_at IS NOT NULL
1687
+ RETURNING id
1688
+ `;
1689
+ return rows[0] ? loadConversationSummary(input) : null;
1690
+ },
1691
+
1692
+ markConversationViewed: async (input) => {
1693
+ const rows = await sql<{ id: string }[]>`
1694
+ UPDATE ai.conversations
1695
+ SET last_viewed_at = now()
1696
+ WHERE id = ${input.conversationId}
1697
+ AND (${input.ownerUserId ?? null}::uuid IS NULL OR created_by_user_id = ${input.ownerUserId ?? null})
1698
+ AND archived_at IS NULL
1699
+ RETURNING id
1700
+ `;
1701
+ return Boolean(rows[0]);
1702
+ },
1703
+
1704
+ listEnrichmentCandidates: async (input) => {
1705
+ const limit = Math.min(Math.max(input.limit, 1), 100);
1706
+ const onlyId = input.conversationId ?? null;
1707
+ // dirty_as_of carries updated_at at full microsecond precision (::text
1708
+ // round-trips losslessly); the ISO field is millisecond-truncated and
1709
+ // must never be written back as enriched_at.
1710
+ // Failure backoff: 5min * 2^fail_count, capped at 2^7 (~10.7h).
1711
+ // A manual reindex (conversationId set) skips the dirty and backoff checks.
1712
+ const rows = await sql<(ConversationRow & { dirty_as_of: string })[]>`
1713
+ SELECT c.*, c.updated_at::text AS dirty_as_of
1714
+ FROM ai.conversations c
1715
+ WHERE c.archived_at IS NULL
1716
+ AND (${onlyId}::uuid IS NULL OR c.id = ${onlyId}::uuid)
1717
+ AND (
1718
+ ${onlyId}::uuid IS NOT NULL
1719
+ OR (
1720
+ (c.enriched_at IS NULL OR c.updated_at > c.enriched_at)
1721
+ AND (
1722
+ c.enrich_failed_at IS NULL
1723
+ OR c.enrich_failed_at + (interval '5 minutes' * pow(2, LEAST(c.enrich_fail_count, 7))) < now()
1724
+ )
1725
+ )
1726
+ )
1727
+ AND EXISTS (SELECT 1 FROM ai.messages m WHERE m.conversation_id = c.id)
1728
+ AND NOT EXISTS (
1729
+ SELECT 1 FROM ai.turns t
1730
+ WHERE t.conversation_id = c.id AND t.status IN ('queued', 'running', 'waiting_for_action')
1731
+ )
1732
+ ORDER BY c.updated_at ASC
1733
+ LIMIT ${limit}
1734
+ `;
1735
+ return rows.map((row) => ({
1736
+ ...rowToConversation(row),
1737
+ dirtyAsOf: row.dirty_as_of,
1738
+ enrichFailCount: row.enrich_fail_count ?? 0,
1739
+ }));
1740
+ },
1741
+
1742
+ applyEnrichment: async (input) => {
1743
+ const title = input.title?.trim();
1744
+ const description = input.description?.trim();
1745
+ await sql`
1746
+ UPDATE ai.conversations
1747
+ SET keywords = ${toPgTextArray(input.keywords)}::text[],
1748
+ search_summary = ${input.searchSummary.trim()},
1749
+ title = COALESCE(${title ?? null}, title),
1750
+ title_source = CASE WHEN ${title ?? null}::text IS NOT NULL THEN 'auto' ELSE title_source END,
1751
+ description = COALESCE(${description ?? null}, description),
1752
+ description_source = CASE WHEN ${description ?? null}::text IS NOT NULL THEN 'auto' ELSE description_source END,
1753
+ enriched_at = ${input.dirtyAsOf}::timestamptz,
1754
+ enrich_failed_at = NULL,
1755
+ enrich_fail_count = 0
1756
+ WHERE id = ${input.conversationId}
1757
+ `;
1758
+ },
1759
+
1760
+ markEnrichmentFailed: async (input) => {
1761
+ await sql`
1762
+ UPDATE ai.conversations
1763
+ SET enrich_failed_at = now(), enrich_fail_count = enrich_fail_count + 1
1764
+ WHERE id = ${input.conversationId}
1765
+ `;
1766
+ },
1767
+
1768
+ recordEnrichmentRun: async (input) => {
1769
+ await sql`
1770
+ INSERT INTO ai.enrichment_runs (conversation_id, status, trigger, model_profile_id, mode, duration_ms, title_updated, keywords_count, error)
1771
+ VALUES (
1772
+ ${input.conversationId},
1773
+ ${input.status},
1774
+ ${input.trigger},
1775
+ ${input.modelProfileId ?? null},
1776
+ ${input.mode ?? null},
1777
+ ${input.durationMs ?? null},
1778
+ ${input.titleUpdated ?? false},
1779
+ ${input.keywordsCount ?? 0},
1780
+ ${input.error?.slice(0, 500) ?? null}
1781
+ )
1782
+ `;
1783
+ // Retention: keep the newest 20 runs per conversation.
1784
+ await sql`
1785
+ DELETE FROM ai.enrichment_runs
1786
+ WHERE conversation_id = ${input.conversationId}
1787
+ AND id NOT IN (
1788
+ SELECT id FROM ai.enrichment_runs
1789
+ WHERE conversation_id = ${input.conversationId}
1790
+ ORDER BY created_at DESC
1791
+ LIMIT 20
1792
+ )
1793
+ `;
1794
+ },
1795
+
1796
+ listEnrichmentRuns: async (input) => {
1797
+ const limit = Math.min(Math.max(input.limit ?? 20, 1), 50);
1798
+ const rows = await sql<EnrichmentRunRow[]>`
1799
+ SELECT * FROM ai.enrichment_runs
1800
+ WHERE conversation_id = ${input.conversationId}
1801
+ ORDER BY created_at DESC
1802
+ LIMIT ${limit}
1803
+ `;
1804
+ return rows.map(rowToEnrichmentRun);
1805
+ },
1806
+
1807
+ getEnrichmentStatus: async (input) => {
1808
+ const rows = await sql<
1809
+ { enriched_at: Date | string | null; dirty: boolean; enrich_fail_count: number | null; keywords: string[] | null }[]
1810
+ >`
1811
+ SELECT enriched_at, (enriched_at IS NULL OR updated_at > enriched_at) AS dirty, enrich_fail_count, keywords
1812
+ FROM ai.conversations
1813
+ WHERE id = ${input.conversationId}
1814
+ `;
1815
+ const row = rows[0];
1816
+ if (!row) return null;
1817
+ return {
1818
+ enrichedAt: row.enriched_at ? iso(row.enriched_at) : null,
1819
+ dirty: row.dirty,
1820
+ enrichFailCount: row.enrich_fail_count ?? 0,
1821
+ keywords: row.keywords ?? [],
1822
+ };
1823
+ },
1824
+
1825
+ getEnrichmentOverview: async (): Promise<AiEnrichmentOverview> => {
1826
+ const [summary] = await sql<
1827
+ Array<{
1828
+ total_conversations: number | string;
1829
+ dirty_conversations: number | string;
1830
+ failed_conversations: number | string;
1831
+ oldest_dirty_at: Date | string | null;
1832
+ last_run_at: Date | string | null;
1833
+ avg_duration_ms: number | string | null;
1834
+ failed_runs_24h: number | string;
1835
+ total_runs_24h: number | string;
1836
+ }>
1837
+ >`
1838
+ WITH conversation_summary AS (
1839
+ SELECT
1840
+ count(*)::int AS total_conversations,
1841
+ count(*) FILTER (WHERE enriched_at IS NULL OR updated_at > enriched_at)::int AS dirty_conversations,
1842
+ count(*) FILTER (WHERE enrich_fail_count > 0)::int AS failed_conversations,
1843
+ min(updated_at) FILTER (WHERE enriched_at IS NULL OR updated_at > enriched_at) AS oldest_dirty_at
1844
+ FROM ai.conversations
1845
+ WHERE archived_at IS NULL
1846
+ ),
1847
+ run_summary AS (
1848
+ SELECT
1849
+ max(created_at) AS last_run_at,
1850
+ round(avg(duration_ms) FILTER (WHERE duration_ms IS NOT NULL))::int AS avg_duration_ms,
1851
+ count(*) FILTER (WHERE created_at >= now() - interval '24 hours')::int AS total_runs_24h,
1852
+ count(*) FILTER (WHERE status = 'failed' AND created_at >= now() - interval '24 hours')::int AS failed_runs_24h
1853
+ FROM ai.enrichment_runs
1854
+ )
1855
+ SELECT *
1856
+ FROM conversation_summary
1857
+ CROSS JOIN run_summary
1858
+ `;
1859
+ const recentRows = await sql<EnrichmentRunRow[]>`
1860
+ SELECT r.*, c.title AS conversation_title
1861
+ FROM ai.enrichment_runs r
1862
+ JOIN ai.conversations c ON c.id = r.conversation_id
1863
+ WHERE c.archived_at IS NULL
1864
+ ORDER BY r.created_at DESC
1865
+ LIMIT 8
1866
+ `;
1867
+ const totalRuns24h = intValue(summary?.total_runs_24h);
1868
+ const failedRuns24h = intValue(summary?.failed_runs_24h);
1869
+ return {
1870
+ totalConversations: intValue(summary?.total_conversations),
1871
+ dirtyConversations: intValue(summary?.dirty_conversations),
1872
+ failedConversations: intValue(summary?.failed_conversations),
1873
+ oldestDirtyAt: summary?.oldest_dirty_at ? iso(summary.oldest_dirty_at) : null,
1874
+ lastRunAt: summary?.last_run_at ? iso(summary.last_run_at) : null,
1875
+ avgDurationMs: numberOrNull(summary?.avg_duration_ms),
1876
+ failedRuns24h,
1877
+ totalRuns24h,
1878
+ errorRate24h: totalRuns24h > 0 ? (failedRuns24h / totalRuns24h) * 100 : 0,
1879
+ recentRuns: recentRows.map(rowToEnrichmentOverviewRun),
1880
+ };
1881
+ },
1882
+
1883
+ listMessages: async (input) => {
1884
+ // Human view: compacted messages stay visible; superseded (compacted)
1885
+ // summaries are hidden. The active summary sorts after archived rows
1886
+ // sharing its checkpoint seq, marking where the model context begins.
1887
+ const rows = await sql<MessageRow[]>`
1888
+ SELECT *
1889
+ FROM ai.messages
1890
+ WHERE conversation_id = ${input.conversationId}
1891
+ AND NOT (kind = 'summary' AND compacted_at IS NOT NULL)
1892
+ ORDER BY seq ASC, (kind = 'summary')::int ASC
1893
+ `;
1894
+ return rows.map(rowToMessage);
1895
+ },
1896
+
1897
+ listMessagesPage: async (input) => {
1898
+ const limit = Math.min(Math.max(input.limit ?? 50, 1), 200);
1899
+ const beforeSeq = Number.isFinite(input.beforeSeq) ? (input.beforeSeq ?? null) : null;
1900
+ // Window by DISTINCT seq: a seq group (archived rows + their compaction
1901
+ // summary share one seq) is never split, so `beforeSeq = min(seq)` is a
1902
+ // lossless cursor. Same visibility rules as listMessages.
1903
+ const rows = await sql<MessageRow[]>`
1904
+ WITH page_seqs AS (
1905
+ SELECT DISTINCT seq
1906
+ FROM ai.messages
1907
+ WHERE conversation_id = ${input.conversationId}
1908
+ AND (${beforeSeq}::int IS NULL OR seq < ${beforeSeq})
1909
+ AND NOT (kind = 'summary' AND compacted_at IS NOT NULL)
1910
+ ORDER BY seq DESC
1911
+ LIMIT ${limit}
1912
+ )
1913
+ SELECT *
1914
+ FROM ai.messages
1915
+ WHERE conversation_id = ${input.conversationId}
1916
+ AND seq IN (SELECT seq FROM page_seqs)
1917
+ AND NOT (kind = 'summary' AND compacted_at IS NOT NULL)
1918
+ ORDER BY seq ASC, (kind = 'summary')::int ASC
1919
+ `;
1920
+ const messages = rows.map(rowToMessage);
1921
+ const oldestSeq = messages[0]?.seq;
1922
+ if (oldestSeq === undefined) return { messages, hasMore: false };
1923
+ const older = await sql<{ exists: boolean }[]>`
1924
+ SELECT EXISTS (
1925
+ SELECT 1 FROM ai.messages
1926
+ WHERE conversation_id = ${input.conversationId}
1927
+ AND seq < ${oldestSeq}
1928
+ AND NOT (kind = 'summary' AND compacted_at IS NOT NULL)
1929
+ ) AS exists
1930
+ `;
1931
+ return { messages, hasMore: Boolean(older[0]?.exists) };
1932
+ },
1933
+
1934
+ searchConversationMessages: async (input) => {
1935
+ const query = input.query.trim();
1936
+ if (!query) return { messages: [] };
1937
+ const beforeSeq = input.beforeSeq && input.beforeSeq > 0 ? Math.floor(input.beforeSeq) : null;
1938
+ const limit = Math.min(Math.max(Math.floor(input.limit ?? 20), 1), 100);
1939
+ const pattern = `%${query.toLowerCase()}%`;
1940
+ const rows = await sql<(MessageRow & { page_rank: number | string })[]>`
1941
+ WITH matching AS (
1942
+ SELECT *, DENSE_RANK() OVER (ORDER BY seq DESC) AS page_rank
1943
+ FROM ai.messages
1944
+ WHERE conversation_id = ${input.conversationId}::uuid
1945
+ AND role <> 'tool_result'
1946
+ AND search_text <> ''
1947
+ AND NOT (kind = 'summary' AND compacted_at IS NOT NULL)
1948
+ AND (${beforeSeq}::int IS NULL OR seq < ${beforeSeq})
1949
+ AND (LOWER(search_text) LIKE ${pattern} OR search_document @@ websearch_to_tsquery('simple', ${query}))
1950
+ )
1951
+ SELECT *
1952
+ FROM matching
1953
+ WHERE page_rank <= ${limit + 1}
1954
+ ORDER BY seq DESC, (kind = 'summary')::int ASC, created_at DESC, id DESC
1955
+ `;
1956
+ const page = rows.filter((row) => Number(row.page_rank) <= limit);
1957
+ return {
1958
+ messages: page.map(rowToMessage),
1959
+ ...(rows.some((row) => Number(row.page_rank) > limit) && page.at(-1) ? { nextCursor: String(page.at(-1)!.seq) } : {}),
1960
+ };
1961
+ },
1962
+
1963
+ listConversationTimeline: async (input): Promise<AiConversationTimelineEntry[]> => {
1964
+ const rows = await sql<TimelineRow[]>`
1965
+ WITH normalized AS (
1966
+ SELECT
1967
+ id,
1968
+ short_id,
1969
+ seq,
1970
+ role,
1971
+ loop_id,
1972
+ created_at,
1973
+ CASE
1974
+ WHEN jsonb_typeof(message) = 'string' THEN (message #>> '{}')::jsonb
1975
+ ELSE message
1976
+ END AS payload,
1977
+ CASE
1978
+ WHEN meta IS NULL THEN NULL
1979
+ WHEN jsonb_typeof(meta) = 'string' THEN (meta #>> '{}')::jsonb
1980
+ ELSE meta
1981
+ END AS meta_payload
1982
+ FROM ai.messages
1983
+ WHERE conversation_id = ${input.conversationId}
1984
+ AND kind = 'message'
1985
+ AND role IN ('user', 'assistant')
1986
+ ),
1987
+ message_text AS (
1988
+ SELECT
1989
+ normalized.*,
1990
+ COALESCE((
1991
+ SELECT string_agg(part ->> 'text', '' ORDER BY ordinal)
1992
+ FROM jsonb_array_elements(COALESCE(payload -> 'content', '[]'::jsonb)) WITH ORDINALITY AS content(part, ordinal)
1993
+ WHERE part ->> 'type' = 'text'
1994
+ AND NOT starts_with(COALESCE(part ->> 'text', ''), 'Attached files for this message:')
1995
+ ), '') AS visible_text,
1996
+ COALESCE((
1997
+ SELECT count(*) FILTER (WHERE part ->> 'type' = 'file')
1998
+ + sum(regexp_count(COALESCE(part ->> 'text', ''), '<attachment path='))
1999
+ + sum(regexp_count(COALESCE(part ->> 'text', ''), '--- file: '))
2000
+ FROM jsonb_array_elements(COALESCE(payload -> 'content', '[]'::jsonb)) AS content(part)
2001
+ ), 0) AS input_file_count
2002
+ FROM normalized
2003
+ ),
2004
+ user_rows AS (
2005
+ SELECT
2006
+ message_text.*,
2007
+ lead(seq) OVER (ORDER BY seq ASC) AS next_user_seq
2008
+ FROM message_text
2009
+ WHERE role = 'user'
2010
+ ),
2011
+ tool_summary AS (
2012
+ SELECT
2013
+ turn_id::text AS loop_id,
2014
+ count(*)::int AS tool_count,
2015
+ count(*) FILTER (WHERE tool_name = 'present' AND status = 'completed')::int AS output_file_count
2016
+ FROM ai.tool_calls
2017
+ WHERE conversation_id = ${input.conversationId}
2018
+ GROUP BY turn_id
2019
+ )
2020
+ SELECT
2021
+ users.short_id AS id,
2022
+ users.seq,
2023
+ COALESCE(timeline_turn.short_id, users.loop_id) AS loop_id,
2024
+ left(
2025
+ regexp_replace(
2026
+ regexp_replace(users.visible_text, '<attachment path="[^"]+" media-type="[^"]*" size="[0-9]+" />', '', 'g'),
2027
+ '\\s+',
2028
+ ' ',
2029
+ 'g'
2030
+ ),
2031
+ 240
2032
+ ) AS user_preview,
2033
+ left(COALESCE((
2034
+ SELECT string_agg(regexp_replace(assistant.visible_text, '\\s+', ' ', 'g'), ' ' ORDER BY assistant.seq)
2035
+ FROM message_text assistant
2036
+ WHERE assistant.role = 'assistant'
2037
+ AND assistant.seq > users.seq
2038
+ AND (users.next_user_seq IS NULL OR assistant.seq < users.next_user_seq)
2039
+ ), ''), 320) AS assistant_preview,
2040
+ COALESCE(users.meta_payload ? 'steerId', false) AS is_steer,
2041
+ users.input_file_count,
2042
+ COALESCE(tools.output_file_count, 0) AS output_file_count,
2043
+ COALESCE(tools.tool_count, 0) AS tool_count,
2044
+ users.created_at
2045
+ FROM user_rows users
2046
+ LEFT JOIN tool_summary tools ON tools.loop_id = users.loop_id
2047
+ LEFT JOIN ai.turns timeline_turn ON timeline_turn.id::text = users.loop_id
2048
+ ORDER BY users.seq ASC
2049
+ `;
2050
+ return rows.map((row) => ({
2051
+ id: row.id,
2052
+ seq: Number(row.seq),
2053
+ loopId: row.loop_id,
2054
+ userPreview: row.user_preview?.trim() || "Message",
2055
+ assistantPreview: row.assistant_preview?.trim() || "",
2056
+ isSteer: Boolean(row.is_steer),
2057
+ inputFileCount: Number(row.input_file_count ?? 0),
2058
+ outputFileCount: Number(row.output_file_count ?? 0),
2059
+ toolCount: Number(row.tool_count ?? 0),
2060
+ createdAt: iso(row.created_at),
2061
+ }));
2062
+ },
2063
+
2064
+ listContextMessages: async (input) => {
2065
+ const rows = await sql<MessageRow[]>`
2066
+ SELECT *
2067
+ FROM ai.messages
2068
+ WHERE conversation_id = ${input.conversationId}
2069
+ AND compacted_at IS NULL
2070
+ ORDER BY seq ASC
2071
+ `;
2072
+ return rows.map(rowToMessage);
2073
+ },
2074
+
2075
+ listTurnMessages: async (input) => {
2076
+ const rows = await sql<MessageRow[]>`
2077
+ SELECT *
2078
+ FROM ai.messages
2079
+ WHERE conversation_id = ${input.conversationId}
2080
+ AND loop_id = ${input.loopId}
2081
+ AND (${input.includeCompacted ?? false} OR compacted_at IS NULL)
2082
+ ORDER BY seq ASC
2083
+ `;
2084
+ return rows.map(rowToMessage);
2085
+ },
2086
+
2087
+ setMessageFeedback: async (input) => {
2088
+ const reasons = input.feedback.rating === "down" ? input.feedback.reasons : [];
2089
+ const comment = input.feedback.comment?.trim() || null;
2090
+ const rows = await sql<MessageRow[]>`
2091
+ UPDATE ai.messages
2092
+ SET feedback_rating = ${input.feedback.rating === "up" ? 1 : -1},
2093
+ feedback_reasons = ${toPgTextArray(reasons)}::text[],
2094
+ feedback_comment = ${comment},
2095
+ feedback_updated_at = now()
2096
+ WHERE conversation_id = ${input.conversationId}::uuid
2097
+ AND short_id = ${input.messageShortId}
2098
+ AND kind = 'message'
2099
+ AND role = 'assistant'
2100
+ RETURNING *
2101
+ `;
2102
+ return rows[0] ? (rowToMessage(rows[0]).feedback ?? null) : null;
2103
+ },
2104
+
2105
+ clearMessageFeedback: async (input) => {
2106
+ const rows = await sql<{ id: string }[]>`
2107
+ UPDATE ai.messages
2108
+ SET feedback_rating = NULL,
2109
+ feedback_reasons = '{}'::text[],
2110
+ feedback_comment = NULL,
2111
+ feedback_updated_at = NULL
2112
+ WHERE conversation_id = ${input.conversationId}::uuid
2113
+ AND short_id = ${input.messageShortId}
2114
+ AND kind = 'message'
2115
+ AND role = 'assistant'
2116
+ RETURNING id
2117
+ `;
2118
+ return Boolean(rows[0]);
2119
+ },
2120
+
2121
+ copyMessages: async (input) => {
2122
+ const throughSeq = Math.floor(input.throughSeq);
2123
+ if (!Number.isFinite(throughSeq) || throughSeq <= 0) return;
2124
+
2125
+ await sql.begin(async (tx) => {
2126
+ await tx`SELECT id FROM ai.conversations WHERE id = ${input.targetConversationId} FOR UPDATE`;
2127
+ await tx`
2128
+ INSERT INTO ai.messages (
2129
+ short_id,
2130
+ conversation_id,
2131
+ seq,
2132
+ kind,
2133
+ role,
2134
+ message,
2135
+ search_text,
2136
+ loop_id,
2137
+ model_profile_id,
2138
+ provider_model,
2139
+ usage,
2140
+ stop_reason,
2141
+ loop_aggregate,
2142
+ loop_done_reason
2143
+ )
2144
+ SELECT
2145
+ short_id,
2146
+ ${input.targetConversationId},
2147
+ seq,
2148
+ kind,
2149
+ role,
2150
+ message,
2151
+ search_text,
2152
+ loop_id,
2153
+ model_profile_id,
2154
+ provider_model,
2155
+ usage,
2156
+ stop_reason,
2157
+ loop_aggregate,
2158
+ loop_done_reason
2159
+ FROM ai.messages
2160
+ WHERE conversation_id = ${input.sourceConversationId}
2161
+ AND compacted_at IS NULL
2162
+ AND seq <= ${throughSeq}
2163
+ ORDER BY seq ASC
2164
+ `;
2165
+ await tx`UPDATE ai.conversations SET updated_at = now() WHERE id = ${input.targetConversationId}`;
2166
+ });
2167
+ },
2168
+
2169
+ truncateMessagesFrom: async (input) => {
2170
+ const fromSeq = Math.floor(input.fromSeq);
2171
+ if (!Number.isFinite(fromSeq) || fromSeq <= 0) return;
2172
+
2173
+ await sql.begin(async (tx) => {
2174
+ await tx`SELECT id FROM ai.conversations WHERE id = ${input.conversationId} FOR UPDATE`;
2175
+ await tx`
2176
+ DELETE FROM ai.messages
2177
+ WHERE conversation_id = ${input.conversationId}
2178
+ AND compacted_at IS NULL
2179
+ AND seq >= ${fromSeq}
2180
+ `;
2181
+ await tx`UPDATE ai.conversations SET updated_at = now() WHERE id = ${input.conversationId}`;
2182
+ });
2183
+ },
2184
+
2185
+ setLatestAssistantLoopAggregate: async (input) => {
2186
+ const loopId = input.loopId ?? null;
2187
+ await sql`
2188
+ UPDATE ai.messages
2189
+ SET loop_aggregate = ${JSON.stringify(input.aggregate)}::jsonb,
2190
+ loop_done_reason = ${input.doneReason}
2191
+ WHERE id = (
2192
+ SELECT id
2193
+ FROM ai.messages
2194
+ WHERE conversation_id = ${input.conversationId}
2195
+ AND compacted_at IS NULL
2196
+ AND kind = 'message'
2197
+ AND role = 'assistant'
2198
+ AND (${loopId}::text IS NULL OR loop_id = ${loopId})
2199
+ ORDER BY seq DESC
2200
+ LIMIT 1
2201
+ )
2202
+ `;
2203
+ },
2204
+
2205
+ compactMessages: async (input) => {
2206
+ const checkpointSeq = Math.floor(input.checkpointSeq);
2207
+ if (!Number.isFinite(checkpointSeq) || checkpointSeq <= 0) return;
2208
+
2209
+ await sql.begin(async (tx) => {
2210
+ await tx`SELECT id FROM ai.conversations WHERE id = ${input.conversationId} FOR UPDATE`;
2211
+ const rows = await tx<{ count: number }[]>`
2212
+ SELECT COUNT(*)::int AS count
2213
+ FROM ai.messages
2214
+ WHERE conversation_id = ${input.conversationId}
2215
+ AND compacted_at IS NULL
2216
+ AND seq <= ${checkpointSeq}
2217
+ `;
2218
+ if ((rows[0]?.count ?? 0) === 0) return;
2219
+
2220
+ const archived = await tx<{ count: number }[]>`
2221
+ WITH archived AS (
2222
+ UPDATE ai.messages
2223
+ SET compacted_at = now()
2224
+ WHERE conversation_id = ${input.conversationId}
2225
+ AND compacted_at IS NULL
2226
+ AND seq <= ${checkpointSeq}
2227
+ RETURNING id
2228
+ )
2229
+ SELECT COUNT(*)::int AS count FROM archived
2230
+ `;
2231
+
2232
+ await insertMessageLocked(
2233
+ {
2234
+ conversationId: input.conversationId,
2235
+ message: input.summary,
2236
+ kind: "summary",
2237
+ seq: checkpointSeq,
2238
+ loopId: null,
2239
+ modelProfileId: input.modelProfileId ?? null,
2240
+ meta: { compactedCount: archived[0]?.count ?? 0 },
2241
+ },
2242
+ tx,
2243
+ );
2244
+ });
2245
+ },
2246
+
2247
+ createCompactionTurn: async (input) => {
2248
+ const rows = await withAiShortId(
2249
+ "idx_ai_turns_conversation_short_id",
2250
+ (shortId) => sql<TurnRow[]>`
2251
+ INSERT INTO ai.turns (
2252
+ short_id,
2253
+ conversation_id,
2254
+ model_profile_id,
2255
+ status,
2256
+ run_config
2257
+ )
2258
+ VALUES (
2259
+ ${shortId},
2260
+ ${input.conversationId},
2261
+ ${input.modelProfileId},
2262
+ 'queued',
2263
+ (${input.runConfig ? JSON.stringify(input.runConfig) : null}::text)::jsonb
2264
+ )
2265
+ RETURNING *
2266
+ `,
2267
+ );
2268
+ return rowToTurn(rows[0]!);
2269
+ },
2270
+
2271
+ submitChatTurn: async (input) => {
2272
+ return await sql.begin(async (tx) => {
2273
+ const [conversation] = await tx<{ draft_revision: number | string; project_id: string | null }[]>`
2274
+ SELECT draft_revision, project_id FROM ai.conversations WHERE id = ${input.conversationId} FOR UPDATE
2275
+ `;
2276
+ if (!conversation) throw new Error("Conversation not found.");
2277
+ if (input.expectedDraftRevision !== undefined && Number(conversation.draft_revision) !== input.expectedDraftRevision) {
2278
+ throw new Error("Conversation draft changed before the turn was submitted.");
2279
+ }
2280
+ if (input.expectedProjectId !== undefined && conversation.project_id !== input.expectedProjectId) {
2281
+ throw new Error("Conversation Project changed before the turn was submitted.");
2282
+ }
2283
+ if (typeof input.truncateFromSeq === "number" && input.truncateFromSeq > 0) {
2284
+ await tx`
2285
+ DELETE FROM ai.messages
2286
+ WHERE conversation_id = ${input.conversationId}
2287
+ AND compacted_at IS NULL
2288
+ AND seq >= ${Math.floor(input.truncateFromSeq)}
2289
+ `;
2290
+ }
2291
+ const turnRows = await withAiShortIdForDb(
2292
+ tx,
2293
+ "idx_ai_turns_conversation_short_id",
2294
+ (attempt, shortId) => attempt<TurnRow[]>`
2295
+ INSERT INTO ai.turns (
2296
+ short_id,
2297
+ conversation_id,
2298
+ model_profile_id,
2299
+ status,
2300
+ run_config
2301
+ )
2302
+ VALUES (
2303
+ ${shortId},
2304
+ ${input.conversationId},
2305
+ ${input.modelProfileId},
2306
+ 'queued',
2307
+ (${JSON.stringify(input.runConfig)}::text)::jsonb
2308
+ )
2309
+ RETURNING *
2310
+ `,
2311
+ );
2312
+ const turn = rowToTurn(turnRows[0]!);
2313
+ const attachedFiles = input.runConfig.files?.attached ?? [];
2314
+ for (const file of attachedFiles) {
2315
+ const copied = input.retrySourceTurnId
2316
+ ? await tx<{ path: string }[]>`
2317
+ INSERT INTO ai.turn_files (turn_id, path, bytes, media_type, size, origin, updated_at, version)
2318
+ SELECT ${turn.id}::uuid, path, bytes, media_type, size, origin, updated_at, version
2319
+ FROM ai.turn_files
2320
+ WHERE turn_id = ${input.retrySourceTurnId}::uuid
2321
+ AND path = ${file.path}
2322
+ AND size = ${file.size}
2323
+ AND media_type = ${file.mediaType}
2324
+ AND version = ${file.version ?? -1}
2325
+ RETURNING path
2326
+ `
2327
+ : await tx<{ path: string }[]>`
2328
+ INSERT INTO ai.turn_files (turn_id, path, bytes, media_type, size, origin, updated_at, version)
2329
+ SELECT ${turn.id}::uuid, path, bytes, media_type, size, origin, updated_at, version
2330
+ FROM ai.files
2331
+ WHERE conversation_id = ${input.conversationId}::uuid
2332
+ AND path = ${file.path}
2333
+ AND size = ${file.size}
2334
+ AND media_type = ${file.mediaType}
2335
+ AND version = ${file.version ?? -1}
2336
+ RETURNING path
2337
+ `;
2338
+ if (!copied[0]) throw new Error(`Attached conversation file changed before the turn was submitted: ${file.path}`);
2339
+ }
2340
+ const messageRow = await insertMessageLocked(
2341
+ {
2342
+ conversationId: input.conversationId,
2343
+ message: input.userMessage,
2344
+ loopId: turn.id,
2345
+ },
2346
+ tx,
2347
+ );
2348
+ const resources = [
2349
+ ...new Map((input.resources ?? []).map((resource) => [`${resource.ref.type}\0${resource.ref.id}`, resource])).values(),
2350
+ ];
2351
+ for (const resource of resources) {
2352
+ await tx`
2353
+ INSERT INTO ai.conversation_resource_refs (
2354
+ conversation_id, resource_type, resource_id, title, preview, icon, href, source_turn_id
2355
+ ) VALUES (
2356
+ ${input.conversationId}, ${resource.ref.type}, ${resource.ref.id}, ${resource.title ?? null},
2357
+ ${resource.preview ?? null}, ${resource.icon ?? null}, ${resource.href ?? null}, ${turn.id}
2358
+ )
2359
+ ON CONFLICT (conversation_id, resource_type, resource_id)
2360
+ DO UPDATE SET
2361
+ title = COALESCE(EXCLUDED.title, ai.conversation_resource_refs.title),
2362
+ preview = COALESCE(EXCLUDED.preview, ai.conversation_resource_refs.preview),
2363
+ icon = COALESCE(EXCLUDED.icon, ai.conversation_resource_refs.icon),
2364
+ href = COALESCE(EXCLUDED.href, ai.conversation_resource_refs.href),
2365
+ source_turn_id = EXCLUDED.source_turn_id,
2366
+ last_seen_at = now()
2367
+ `;
2368
+ }
2369
+ if (input.expectedDraftRevision !== undefined) {
2370
+ await tx`
2371
+ UPDATE ai.conversations
2372
+ SET draft_content = '[]'::jsonb,
2373
+ draft_revision = draft_revision + 1,
2374
+ draft_updated_at = now()
2375
+ WHERE id = ${input.conversationId}::uuid
2376
+ `;
2377
+ }
2378
+ return { turn, message: rowToMessage(messageRow) };
2379
+ });
2380
+ },
2381
+
2382
+ getTurnRunConfig: async (input) => {
2383
+ const rows = await sql<{ run_config: unknown }[]>`
2384
+ SELECT run_config
2385
+ FROM ai.turns
2386
+ WHERE id = ${input.turnId}::uuid
2387
+ AND conversation_id = ${input.conversationId}::uuid
2388
+ LIMIT 1
2389
+ `;
2390
+ return rows[0]?.run_config ? parseJsonValue<AiTurnRunConfig>(rows[0].run_config) : null;
2391
+ },
2392
+
2393
+ getTurn: async (input) => {
2394
+ const rows = await sql<TurnRow[]>`
2395
+ SELECT *
2396
+ FROM ai.turns
2397
+ WHERE id = ${input.turnId}
2398
+ AND conversation_id = ${input.conversationId}
2399
+ LIMIT 1
2400
+ `;
2401
+ return rows[0] ? rowToTurn(rows[0]) : null;
2402
+ },
2403
+
2404
+ getTurnByShortId: async (input) => {
2405
+ const rows = await sql<TurnRow[]>`
2406
+ SELECT *
2407
+ FROM ai.turns
2408
+ WHERE short_id = ${input.shortId}
2409
+ AND conversation_id = ${input.conversationId}::uuid
2410
+ LIMIT 1
2411
+ `;
2412
+ return rows[0] ? rowToTurn(rows[0]) : null;
2413
+ },
2414
+
2415
+ getActiveTurn: async (input) => {
2416
+ const rows = await sql<TurnRow[]>`
2417
+ SELECT *
2418
+ FROM ai.turns
2419
+ WHERE conversation_id = ${input.conversationId}
2420
+ AND status IN ('queued', 'running', 'waiting_for_action')
2421
+ ORDER BY created_at DESC
2422
+ LIMIT 1
2423
+ `;
2424
+ if (!rows[0]) return null;
2425
+ return {
2426
+ turn: rowToTurn(rows[0]),
2427
+ liveBlocks: rowToLiveBlocks(rows[0]),
2428
+ liveSeq: Number(rows[0].live_seq ?? 0),
2429
+ };
2430
+ },
2431
+
2432
+ claimTurn: async (input) => {
2433
+ const leaseMs = boundedMs(input.leaseMs, 60_000, 5_000, 5 * 60_000);
2434
+ const runBudgetMs = boundedMs(input.runBudgetMs, 10 * 60_000, 10_000, 60 * 60_000);
2435
+ const maxAttempts = Math.max(1, Math.floor(input.maxAttempts));
2436
+ const rows = await sql<TurnRow[]>`
2437
+ UPDATE ai.turns
2438
+ SET attempt = attempt + 1,
2439
+ status = 'running',
2440
+ lease_owner = ${input.leaseOwner},
2441
+ lease_expires_at = now() + (${leaseMs} * interval '1 millisecond'),
2442
+ heartbeat_at = now(),
2443
+ deadline = CASE
2444
+ WHEN ${input.from} = 'waiting' THEN now() + (${runBudgetMs} * interval '1 millisecond')
2445
+ ELSE COALESCE(deadline, now() + (${runBudgetMs} * interval '1 millisecond'))
2446
+ END
2447
+ WHERE id = ${input.turnId}
2448
+ AND conversation_id = ${input.conversationId}
2449
+ AND cancel_requested_at IS NULL
2450
+ AND attempt - (
2451
+ SELECT COUNT(*)::int
2452
+ FROM ai.pending_actions
2453
+ WHERE turn_id = ${input.turnId}
2454
+ AND conversation_id = ${input.conversationId}
2455
+ AND status = 'resolved'
2456
+ ) < ${maxAttempts}
2457
+ AND (
2458
+ (
2459
+ ${input.from} = 'queue'
2460
+ AND (
2461
+ status = 'queued'
2462
+ OR (status = 'running' AND (lease_expires_at IS NULL OR lease_expires_at < now()))
2463
+ )
2464
+ )
2465
+ OR (
2466
+ ${input.from} = 'waiting'
2467
+ AND status = 'waiting_for_action'
2468
+ AND EXISTS (
2469
+ SELECT 1
2470
+ FROM ai.pending_actions action
2471
+ WHERE action.turn_id = ${input.turnId}
2472
+ AND action.conversation_id = ${input.conversationId}
2473
+ AND action.status = 'resolved'
2474
+ AND EXISTS (
2475
+ SELECT 1
2476
+ FROM jsonb_array_elements(
2477
+ CASE WHEN jsonb_typeof(live_blocks) = 'array' THEN live_blocks ELSE '[]'::jsonb END
2478
+ ) block
2479
+ WHERE block->>'kind' = 'tool'
2480
+ AND block->>'callId' = action.call_id
2481
+ AND block->>'status' IN ('awaiting_approval', 'awaiting_client')
2482
+ )
2483
+ )
2484
+ )
2485
+ )
2486
+ RETURNING *
2487
+ `;
2488
+ if (!rows[0]) return null;
2489
+ const claim: AiTurnClaim = {
2490
+ turn: rowToTurn(rows[0]),
2491
+ runConfig: rows[0].run_config ? parseJsonValue<AiTurnRunConfig>(rows[0].run_config) : null,
2492
+ liveBlocks: rows[0].live_blocks ? rowToLiveBlocks(rows[0]) : null,
2493
+ liveSeq: Number(rows[0].live_seq ?? 0),
2494
+ };
2495
+ return claim;
2496
+ },
2497
+
2498
+ heartbeatTurn: async (input) => {
2499
+ const leaseMs = boundedMs(input.leaseMs, 60_000, 5_000, 5 * 60_000);
2500
+ const rows = await sql<{ id: string }[]>`
2501
+ UPDATE ai.turns
2502
+ SET heartbeat_at = now(),
2503
+ lease_expires_at = now() + (${leaseMs} * interval '1 millisecond')
2504
+ WHERE id = ${input.turnId}
2505
+ AND conversation_id = ${input.conversationId}
2506
+ AND lease_owner = ${input.leaseOwner}
2507
+ AND status = 'running'
2508
+ AND cancel_requested_at IS NULL
2509
+ AND (deadline IS NULL OR deadline > now())
2510
+ RETURNING id
2511
+ `;
2512
+ return Boolean(rows[0]);
2513
+ },
2514
+
2515
+ suspendTurn: async (input) => {
2516
+ const waitingBudgetMs = boundedMs(input.waitingBudgetMs, 24 * 60 * 60_000, 60_000, 7 * 24 * 60 * 60_000);
2517
+ const rows = await sql<{ id: string }[]>`
2518
+ UPDATE ai.turns
2519
+ SET status = 'waiting_for_action',
2520
+ lease_owner = NULL,
2521
+ lease_expires_at = NULL,
2522
+ heartbeat_at = now(),
2523
+ live_blocks = (${JSON.stringify(input.blocks)}::text)::jsonb,
2524
+ live_seq = ${input.seq},
2525
+ deadline = now() + (${waitingBudgetMs} * interval '1 millisecond')
2526
+ WHERE id = ${input.turnId}
2527
+ AND conversation_id = ${input.conversationId}
2528
+ AND status = 'running'
2529
+ AND lease_owner = ${input.leaseOwner}
2530
+ RETURNING id
2531
+ `;
2532
+ return Boolean(rows[0]);
2533
+ },
2534
+
2535
+ saveTurnLiveState: async (input) => {
2536
+ const rows = await sql<{ id: string }[]>`
2537
+ UPDATE ai.turns
2538
+ SET live_blocks = (${JSON.stringify(input.blocks)}::text)::jsonb,
2539
+ live_seq = ${input.seq}
2540
+ WHERE id = ${input.turnId}
2541
+ AND conversation_id = ${input.conversationId}
2542
+ AND status = 'running'
2543
+ AND lease_owner = ${input.leaseOwner}
2544
+ RETURNING id
2545
+ `;
2546
+ return Boolean(rows[0]);
2547
+ },
2548
+
2549
+ requestTurnAbort: async (input) => {
2550
+ const reason = input.reason ?? "user";
2551
+ const rows = await sql<TurnRow[]>`
2552
+ UPDATE ai.turns
2553
+ SET cancel_requested_at = COALESCE(cancel_requested_at, now()),
2554
+ cancellation_reason = COALESCE(cancellation_reason, ${reason})
2555
+ WHERE id = ${input.turnId}
2556
+ AND conversation_id = ${input.conversationId}
2557
+ AND status IN ('queued', 'running', 'waiting_for_action')
2558
+ RETURNING status, lease_owner, lease_expires_at, id, conversation_id, attempt, model_profile_id, created_at, completed_at, error
2559
+ `;
2560
+ if (!rows[0]) return { found: false };
2561
+ const row = rows[0];
2562
+ const ownerless = !row.lease_owner || !row.lease_expires_at || new Date(row.lease_expires_at).getTime() < Date.now();
2563
+ return { found: true, status: row.status, ownerless };
2564
+ },
2565
+
2566
+ completeTurn: async (input) => {
2567
+ return sql.begin(async (tx) => {
2568
+ const turnRows = await tx<{ id: string }[]>`
2569
+ SELECT id
2570
+ FROM ai.turns
2571
+ WHERE id = ${input.turnId}
2572
+ AND conversation_id = ${input.conversationId}
2573
+ AND status IN ('queued', 'running', 'waiting_for_action')
2574
+ AND (
2575
+ (${input.leaseOwner ?? null}::text IS NOT NULL AND lease_owner = ${input.leaseOwner ?? null})
2576
+ OR (
2577
+ ${input.leaseOwner ?? null}::text IS NULL
2578
+ AND (lease_owner IS NULL OR lease_expires_at IS NULL OR lease_expires_at < now())
2579
+ )
2580
+ )
2581
+ FOR UPDATE
2582
+ `;
2583
+ if (!turnRows[0]) return "lost" as const;
2584
+
2585
+ if (input.status === "completed") {
2586
+ const pending = await tx<{ id: string }[]>`
2587
+ SELECT id
2588
+ FROM ai.turn_steers
2589
+ WHERE conversation_id = ${input.conversationId}
2590
+ AND turn_id = ${input.turnId}
2591
+ AND status = 'pending'
2592
+ LIMIT 1
2593
+ `;
2594
+ if (pending[0]) return "pending_steering" as const;
2595
+ }
2596
+
2597
+ await tx`
2598
+ UPDATE ai.turns
2599
+ SET status = ${input.status},
2600
+ completed_at = now(),
2601
+ error = ${input.error ?? null},
2602
+ lease_owner = NULL,
2603
+ lease_expires_at = NULL,
2604
+ live_blocks = NULL
2605
+ WHERE id = ${input.turnId}
2606
+ `;
2607
+ await tx`
2608
+ UPDATE ai.pending_actions
2609
+ SET status = 'aborted', resolved_at = COALESCE(resolved_at, now())
2610
+ WHERE turn_id = ${input.turnId}
2611
+ AND status = 'pending'
2612
+ `;
2613
+ if (input.status !== "completed") {
2614
+ await tx`
2615
+ UPDATE ai.turn_steers
2616
+ SET status = 'discarded', consumed_at = COALESCE(consumed_at, now())
2617
+ WHERE turn_id = ${input.turnId}
2618
+ AND status = 'pending'
2619
+ `;
2620
+ }
2621
+ return "completed" as const;
2622
+ });
2623
+ },
2624
+
2625
+ sweepTurns: async (input) => {
2626
+ const limit = Math.min(Math.max(Math.floor(input?.limit ?? 200), 1), 1_000);
2627
+ const maxAttempts = Math.max(1, Math.floor(input?.maxAttempts ?? 5));
2628
+ const result: AiTurnSweepResult = { requeued: [], failed: [], aborted: [] };
2629
+
2630
+ // 1) Finalize turns that exhausted actual recovery attempts. Resuming a
2631
+ // resolved user/frontend action is normal progress and does not consume
2632
+ // this budget.
2633
+ const exhaustedRows = await sql<{ id: string; conversation_id: string; error: string; attempt: number; live_seq: number | string }[]>`
2634
+ UPDATE ai.turns
2635
+ SET status = 'failed',
2636
+ completed_at = now(),
2637
+ error = 'AI turn exhausted its recovery attempts.',
2638
+ lease_owner = NULL,
2639
+ lease_expires_at = NULL,
2640
+ live_blocks = NULL
2641
+ WHERE id IN (
2642
+ SELECT turn_row.id
2643
+ FROM ai.turns turn_row
2644
+ WHERE turn_row.cancel_requested_at IS NULL
2645
+ AND (turn_row.lease_owner IS NULL OR turn_row.lease_expires_at IS NULL OR turn_row.lease_expires_at < now())
2646
+ AND turn_row.attempt - (
2647
+ SELECT COUNT(*)::int
2648
+ FROM ai.pending_actions action
2649
+ WHERE action.turn_id = turn_row.id
2650
+ AND action.conversation_id = turn_row.conversation_id
2651
+ AND action.status = 'resolved'
2652
+ ) >= ${maxAttempts}
2653
+ AND (
2654
+ turn_row.status = 'queued'
2655
+ OR (turn_row.status = 'running' AND (turn_row.lease_expires_at IS NULL OR turn_row.lease_expires_at < now()))
2656
+ OR (
2657
+ turn_row.status = 'waiting_for_action'
2658
+ AND EXISTS (
2659
+ SELECT 1
2660
+ FROM ai.pending_actions action
2661
+ WHERE action.turn_id = turn_row.id
2662
+ AND action.conversation_id = turn_row.conversation_id
2663
+ AND action.status = 'resolved'
2664
+ AND EXISTS (
2665
+ SELECT 1
2666
+ FROM jsonb_array_elements(
2667
+ CASE
2668
+ WHEN jsonb_typeof(turn_row.live_blocks) = 'array' THEN turn_row.live_blocks
2669
+ ELSE '[]'::jsonb
2670
+ END
2671
+ ) block
2672
+ WHERE block->>'kind' = 'tool'
2673
+ AND block->>'callId' = action.call_id
2674
+ AND block->>'status' IN ('awaiting_approval', 'awaiting_client')
2675
+ )
2676
+ )
2677
+ )
2678
+ )
2679
+ LIMIT ${limit}
2680
+ )
2681
+ RETURNING id, conversation_id, error, attempt, live_seq
2682
+ `;
2683
+
2684
+ // 2) Finalize over-budget turns without a live lease.
2685
+ const budgetRows = await sql<{ id: string; conversation_id: string; error: string; attempt: number; live_seq: number | string }[]>`
2686
+ UPDATE ai.turns
2687
+ SET status = 'failed',
2688
+ completed_at = now(),
2689
+ error = 'AI turn exceeded its execution budget.',
2690
+ lease_owner = NULL,
2691
+ lease_expires_at = NULL,
2692
+ live_blocks = NULL
2693
+ WHERE id IN (
2694
+ SELECT id FROM ai.turns
2695
+ WHERE status IN ('queued', 'running')
2696
+ AND (
2697
+ (deadline IS NOT NULL AND deadline < now())
2698
+ OR (status = 'queued' AND deadline IS NULL AND created_at < now() - (${SWEEP_DEAD_QUEUED_MS} * interval '1 millisecond'))
2699
+ )
2700
+ AND (lease_owner IS NULL OR lease_expires_at IS NULL OR lease_expires_at < now())
2701
+ LIMIT ${limit}
2702
+ )
2703
+ RETURNING id, conversation_id, error, attempt, live_seq
2704
+ `;
2705
+ result.failed = [...exhaustedRows, ...budgetRows].map((row) => ({
2706
+ conversationId: row.conversation_id,
2707
+ turnId: row.id,
2708
+ error: row.error,
2709
+ attempt: Number(row.attempt),
2710
+ seq: Number(row.live_seq) + 1,
2711
+ }));
2712
+
2713
+ // 3) Finalize aborts: cancel-requested turns without a live lease, and expired waits.
2714
+ const abortedRows = await sql<{ id: string; conversation_id: string; attempt: number; live_seq: number | string }[]>`
2715
+ UPDATE ai.turns
2716
+ SET status = 'aborted',
2717
+ completed_at = now(),
2718
+ cancellation_reason = COALESCE(cancellation_reason, 'sweep'),
2719
+ lease_owner = NULL,
2720
+ lease_expires_at = NULL,
2721
+ live_blocks = NULL
2722
+ WHERE id IN (
2723
+ SELECT id FROM ai.turns
2724
+ WHERE status IN ('queued', 'running', 'waiting_for_action')
2725
+ AND (
2726
+ (cancel_requested_at IS NOT NULL AND (lease_owner IS NULL OR lease_expires_at IS NULL OR lease_expires_at < now()))
2727
+ OR (status = 'waiting_for_action' AND deadline IS NOT NULL AND deadline < now())
2728
+ )
2729
+ LIMIT ${limit}
2730
+ )
2731
+ RETURNING id, conversation_id, attempt, live_seq
2732
+ `;
2733
+ result.aborted = abortedRows.map((row) => ({
2734
+ conversationId: row.conversation_id,
2735
+ turnId: row.id,
2736
+ attempt: Number(row.attempt),
2737
+ seq: Number(row.live_seq) + 1,
2738
+ }));
2739
+
2740
+ for (const finalized of [...result.failed, ...result.aborted]) {
2741
+ await sql`
2742
+ UPDATE ai.pending_actions
2743
+ SET status = 'aborted', resolved_at = COALESCE(resolved_at, now())
2744
+ WHERE turn_id = ${finalized.turnId}
2745
+ AND status = 'pending'
2746
+ `;
2747
+ await sql`
2748
+ UPDATE ai.turn_steers
2749
+ SET status = 'discarded', consumed_at = COALESCE(consumed_at, now())
2750
+ WHERE turn_id = ${finalized.turnId}
2751
+ AND status = 'pending'
2752
+ `;
2753
+ }
2754
+
2755
+ // 4) Requeue crashed running turns (lease expired, still within budget).
2756
+ const requeuedRows = await sql<{ id: string; conversation_id: string }[]>`
2757
+ UPDATE ai.turns
2758
+ SET status = 'queued',
2759
+ lease_owner = NULL,
2760
+ lease_expires_at = NULL
2761
+ WHERE id IN (
2762
+ SELECT id FROM ai.turns
2763
+ WHERE status = 'running'
2764
+ AND lease_expires_at IS NOT NULL
2765
+ AND lease_expires_at < now()
2766
+ AND cancel_requested_at IS NULL
2767
+ AND (deadline IS NULL OR deadline > now())
2768
+ LIMIT ${limit}
2769
+ )
2770
+ RETURNING id, conversation_id
2771
+ `;
2772
+
2773
+ // 5) Stale queued turns whose queue message may be lost — re-enqueue them too.
2774
+ const staleQueuedRows = await sql<{ id: string; conversation_id: string }[]>`
2775
+ SELECT id, conversation_id
2776
+ FROM ai.turns
2777
+ WHERE status = 'queued'
2778
+ AND cancel_requested_at IS NULL
2779
+ AND created_at < now() - (${SWEEP_STALE_QUEUED_MS} * interval '1 millisecond')
2780
+ AND (heartbeat_at IS NULL OR heartbeat_at < now() - (${SWEEP_STALE_QUEUED_MS} * interval '1 millisecond'))
2781
+ LIMIT ${limit}
2782
+ `;
2783
+
2784
+ // 6) A durable action response can outlive the queue message that was meant
2785
+ // to resume it. Re-enqueue exactly the action still shown as awaiting in the
2786
+ // turn snapshot; historical responses must not unlock a later action.
2787
+ const resumableWaitingRows = await sql<{ id: string; conversation_id: string }[]>`
2788
+ SELECT turn_row.id, turn_row.conversation_id
2789
+ FROM ai.turns turn_row
2790
+ WHERE turn_row.status = 'waiting_for_action'
2791
+ AND turn_row.cancel_requested_at IS NULL
2792
+ AND (turn_row.deadline IS NULL OR turn_row.deadline > now())
2793
+ AND turn_row.attempt - (
2794
+ SELECT COUNT(*)::int
2795
+ FROM ai.pending_actions action
2796
+ WHERE action.turn_id = turn_row.id
2797
+ AND action.conversation_id = turn_row.conversation_id
2798
+ AND action.status = 'resolved'
2799
+ ) < ${maxAttempts}
2800
+ AND EXISTS (
2801
+ SELECT 1
2802
+ FROM ai.pending_actions action
2803
+ WHERE action.turn_id = turn_row.id
2804
+ AND action.conversation_id = turn_row.conversation_id
2805
+ AND action.status = 'resolved'
2806
+ AND EXISTS (
2807
+ SELECT 1
2808
+ FROM jsonb_array_elements(
2809
+ CASE
2810
+ WHEN jsonb_typeof(turn_row.live_blocks) = 'array' THEN turn_row.live_blocks
2811
+ ELSE '[]'::jsonb
2812
+ END
2813
+ ) block
2814
+ WHERE block->>'kind' = 'tool'
2815
+ AND block->>'callId' = action.call_id
2816
+ AND block->>'status' IN ('awaiting_approval', 'awaiting_client')
2817
+ )
2818
+ )
2819
+ LIMIT ${limit}
2820
+ `;
2821
+
2822
+ const requeueIds = new Set<string>();
2823
+ for (const row of [...requeuedRows, ...staleQueuedRows, ...resumableWaitingRows]) {
2824
+ if (requeueIds.has(row.id)) continue;
2825
+ requeueIds.add(row.id);
2826
+ result.requeued.push({ conversationId: row.conversation_id, turnId: row.id });
2827
+ }
2828
+
2829
+ return result;
2830
+ },
2831
+
2832
+ savePendingTurnAction: async (input) => {
2833
+ await sql`
2834
+ INSERT INTO ai.pending_actions (
2835
+ turn_id,
2836
+ conversation_id,
2837
+ call_id,
2838
+ kind,
2839
+ tool_name,
2840
+ args,
2841
+ message,
2842
+ review,
2843
+ approval_scope,
2844
+ allow_always,
2845
+ frontend_mode,
2846
+ status,
2847
+ resolved_event,
2848
+ resolved_at
2849
+ )
2850
+ VALUES (
2851
+ ${input.turnId},
2852
+ ${input.conversationId},
2853
+ ${input.callId},
2854
+ ${input.kind},
2855
+ ${input.name},
2856
+ ${JSON.stringify(input.args ?? null)}::jsonb,
2857
+ ${input.message ?? null},
2858
+ ${input.review ? JSON.stringify(input.review) : null}::jsonb,
2859
+ ${input.approvalScope},
2860
+ ${input.allowAlways},
2861
+ ${input.frontendMode ?? null},
2862
+ ${input.resolvedEvent ? "resolved" : "pending"},
2863
+ ${input.resolvedEvent ? JSON.stringify(input.resolvedEvent) : null}::jsonb,
2864
+ CASE WHEN ${Boolean(input.resolvedEvent)} THEN now() ELSE NULL END
2865
+ )
2866
+ ON CONFLICT (turn_id, call_id)
2867
+ DO UPDATE SET
2868
+ kind = EXCLUDED.kind,
2869
+ tool_name = EXCLUDED.tool_name,
2870
+ args = EXCLUDED.args,
2871
+ message = EXCLUDED.message,
2872
+ review = EXCLUDED.review,
2873
+ approval_scope = EXCLUDED.approval_scope,
2874
+ allow_always = EXCLUDED.allow_always,
2875
+ frontend_mode = EXCLUDED.frontend_mode
2876
+ `;
2877
+ },
2878
+
2879
+ listPendingTurnActions: async (input) => {
2880
+ const rows = await sql<PendingActionRow[]>`
2881
+ SELECT *
2882
+ FROM ai.pending_actions
2883
+ WHERE conversation_id = ${input.conversationId}
2884
+ AND turn_id = ${input.turnId}
2885
+ AND status = 'pending'
2886
+ ORDER BY created_at ASC
2887
+ `;
2888
+ return rows.map(pendingActionToPublicEvent);
2889
+ },
2890
+
2891
+ getPendingTurnAction: async (input) => {
2892
+ const rows = await sql<PendingActionRow[]>`
2893
+ SELECT *
2894
+ FROM ai.pending_actions
2895
+ WHERE conversation_id = ${input.conversationId}
2896
+ AND turn_id = ${input.turnId}
2897
+ AND call_id = ${input.callId}
2898
+ LIMIT 1
2899
+ `;
2900
+ return rows[0] ? rowToPendingActionRecord(rows[0]) : null;
2901
+ },
2902
+
2903
+ listPendingActionRecords: async (input) => {
2904
+ const rows = await sql<PendingActionRow[]>`
2905
+ SELECT *
2906
+ FROM ai.pending_actions
2907
+ WHERE conversation_id = ${input.conversationId}
2908
+ AND turn_id = ${input.turnId}
2909
+ AND status = 'pending'
2910
+ ORDER BY created_at ASC
2911
+ `;
2912
+ return rows.map(rowToPendingActionRecord);
2913
+ },
2914
+
2915
+ listResolvedPendingActions: async (input) => {
2916
+ const rows = await sql<PendingActionRow[]>`
2917
+ SELECT *
2918
+ FROM ai.pending_actions
2919
+ WHERE conversation_id = ${input.conversationId}
2920
+ AND turn_id = ${input.turnId}
2921
+ AND status = 'resolved'
2922
+ ORDER BY created_at ASC
2923
+ `;
2924
+ return rows.map(rowToPendingActionRecord);
2925
+ },
2926
+
2927
+ resolvePendingTurnAction: async (input) => {
2928
+ const rows = await sql<PendingActionRow[]>`
2929
+ UPDATE ai.pending_actions
2930
+ SET status = 'resolved',
2931
+ resolved_event = ${JSON.stringify(input.event)}::jsonb,
2932
+ resolved_at = now()
2933
+ WHERE conversation_id = ${input.conversationId}
2934
+ AND turn_id = ${input.turnId}
2935
+ AND call_id = ${input.callId}
2936
+ AND status = 'pending'
2937
+ RETURNING *
2938
+ `;
2939
+ return rows[0] ? rowToPendingActionRecord(rows[0]) : null;
2940
+ },
2941
+
2942
+ clearPendingTurnActions: async (input) => {
2943
+ await sql`
2944
+ UPDATE ai.pending_actions
2945
+ SET status = 'aborted',
2946
+ resolved_at = COALESCE(resolved_at, now())
2947
+ WHERE conversation_id = ${input.conversationId}
2948
+ AND turn_id = ${input.turnId}
2949
+ AND status = 'pending'
2950
+ `;
2951
+ },
2952
+
2953
+ enqueueTurnSteer: async (input) =>
2954
+ sql.begin(async (tx) => {
2955
+ const turnRows = await tx<TurnRow[]>`
2956
+ SELECT *
2957
+ FROM ai.turns
2958
+ WHERE id = ${input.turnId}
2959
+ AND conversation_id = ${input.conversationId}
2960
+ FOR UPDATE
2961
+ `;
2962
+ const turn = turnRows[0];
2963
+ if (!turn) return { ok: false, reason: "not_found" as const };
2964
+ const runConfig = turn.run_config ? parseJsonValue<AiTurnRunConfig>(turn.run_config) : null;
2965
+ if (runConfig?.kind === "compact") return { ok: false, reason: "not_chat" as const };
2966
+ if (!(["queued", "running", "waiting_for_action"] as AiTurnStatus[]).includes(turn.status)) {
2967
+ return { ok: false, reason: "not_active" as const };
2968
+ }
2969
+
2970
+ const existing = await tx<TurnSteerRow[]>`
2971
+ SELECT *
2972
+ FROM ai.turn_steers
2973
+ WHERE turn_id = ${input.turnId}
2974
+ AND client_request_id = ${input.clientRequestId}
2975
+ LIMIT 1
2976
+ `;
2977
+ if (existing[0]) return { ok: true, steer: rowToTurnSteer(existing[0]) };
2978
+
2979
+ const rows = await tx<TurnSteerRow[]>`
2980
+ INSERT INTO ai.turn_steers (conversation_id, turn_id, seq, client_request_id, text)
2981
+ VALUES (
2982
+ ${input.conversationId},
2983
+ ${input.turnId},
2984
+ (SELECT COALESCE(MAX(seq), 0) + 1 FROM ai.turn_steers WHERE turn_id = ${input.turnId}),
2985
+ ${input.clientRequestId},
2986
+ ${input.text}
2987
+ )
2988
+ RETURNING *
2989
+ `;
2990
+ return { ok: true, steer: rowToTurnSteer(rows[0]!) };
2991
+ }),
2992
+
2993
+ listTurnSteers: async (input) => {
2994
+ const rows = await sql<TurnSteerRow[]>`
2995
+ SELECT *
2996
+ FROM ai.turn_steers
2997
+ WHERE conversation_id = ${input.conversationId}
2998
+ AND turn_id = ${input.turnId}
2999
+ ORDER BY seq ASC
3000
+ `;
3001
+ return rows.map(rowToTurnSteer);
3002
+ },
3003
+
3004
+ takePendingTurnSteers: async (input) =>
3005
+ sql.begin(async (tx) => {
3006
+ const owner = await tx<{ id: string }[]>`
3007
+ SELECT id
3008
+ FROM ai.turns
3009
+ WHERE id = ${input.turnId}
3010
+ AND conversation_id = ${input.conversationId}
3011
+ AND status = 'running'
3012
+ AND lease_owner = ${input.leaseOwner}
3013
+ AND cancel_requested_at IS NULL
3014
+ AND lease_expires_at > now()
3015
+ FOR UPDATE
3016
+ `;
3017
+ if (!owner[0]) throw new Error("AI turn lost its lease while taking steering.");
3018
+
3019
+ const pending = await tx<TurnSteerRow[]>`
3020
+ SELECT *
3021
+ FROM ai.turn_steers
3022
+ WHERE conversation_id = ${input.conversationId}
3023
+ AND turn_id = ${input.turnId}
3024
+ AND status = 'pending'
3025
+ ORDER BY seq ASC
3026
+ FOR UPDATE
3027
+ `;
3028
+ if (pending.length === 0) return [];
3029
+
3030
+ await tx`SELECT id FROM ai.conversations WHERE id = ${input.conversationId} FOR UPDATE`;
3031
+ const consumed: AiTurnSteer[] = [];
3032
+ for (const steer of pending) {
3033
+ const message = await insertMessageLocked(
3034
+ {
3035
+ conversationId: input.conversationId,
3036
+ message: { role: "user", content: [{ type: "text", text: steer.text }] },
3037
+ loopId: input.turnId,
3038
+ meta: { steerId: steer.id },
3039
+ },
3040
+ tx,
3041
+ );
3042
+ const rows = await tx<TurnSteerRow[]>`
3043
+ UPDATE ai.turn_steers
3044
+ SET status = 'consumed', message_id = ${message.id}, consumed_at = now()
3045
+ WHERE id = ${steer.id}
3046
+ AND status = 'pending'
3047
+ RETURNING *
3048
+ `;
3049
+ if (rows[0]) consumed.push(rowToTurnSteer(rows[0]));
3050
+ }
3051
+ return consumed;
3052
+ }),
3053
+
3054
+ createSessionStore: (input): SessionStore => ({
3055
+ load: async (): Promise<StoreEntry[]> => {
3056
+ // The loop must only ever see the active model context, never archived history.
3057
+ const rows = await aiConversations.listContextMessages({ conversationId: input.conversationId });
3058
+ return rows.map((row) => ({
3059
+ seq: row.seq,
3060
+ kind: row.kind,
3061
+ message:
3062
+ input.turnInput !== undefined && input.turnId === row.loopId && row.message.role === "user" && !row.meta?.steerId
3063
+ ? {
3064
+ role: "user" as const,
3065
+ content:
3066
+ typeof input.turnInput === "string"
3067
+ ? [{ type: "text" as const, text: input.turnInput }]
3068
+ : input.turnInput.map((part) => (typeof part === "string" ? { type: "text" as const, text: part } : part)),
3069
+ }
3070
+ : row.message,
3071
+ }));
3072
+ },
3073
+ append: async (message, opts) => {
3074
+ // Initial input and durable steering are already persisted transactionally before Nessi appends them.
3075
+ if (message.role === "user") return;
3076
+ const meta = toolMessageMeta(message, input.toolPresentations, input.rejectedToolCallIds);
3077
+
3078
+ if (input.turnId && input.leaseOwner) {
3079
+ const appended = await appendTurnOwnedMessage({
3080
+ conversationId: input.conversationId,
3081
+ turnId: input.turnId,
3082
+ leaseOwner: input.leaseOwner,
3083
+ message,
3084
+ kind: opts?.kind,
3085
+ seq: opts?.seq,
3086
+ loopId: opts?.kind === "summary" ? null : input.turnId,
3087
+ modelProfileId: input.modelProfileId,
3088
+ meta,
3089
+ });
3090
+ if (!appended) {
3091
+ throw new Error("AI turn lost its lease while writing a message.");
3092
+ }
3093
+ return;
3094
+ }
3095
+
3096
+ await sql.begin(async (tx) => {
3097
+ await tx`SELECT id FROM ai.conversations WHERE id = ${input.conversationId} FOR UPDATE`;
3098
+ await insertMessageLocked(
3099
+ {
3100
+ conversationId: input.conversationId,
3101
+ message,
3102
+ kind: opts?.kind,
3103
+ seq: opts?.seq,
3104
+ loopId: input.turnId && opts?.kind !== "summary" ? input.turnId : null,
3105
+ modelProfileId: input.modelProfileId,
3106
+ meta,
3107
+ },
3108
+ tx,
3109
+ );
3110
+ });
3111
+ },
3112
+ }),
3113
+ };