experimental-a2 0.20.0 → 0.22.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 (309) hide show
  1. package/CHANGELOG.md +55 -0
  2. package/README.md +1 -1
  3. package/dist/{actor-C7b-1arn.d.ts → actor-CqibLgjQ.d.ts} +15 -7
  4. package/dist/actor-CqibLgjQ.d.ts.map +1 -0
  5. package/dist/actor-client.d.ts +1 -1
  6. package/dist/actor-client.js +2 -2
  7. package/dist/actor-react.d.ts +3 -3
  8. package/dist/actor-react.js +5 -5
  9. package/dist/{actor-shared-t7w8xIKP.js → actor-shared-DFET5q16.js} +3 -3
  10. package/dist/actor-shared-DFET5q16.js.map +1 -0
  11. package/dist/actor.d.ts +1 -1
  12. package/dist/actor.js +54 -43
  13. package/dist/actor.js.map +1 -1
  14. package/dist/{ai-Cz3hqVhk.d.ts → ai-DAu77CDr.d.ts} +8 -8
  15. package/dist/{ai-Cz3hqVhk.d.ts.map → ai-DAu77CDr.d.ts.map} +1 -1
  16. package/dist/{ai-mMiWul1T.js → ai-EEfKyc-z.js} +9 -9
  17. package/dist/ai-EEfKyc-z.js.map +1 -0
  18. package/dist/ai-client.d.ts +2 -2
  19. package/dist/ai-client.js +2 -2
  20. package/dist/ai-fx.d.ts +4 -2
  21. package/dist/ai-fx.d.ts.map +1 -1
  22. package/dist/ai-fx.js +214 -67
  23. package/dist/ai-fx.js.map +1 -1
  24. package/dist/{ai-harness-CV6rXbB3.d.ts → ai-harness-DTjkAr2T.d.ts} +2 -2
  25. package/dist/{ai-harness-CV6rXbB3.d.ts.map → ai-harness-DTjkAr2T.d.ts.map} +1 -1
  26. package/dist/{ai-harness-Clbg5ei7.js → ai-harness-dQeJaR1t.js} +7 -3
  27. package/dist/ai-harness-dQeJaR1t.js.map +1 -0
  28. package/dist/ai-pi.d.ts +1 -1
  29. package/dist/ai-pi.js +3 -3
  30. package/dist/ai-pi.js.map +1 -1
  31. package/dist/ai-react.d.ts +5 -5
  32. package/dist/ai-react.js +2 -2
  33. package/dist/ai-server.d.ts +14 -17
  34. package/dist/ai-server.d.ts.map +1 -1
  35. package/dist/ai-server.js +86 -73
  36. package/dist/ai-server.js.map +1 -1
  37. package/dist/ai.d.ts +2 -2
  38. package/dist/ai.js +2 -2
  39. package/dist/{client-BhHLoNKF.js → client-BQS4i_V9.js} +29 -6
  40. package/dist/client-BQS4i_V9.js.map +1 -0
  41. package/dist/{client-h_DKFAjj.d.ts → client-CUMbS_38.d.ts} +3 -3
  42. package/dist/{client-h_DKFAjj.d.ts.map → client-CUMbS_38.d.ts.map} +1 -1
  43. package/dist/client.d.ts +3 -3
  44. package/dist/client.js +1 -1
  45. package/dist/{contract-BYKASlCV.js → contract-CWukt01d.js} +2 -2
  46. package/dist/contract-CWukt01d.js.map +1 -0
  47. package/dist/index.d.ts +5 -5
  48. package/dist/index.js +3 -3
  49. package/dist/{initial-snapshot-Bx-c0pDP.d.ts → initial-snapshot-D3Lve43o.d.ts} +4 -4
  50. package/dist/initial-snapshot-D3Lve43o.d.ts.map +1 -0
  51. package/dist/{internal-CscSY7K_.js → internal-CvsrprnU.js} +40 -16
  52. package/dist/internal-CvsrprnU.js.map +1 -0
  53. package/dist/otel.d.ts +1 -1
  54. package/dist/otel.js +1 -1
  55. package/dist/otel.js.map +1 -1
  56. package/dist/{react-BBXgHWFc.d.ts → react-BoHvC3v9.d.ts} +4 -4
  57. package/dist/{react-BBXgHWFc.d.ts.map → react-BoHvC3v9.d.ts.map} +1 -1
  58. package/dist/react.d.ts +1 -1
  59. package/dist/react.js +1 -1
  60. package/dist/{reducer-CqA59Q6U.d.ts → reducer-CYPHsSmd.d.ts} +2 -2
  61. package/dist/{reducer-CqA59Q6U.d.ts.map → reducer-CYPHsSmd.d.ts.map} +1 -1
  62. package/dist/{reducer-BAXc8JyF.js → reducer-CjmvZSAb.js} +2 -2
  63. package/dist/{reducer-BAXc8JyF.js.map → reducer-CjmvZSAb.js.map} +1 -1
  64. package/dist/runtime-consumer-CIeL33wj.js +12 -0
  65. package/dist/runtime-consumer-CIeL33wj.js.map +1 -0
  66. package/dist/scheduler-qstash.d.ts +2 -2
  67. package/dist/scheduler-qstash.js +3 -3
  68. package/dist/scheduler-task-X1SToDjP.js.map +1 -1
  69. package/dist/scheduler-vercel.d.ts +2 -2
  70. package/dist/scheduler-vercel.js +3 -3
  71. package/dist/scheduler-vercel.js.map +1 -1
  72. package/dist/{server-qyb5aJJG.js → server-DtFrWxi3.js} +412 -116
  73. package/dist/server-DtFrWxi3.js.map +1 -0
  74. package/dist/{server-DYAATFuT.d.ts → server-qgez0Xny.d.ts} +108 -41
  75. package/dist/server-qgez0Xny.d.ts.map +1 -0
  76. package/dist/server.browser.js +1 -1
  77. package/dist/server.browser.js.map +1 -1
  78. package/dist/server.d.ts +4 -4
  79. package/dist/server.js +2 -2
  80. package/dist/{session-clear-C-QwTXV1.d.ts → session-clear-D1PFe7xl.d.ts} +2 -2
  81. package/dist/session-clear-D1PFe7xl.d.ts.map +1 -0
  82. package/dist/{session-clear-DGI4jTQ5.js → session-clear-wHd5GCpZ.js} +5 -4
  83. package/dist/session-clear-wHd5GCpZ.js.map +1 -0
  84. package/dist/store-D0MhBvg6.d.ts +532 -0
  85. package/dist/store-D0MhBvg6.d.ts.map +1 -0
  86. package/dist/store-N8PXxDAS.js.map +1 -1
  87. package/dist/store-codec-DwrWlgQx.js +65 -0
  88. package/dist/store-codec-DwrWlgQx.js.map +1 -0
  89. package/dist/store-memory.d.ts +1 -1
  90. package/dist/store-memory.d.ts.map +1 -1
  91. package/dist/store-memory.js +406 -225
  92. package/dist/store-memory.js.map +1 -1
  93. package/dist/{store-polling-7WJV0YWP.js → store-polling-BZt2Rf3D.js} +2 -2
  94. package/dist/{store-polling-7WJV0YWP.js.map → store-polling-BZt2Rf3D.js.map} +1 -1
  95. package/dist/store-postgres.d.ts +1 -1
  96. package/dist/store-postgres.d.ts.map +1 -1
  97. package/dist/store-postgres.js +1598 -587
  98. package/dist/store-postgres.js.map +1 -1
  99. package/dist/store-presence-polling-JuAy2Ck2.js +111 -0
  100. package/dist/store-presence-polling-JuAy2Ck2.js.map +1 -0
  101. package/dist/store-redis-http.d.ts +1 -1
  102. package/dist/store-redis-http.js +4 -4
  103. package/dist/store-redis-notify-CsJw_hfD.js +3060 -0
  104. package/dist/store-redis-notify-CsJw_hfD.js.map +1 -0
  105. package/dist/store-redis.d.ts +1 -1
  106. package/dist/store-redis.d.ts.map +1 -1
  107. package/dist/store-redis.js +4 -5
  108. package/dist/store-redis.js.map +1 -1
  109. package/dist/store-sqlite.d.ts +1 -1
  110. package/dist/store-sqlite.d.ts.map +1 -1
  111. package/dist/store-sqlite.js +527 -299
  112. package/dist/store-sqlite.js.map +1 -1
  113. package/dist/stream-buffer-DrTyiIuo.js +57 -0
  114. package/dist/stream-buffer-DrTyiIuo.js.map +1 -0
  115. package/dist/{telemetry-Cyfqo6Qt.d.ts → telemetry-CWwsh-Gx.d.ts} +3 -3
  116. package/dist/telemetry-CWwsh-Gx.d.ts.map +1 -0
  117. package/dist/{wire-BKxH1vbP.js → wire-CV-Z7wz-.js} +6 -5
  118. package/dist/wire-CV-Z7wz-.js.map +1 -0
  119. package/docs/01-quickstart.mdx +21 -15
  120. package/docs/02-examples.mdx +1 -1
  121. package/docs/actors/01-introduction.mdx +13 -2
  122. package/docs/actors/02-concurrency.mdx +1 -1
  123. package/docs/agents/01-introduction.mdx +1 -1
  124. package/docs/agents/02-tools.mdx +12 -11
  125. package/docs/agents/03-conversations.mdx +36 -25
  126. package/docs/agents/04-harnesses.mdx +14 -7
  127. package/docs/client/03-presence.mdx +18 -14
  128. package/docs/contract/01-events.mdx +2 -2
  129. package/docs/contract/02-state.mdx +18 -14
  130. package/docs/contract/03-sessions.mdx +32 -25
  131. package/docs/index.mdx +29 -24
  132. package/docs/reference/01-core.mdx +7 -6
  133. package/docs/reference/02-server.mdx +250 -191
  134. package/docs/reference/04-ai.mdx +33 -23
  135. package/docs/reference/05-telemetry.mdx +27 -13
  136. package/docs/reference/06-errors.mdx +25 -19
  137. package/docs/reference/meta.ts +1 -1
  138. package/docs/schedulers/01-overview.mdx +8 -8
  139. package/docs/schedulers/02-vercel-queues.mdx +2 -2
  140. package/docs/schedulers/03-qstash.mdx +4 -4
  141. package/docs/server/01-consumers.mdx +418 -0
  142. package/docs/server/02-timers.mdx +31 -21
  143. package/docs/server/03-cancellation.mdx +52 -44
  144. package/docs/server/04-routes.mdx +4 -1
  145. package/docs/server/05-sql-projections.mdx +391 -0
  146. package/docs/server/06-guarantees.mdx +61 -51
  147. package/docs/stores/01-overview.mdx +3 -3
  148. package/docs/stores/02-redis.mdx +24 -10
  149. package/docs/stores/03-redis-http.mdx +8 -6
  150. package/docs/stores/04-postgres.mdx +65 -14
  151. package/docs/stores/05-sqlite.mdx +5 -3
  152. package/docs/upgrades/01-overview.mdx +31 -0
  153. package/docs/upgrades/78-0-22.mdx +115 -0
  154. package/docs/upgrades/79-0-21.mdx +165 -0
  155. package/docs/upgrades/80-0-20.mdx +43 -0
  156. package/docs/upgrades/81-0-19.mdx +33 -0
  157. package/docs/upgrades/82-0-18.mdx +33 -0
  158. package/docs/upgrades/83-0-17.mdx +69 -0
  159. package/docs/upgrades/meta.ts +3 -0
  160. package/examples/playground/DEPLOY.md +39 -7
  161. package/examples/playground/app/a0/chat.test.tsx +2 -2
  162. package/examples/playground/app/a0/sandbox-log.ts +17 -15
  163. package/examples/playground/app/a0/sandbox.ts +58 -13
  164. package/examples/playground/app/a0/server.ts +4 -4
  165. package/examples/playground/app/a2slack/a2slack.css +1067 -0
  166. package/examples/playground/app/a2slack/c/[channel]/page.tsx +20 -0
  167. package/examples/playground/app/a2slack/consumers/agent.ts +63 -0
  168. package/examples/playground/app/a2slack/consumers/feed.ts +164 -0
  169. package/examples/playground/app/a2slack/consumers/notify.ts +116 -0
  170. package/examples/playground/app/a2slack/consumers/search.ts +37 -0
  171. package/examples/playground/app/a2slack/consumers/triage.ts +31 -0
  172. package/examples/playground/app/a2slack/consumers/welcome.ts +25 -0
  173. package/examples/playground/app/a2slack/db/a2.ts +209 -0
  174. package/examples/playground/app/a2slack/db/index.ts +73 -0
  175. package/examples/playground/app/a2slack/db/migrations/0000_extensions.sql +4 -0
  176. package/examples/playground/app/a2slack/db/migrations/0001_workspace.sql +74 -0
  177. package/examples/playground/app/a2slack/db/migrations/0002_a2.sql +215 -0
  178. package/examples/playground/app/a2slack/db/migrations/0003_a2_batch.sql +67 -0
  179. package/examples/playground/app/a2slack/db/migrations/0004_a2_session_event_ids.sql +2 -0
  180. package/examples/playground/app/a2slack/db/migrations/meta/0000_snapshot.json +18 -0
  181. package/examples/playground/app/a2slack/db/migrations/meta/0001_snapshot.json +500 -0
  182. package/examples/playground/app/a2slack/db/migrations/meta/0002_snapshot.json +1159 -0
  183. package/examples/playground/app/a2slack/db/migrations/meta/0003_snapshot.json +1159 -0
  184. package/examples/playground/app/a2slack/db/migrations/meta/0004_snapshot.json +1165 -0
  185. package/examples/playground/app/a2slack/db/migrations/meta/_journal.json +41 -0
  186. package/examples/playground/app/a2slack/db/queries.ts +263 -0
  187. package/examples/playground/app/a2slack/db/schema.ts +145 -0
  188. package/examples/playground/app/a2slack/drills/a2slack.drill.ts +708 -0
  189. package/examples/playground/app/a2slack/drills/harness.ts +244 -0
  190. package/examples/playground/app/a2slack/events/route.ts +36 -0
  191. package/examples/playground/app/a2slack/identity.ts +44 -0
  192. package/examples/playground/app/a2slack/inbox/route.ts +8 -0
  193. package/examples/playground/app/a2slack/lag/data/route.ts +8 -0
  194. package/examples/playground/app/a2slack/lag/page.tsx +101 -0
  195. package/examples/playground/app/a2slack/lag.ts +73 -0
  196. package/examples/playground/app/a2slack/layout.tsx +43 -0
  197. package/examples/playground/app/a2slack/messages/route.ts +17 -0
  198. package/examples/playground/app/a2slack/model.test.ts +208 -0
  199. package/examples/playground/app/a2slack/model.ts +369 -0
  200. package/examples/playground/app/a2slack/models.test.ts +72 -0
  201. package/examples/playground/app/a2slack/page.tsx +10 -0
  202. package/examples/playground/app/a2slack/scheduler/route.ts +5 -0
  203. package/examples/playground/app/a2slack/search/route.ts +40 -0
  204. package/examples/playground/app/a2slack/search-index.ts +108 -0
  205. package/examples/playground/app/a2slack/server.test.ts +315 -0
  206. package/examples/playground/app/a2slack/server.ts +55 -0
  207. package/examples/playground/app/a2slack/sessions.ts +50 -0
  208. package/examples/playground/app/a2slack/thread/route.ts +8 -0
  209. package/examples/playground/app/a2slack/ui/ChannelView.tsx +87 -0
  210. package/examples/playground/app/a2slack/ui/Composer.tsx +117 -0
  211. package/examples/playground/app/a2slack/ui/DotsShadow.tsx +42 -0
  212. package/examples/playground/app/a2slack/ui/Feed.tsx +157 -0
  213. package/examples/playground/app/a2slack/ui/InboxContext.tsx +63 -0
  214. package/examples/playground/app/a2slack/ui/InboxView.tsx +82 -0
  215. package/examples/playground/app/a2slack/ui/Menu.tsx +128 -0
  216. package/examples/playground/app/a2slack/ui/MessageRow.tsx +314 -0
  217. package/examples/playground/app/a2slack/ui/NameGate.tsx +35 -0
  218. package/examples/playground/app/a2slack/ui/SearchContext.tsx +146 -0
  219. package/examples/playground/app/a2slack/ui/SearchResults.tsx +97 -0
  220. package/examples/playground/app/a2slack/ui/Shell.tsx +34 -0
  221. package/examples/playground/app/a2slack/ui/Sidebar.tsx +199 -0
  222. package/examples/playground/app/a2slack/ui/ThreadPanel.tsx +134 -0
  223. package/examples/playground/app/a2slack/ui/useInbox.ts +71 -0
  224. package/examples/playground/app/agent/server.ts +3 -3
  225. package/examples/playground/app/chat/[chatId]/chat-client.tsx +1 -1
  226. package/examples/playground/app/chat/server.ts +45 -35
  227. package/examples/playground/app/components/demo-icons.tsx +7 -0
  228. package/examples/playground/app/components/demos.tsx +7 -0
  229. package/examples/playground/app/orders/[orderId]/order-client.tsx +2 -2
  230. package/examples/playground/app/orders/page.tsx +1 -1
  231. package/examples/playground/app/orders/server.ts +49 -30
  232. package/examples/playground/app/recovery/[recoveryId]/recovery-client.tsx +14 -15
  233. package/examples/playground/app/recovery/page.tsx +4 -4
  234. package/examples/playground/app/recovery/server.ts +23 -32
  235. package/examples/playground/drizzle.config.ts +14 -0
  236. package/examples/playground/lib/store.ts +1 -1
  237. package/examples/playground/next.config.ts +6 -1
  238. package/examples/playground/package.json +12 -1
  239. package/examples/playground/scripts/migrate.mjs +31 -0
  240. package/examples/playground/vercel.json +9 -0
  241. package/examples/playground/vitest.drills.config.ts +32 -0
  242. package/package.json +5 -2
  243. package/sql/postgres/drizzle-schema.ts +209 -0
  244. package/sql/postgres/schema.sql +227 -67
  245. package/src/actor-shared.ts +10 -2
  246. package/src/actor.ts +101 -71
  247. package/src/ai-agent.ts +162 -111
  248. package/src/ai-control-server.ts +8 -8
  249. package/src/ai-fx.ts +324 -82
  250. package/src/ai-harness.ts +17 -2
  251. package/src/ai-pi.ts +2 -2
  252. package/src/ai-server.ts +2 -2
  253. package/src/ai.ts +7 -7
  254. package/src/client.ts +19 -2
  255. package/src/contract.ts +1 -1
  256. package/src/deterministic-id.ts +27 -4
  257. package/src/errors.ts +8 -5
  258. package/src/idempotent-replay.ts +0 -1
  259. package/src/index.ts +1 -1
  260. package/src/internal.ts +57 -16
  261. package/src/otel.ts +1 -1
  262. package/src/postgres-schema.ts +98 -57
  263. package/src/presence-recovery.ts +18 -11
  264. package/src/presence-tombstones.ts +52 -0
  265. package/src/push-envelope.ts +20 -0
  266. package/src/runtime-consumer.ts +10 -0
  267. package/src/scheduler-task.ts +2 -2
  268. package/src/scheduler-vercel.ts +1 -1
  269. package/src/server.browser.ts +1 -1
  270. package/src/server.ts +819 -218
  271. package/src/session-socket.ts +1 -1
  272. package/src/store-codec.ts +66 -0
  273. package/src/store-memory.ts +604 -286
  274. package/src/store-postgres.ts +2006 -857
  275. package/src/store-presence-polling.ts +59 -34
  276. package/src/store-redis-core.ts +1961 -1333
  277. package/src/store-redis-notify.ts +76 -26
  278. package/src/store-redis.ts +2 -3
  279. package/src/store-sqlite.ts +915 -516
  280. package/src/store.ts +241 -102
  281. package/src/telemetry.ts +2 -1
  282. package/src/wire.ts +1 -0
  283. package/dist/actor-C7b-1arn.d.ts.map +0 -1
  284. package/dist/actor-shared-t7w8xIKP.js.map +0 -1
  285. package/dist/ai-harness-Clbg5ei7.js.map +0 -1
  286. package/dist/ai-mMiWul1T.js.map +0 -1
  287. package/dist/client-BhHLoNKF.js.map +0 -1
  288. package/dist/contract-BYKASlCV.js.map +0 -1
  289. package/dist/idempotent-replay-JdgMQRRm.js +0 -21
  290. package/dist/idempotent-replay-JdgMQRRm.js.map +0 -1
  291. package/dist/initial-snapshot-Bx-c0pDP.d.ts.map +0 -1
  292. package/dist/internal-CscSY7K_.js.map +0 -1
  293. package/dist/server-DYAATFuT.d.ts.map +0 -1
  294. package/dist/server-qyb5aJJG.js.map +0 -1
  295. package/dist/session-clear-C-QwTXV1.d.ts.map +0 -1
  296. package/dist/session-clear-DGI4jTQ5.js.map +0 -1
  297. package/dist/store-CyLUy86v.d.ts +0 -404
  298. package/dist/store-CyLUy86v.d.ts.map +0 -1
  299. package/dist/store-codec-DTG0Ftek.js +0 -10
  300. package/dist/store-codec-DTG0Ftek.js.map +0 -1
  301. package/dist/store-presence-polling-B1ixeqIS.js +0 -94
  302. package/dist/store-presence-polling-B1ixeqIS.js.map +0 -1
  303. package/dist/store-redis-notify-CM0AfLY0.js +0 -2544
  304. package/dist/store-redis-notify-CM0AfLY0.js.map +0 -1
  305. package/dist/stream-buffer-Cx7AAPGq.js +0 -23
  306. package/dist/stream-buffer-Cx7AAPGq.js.map +0 -1
  307. package/dist/telemetry-Cyfqo6Qt.d.ts.map +0 -1
  308. package/dist/wire-BKxH1vbP.js.map +0 -1
  309. package/docs/server/01-handlers.mdx +0 -227
@@ -0,0 +1,20 @@
1
+ import type { ReactNode } from 'react'
2
+ import { notFound } from 'next/navigation'
3
+ import { channelIdSchema } from '@/app/a2slack/model'
4
+ import { messagePage } from '@/app/a2slack/db/queries'
5
+ import { ChannelView } from '@/app/a2slack/ui/ChannelView'
6
+
7
+ export default async function ChannelPage({
8
+ params,
9
+ }: {
10
+ params: Promise<{ channel: string }>
11
+ }): Promise<ReactNode> {
12
+ const { channel } = await params
13
+ if (!channelIdSchema.safeParse(channel).success) notFound()
14
+
15
+ // The newest page, read at a log index; the client keeps it live from the
16
+ // workspace's events after that index.
17
+ const page = await messagePage(channel)
18
+
19
+ return <ChannelView key={channel} channel={channel} page={page} />
20
+ }
@@ -0,0 +1,63 @@
1
+ /**
2
+ * The bot. A notification addressed to it wakes it, as vboard's watchers
3
+ * wake Pi: it consumes the feed through the message, so the feed's tables
4
+ * have it, reads the whole thread from them, and replies in it. Notifications for one thread wait in
5
+ * its lane, and a burst of them costs one reply: once the bot has answered
6
+ * after a message, the rest find nothing new.
7
+ */
8
+ import 'server-only'
9
+ import { generateText } from 'ai'
10
+ import { consumer } from 'experimental-a2/server'
11
+ import { threadPage } from '../db/queries'
12
+ import { BOT, workspace, type ChatMessage } from '../model'
13
+ import { feed } from './feed'
14
+
15
+ /** The prompt's last messages: anyone can post, so what one call costs is capped. */
16
+ const PROMPT_MESSAGES = 30
17
+ const PROMPT_CHARACTERS = 12_000
18
+
19
+ const promptOf = (thread: readonly ChatMessage[]): string => {
20
+ const lines = thread
21
+ .filter((message) => !message.deleted)
22
+ .slice(-PROMPT_MESSAGES)
23
+ .map((message) => `${message.author.name}: ${message.text}`)
24
+ let prompt = lines.join('\n')
25
+ while (prompt.length > PROMPT_CHARACTERS && lines.length > 1) {
26
+ lines.shift()
27
+ prompt = lines.join('\n')
28
+ }
29
+ return prompt.slice(-PROMPT_CHARACTERS)
30
+ }
31
+
32
+ export const agent = consumer({
33
+ contract: workspace,
34
+ name: 'a2slack-agent',
35
+ types: ['notification.sent'],
36
+ lane: ({ event }) =>
37
+ event.payload.to === BOT.id ? `thread:${event.payload.threadId}` : null,
38
+ }).run(async ({ event, session, signal }) => {
39
+ const { to, channel, threadId, messageIndex } = event.payload
40
+ if (to !== BOT.id) return
41
+ await session.consume(feed, { through: messageIndex, signal })
42
+ const { root, replies } = await threadPage(threadId)
43
+ const thread = [root, ...replies].filter((message) => message !== null)
44
+ if (
45
+ thread.some(
46
+ (message) => message.author.id === BOT.id && message.index > messageIndex,
47
+ )
48
+ )
49
+ return
50
+
51
+ const { text } = await generateText({
52
+ model: 'openai/gpt-5.4',
53
+ instructions: `You are "${BOT.name}", a teammate in a team chat. Someone mentioned you, or replied in a thread you're in. Answer the thread's latest message in a few sentences, plainly. Messages are formatted as "name: text".`,
54
+ prompt: promptOf(thread),
55
+ abortSignal: signal,
56
+ })
57
+ const reply = text.trim().slice(0, 2000)
58
+ if (reply === '') return
59
+ return {
60
+ type: 'message.posted',
61
+ payload: { channel, author: BOT, text: reply, threadId },
62
+ }
63
+ })
@@ -0,0 +1,164 @@
1
+ /**
2
+ * The feed: every table a page reads, kept from the whole log, in order,
3
+ * from the beginning. Each event's transaction first claims it by moving the
4
+ * feed's position up to it, and skips it when the position has already
5
+ * reached it, so a retry, or an attempt that lost its claim, changes nothing
6
+ * and every event lands exactly once. Reads return that position as the
7
+ * page's `through` (db/queries.ts).
8
+ */
9
+ import 'server-only'
10
+ import { and, eq, lt, sql } from 'drizzle-orm'
11
+ import { consumer } from 'experimental-a2/server'
12
+ import { database, type Db } from '../db'
13
+ import { FEED } from '../db/queries'
14
+ import {
15
+ channels,
16
+ messages,
17
+ notifications,
18
+ positions,
19
+ reactions,
20
+ reads,
21
+ } from '../db/schema'
22
+ import { WORKSPACE, workspace, type WorkspaceEvent } from '../model'
23
+
24
+ type Tx = Parameters<Parameters<Db['transaction']>[0]>[0]
25
+
26
+ async function apply(tx: Tx, event: WorkspaceEvent): Promise<void> {
27
+ const at = event.createdAt
28
+ switch (event.type) {
29
+ case 'channel.created':
30
+ await tx
31
+ .insert(channels)
32
+ .values({
33
+ id: event.payload.id,
34
+ createdByName: event.payload.by.name,
35
+ createdAt: at,
36
+ logIndex: event.index,
37
+ })
38
+ .onConflictDoNothing()
39
+ return
40
+ case 'message.posted':
41
+ await tx.insert(messages).values({
42
+ id: event.id,
43
+ channel: event.payload.channel,
44
+ logIndex: event.index,
45
+ threadId: event.payload.threadId ?? null,
46
+ authorId: event.payload.author.id,
47
+ authorName: event.payload.author.name,
48
+ text: event.payload.text,
49
+ postedAt: at,
50
+ })
51
+ return
52
+ case 'message.edited':
53
+ await tx
54
+ .update(messages)
55
+ .set({ text: event.payload.text, editedAt: at })
56
+ .where(
57
+ and(
58
+ eq(messages.id, event.payload.messageId),
59
+ eq(messages.deleted, false),
60
+ ),
61
+ )
62
+ return
63
+ case 'message.deleted':
64
+ await tx
65
+ .update(messages)
66
+ .set({ text: '', deleted: true })
67
+ .where(eq(messages.id, event.payload.messageId))
68
+ await tx
69
+ .delete(reactions)
70
+ .where(eq(reactions.messageId, event.payload.messageId))
71
+ return
72
+ case 'reaction.added':
73
+ await tx
74
+ .insert(reactions)
75
+ .values({
76
+ messageId: event.payload.messageId,
77
+ emoji: event.payload.emoji,
78
+ authorId: event.payload.by.id,
79
+ authorName: event.payload.by.name,
80
+ logIndex: event.index,
81
+ })
82
+ .onConflictDoNothing()
83
+ return
84
+ case 'reaction.removed':
85
+ await tx
86
+ .delete(reactions)
87
+ .where(
88
+ and(
89
+ eq(reactions.messageId, event.payload.messageId),
90
+ eq(reactions.emoji, event.payload.emoji),
91
+ eq(reactions.authorId, event.payload.by.id),
92
+ ),
93
+ )
94
+ return
95
+ case 'message.labeled':
96
+ await tx
97
+ .update(messages)
98
+ .set({ label: event.payload.label })
99
+ .where(
100
+ and(
101
+ eq(messages.id, event.payload.messageId),
102
+ eq(messages.deleted, false),
103
+ ),
104
+ )
105
+ return
106
+ case 'notification.sent':
107
+ await tx.insert(notifications).values({
108
+ id: event.id,
109
+ userId: event.payload.to,
110
+ threadId: event.payload.threadId,
111
+ channel: event.payload.channel,
112
+ reason: event.payload.reason,
113
+ messageId: event.payload.messageId,
114
+ fromId: event.payload.from.id,
115
+ fromName: event.payload.from.name,
116
+ text: event.payload.text,
117
+ logIndex: event.index,
118
+ sentAt: at,
119
+ })
120
+ return
121
+ case 'inbox.read':
122
+ await tx
123
+ .insert(reads)
124
+ .values({
125
+ userId: event.payload.by,
126
+ threadId: event.payload.threadId,
127
+ through: event.payload.through,
128
+ })
129
+ .onConflictDoUpdate({
130
+ target: [reads.userId, reads.threadId],
131
+ set: { through: sql`greatest(${reads.through}, excluded.through)` },
132
+ })
133
+ }
134
+ }
135
+
136
+ /** Applies `event` unless the feed's position has already reached it. */
137
+ export async function feedEvent(db: Db, event: WorkspaceEvent): Promise<void> {
138
+ await db.transaction(async (tx) => {
139
+ // The claim comes first, even for a first event with no row yet: a
140
+ // concurrent duplicate waits on it, then finds the position caught up.
141
+ const claimed = await tx
142
+ .insert(positions)
143
+ .values({ consumer: FEED, logIndex: event.index })
144
+ .onConflictDoUpdate({
145
+ target: positions.consumer,
146
+ set: { logIndex: event.index },
147
+ setWhere: lt(positions.logIndex, event.index),
148
+ })
149
+ .returning({ logIndex: positions.logIndex })
150
+ if (claimed.length === 0) return
151
+ await apply(tx, event)
152
+ })
153
+ }
154
+
155
+ export const feed = consumer({
156
+ contract: workspace,
157
+ name: FEED,
158
+ lane: 'feed',
159
+ backfill: true,
160
+ }).run(async ({ event, session }) => {
161
+ // One position for the workspace: another session's events would move it.
162
+ if (session.id !== WORKSPACE) return
163
+ await feedEvent(await database(), event)
164
+ })
@@ -0,0 +1,116 @@
1
+ /**
2
+ * Notify: who should hear about each message. People named with `@name` are
3
+ * mentioned, and everyone already in the thread hears about a reply. Each
4
+ * becomes a `notification.sent` event addressed to them, returned so it
5
+ * commits with notify's progress through the log. People read them in their
6
+ * inbox; the bot is woken by them (agent.ts).
7
+ *
8
+ * Notify keeps its own record of who's in each thread. It reads only what was
9
+ * recorded before the message, so a retry finds the same people and returns
10
+ * the same notifications, which A2 keeps once.
11
+ */
12
+ import 'server-only'
13
+ import { and, eq, inArray, lt, sql } from 'drizzle-orm'
14
+ import { consumer } from 'experimental-a2/server'
15
+ import { database } from '../db'
16
+ import { people, threadMembers } from '../db/schema'
17
+ import { BOT, SYSTEM, workspace, type Reason } from '../model'
18
+
19
+ /** The lowercase names a message mentions with `@name`. */
20
+ export const mentionedNames = (text: string): string[] => [
21
+ ...new Set(
22
+ [...text.matchAll(/(?:^|[^\w@])@([\w-]{1,32})/g)].map(([, name]) =>
23
+ name!.toLowerCase(),
24
+ ),
25
+ ),
26
+ ]
27
+
28
+ /** A notification for a message this old would be news to nobody. */
29
+ const STALE_MS = 60 * 60 * 1000
30
+
31
+ export const notify = consumer({
32
+ contract: workspace,
33
+ name: 'a2slack-notify',
34
+ types: ['message.posted'],
35
+ lane: 'notify',
36
+ backfill: true,
37
+ }).run(async ({ event }) => {
38
+ const { channel, author, text } = event.payload
39
+ if (author.id === SYSTEM.id) return
40
+ const threadId = event.payload.threadId ?? event.id
41
+ const names = mentionedNames(text)
42
+ const db = await database()
43
+
44
+ const { members, mentioned } = await db.transaction(async (tx) => {
45
+ const [inThread, named] = await Promise.all([
46
+ tx
47
+ .select({ id: threadMembers.personId })
48
+ .from(threadMembers)
49
+ .where(
50
+ and(
51
+ eq(threadMembers.threadId, threadId),
52
+ lt(threadMembers.logIndex, event.index),
53
+ ),
54
+ ),
55
+ names.length === 0
56
+ ? []
57
+ : tx
58
+ .select({ id: people.id })
59
+ .from(people)
60
+ .where(
61
+ and(
62
+ inArray(sql`lower(${people.name})`, names),
63
+ lt(people.logIndex, event.index),
64
+ ),
65
+ ),
66
+ ])
67
+ const mentionedIds = [
68
+ ...named.map((person) => person.id),
69
+ ...(names.includes(BOT.name) ? [BOT.id] : []),
70
+ ]
71
+ await tx
72
+ .insert(people)
73
+ .values({ id: author.id, name: author.name, logIndex: event.index })
74
+ .onConflictDoUpdate({
75
+ target: people.id,
76
+ set: {
77
+ name: author.name,
78
+ logIndex: sql`least(${people.logIndex}, excluded.log_index)`,
79
+ },
80
+ })
81
+ // The author, and whoever the message names, are in the thread from here.
82
+ await tx
83
+ .insert(threadMembers)
84
+ .values(
85
+ [author.id, ...mentionedIds].map((personId) => ({
86
+ threadId,
87
+ personId,
88
+ logIndex: event.index,
89
+ })),
90
+ )
91
+ .onConflictDoNothing()
92
+ return {
93
+ members: inThread.map((member) => member.id),
94
+ mentioned: mentionedIds,
95
+ }
96
+ })
97
+
98
+ if (Date.now() - event.createdAt.getTime() > STALE_MS) return
99
+ const recipients = new Map<string, Reason>()
100
+ for (const id of members) recipients.set(id, 'reply')
101
+ for (const id of mentioned) recipients.set(id, 'mention')
102
+ recipients.delete(author.id)
103
+ return [...recipients].map(([to, reason]) => ({
104
+ type: 'notification.sent' as const,
105
+ payload: {
106
+ channel,
107
+ messageId: event.id,
108
+ to,
109
+ reason,
110
+ threadId,
111
+ messageIndex: event.index,
112
+ from: author,
113
+ text: text.slice(0, 200),
114
+ },
115
+ }))
116
+ })
@@ -0,0 +1,37 @@
1
+ /**
2
+ * Keeps the search index (search-index.ts): one document per message,
3
+ * rewritten by edits and removed by deletions, from the whole log in order.
4
+ * Every write sets a document to its event's value, so a retry is harmless.
5
+ */
6
+ import 'server-only'
7
+ import { consumer } from 'experimental-a2/server'
8
+ import { WORKSPACE, workspace } from '../model'
9
+ import { deleteDocument, putDocument, setDocumentText } from '../search-index'
10
+
11
+ export const searchIndex = consumer({
12
+ contract: workspace,
13
+ name: 'a2slack-search-v1',
14
+ types: ['message.posted', 'message.edited', 'message.deleted'],
15
+ lane: 'search',
16
+ backfill: true,
17
+ }).run(async ({ event, session }) => {
18
+ // One index for the workspace.
19
+ if (session.id !== WORKSPACE) return
20
+ switch (event.type) {
21
+ case 'message.posted':
22
+ await putDocument(event.id, {
23
+ channel: event.payload.channel,
24
+ threadId: event.payload.threadId ?? '',
25
+ authorId: event.payload.author.id,
26
+ authorName: event.payload.author.name,
27
+ text: event.payload.text,
28
+ postedAt: event.createdAt.getTime(),
29
+ })
30
+ return
31
+ case 'message.edited':
32
+ await setDocumentText(event.payload.messageId, event.payload.text)
33
+ return
34
+ case 'message.deleted':
35
+ await deleteDocument(event.payload.messageId)
36
+ }
37
+ })
@@ -0,0 +1,31 @@
1
+ /**
2
+ * Triage labels each top-level message with a model, as vboard's triage
3
+ * watcher labels threads. The label is a returned event, so it commits with
4
+ * triage's progress through the log.
5
+ */
6
+ import 'server-only'
7
+ import { generateText, Output } from 'ai'
8
+ import { consumer } from 'experimental-a2/server'
9
+ import { BOT, LABELS, SYSTEM, workspace } from '../model'
10
+
11
+ export const triage = consumer({
12
+ contract: workspace,
13
+ name: 'a2slack-triage',
14
+ types: ['message.posted'],
15
+ }).run(async ({ event, signal }) => {
16
+ const { author, channel, text, threadId } = event.payload
17
+ if (threadId !== undefined || author.id === SYSTEM.id || author.id === BOT.id)
18
+ return
19
+ const { output } = await generateText({
20
+ model: 'openai/gpt-6-luna-decisions',
21
+ output: Output.choice({ options: [...LABELS] }),
22
+ instructions:
23
+ 'Label one message from a team chat channel. question: it asks something. idea: it proposes something. update: it reports news or progress. chatter: anything else.',
24
+ prompt: text,
25
+ abortSignal: signal,
26
+ })
27
+ return {
28
+ type: 'message.labeled',
29
+ payload: { channel, messageId: event.id, label: output },
30
+ }
31
+ })
@@ -0,0 +1,25 @@
1
+ /**
2
+ * Welcome posts a new channel's opening message, committed with its progress.
3
+ * The channel list, folded through this event, says whether it's the
4
+ * channel's first `channel.created`; a second one gets no welcome.
5
+ */
6
+ import 'server-only'
7
+ import { consumer } from 'experimental-a2/server'
8
+ import { channelsReducer, SYSTEM, workspace } from '../model'
9
+
10
+ export const welcome = consumer({
11
+ contract: workspace,
12
+ name: 'welcome',
13
+ types: ['channel.created'],
14
+ }).run(async ({ event, session }) => {
15
+ const { state } = await session.state(channelsReducer)
16
+ if (state.opened[event.payload.id] !== event.index) return
17
+ return {
18
+ type: 'message.posted',
19
+ payload: {
20
+ channel: event.payload.id,
21
+ author: SYSTEM,
22
+ text: `#${event.payload.id} is open — ${event.payload.by.name} started it.`,
23
+ },
24
+ }
25
+ })
@@ -0,0 +1,209 @@
1
+ /**
2
+ * An example: A2's tables for experimental-a2/store-postgres as Drizzle
3
+ * definitions, the same tables schema.sql next to this file creates. It isn't
4
+ * an API, and nothing imports it. Use it however fits your setup, for example
5
+ * as a copy next to your own schema, so your migrations create A2's tables
6
+ * with yours. Adapt it to your Drizzle version, your ORM, and how you lay out
7
+ * your schema.
8
+ *
9
+ * Whatever creates them, A2 relies on the table and column names, the column
10
+ * types, the primary keys, and the unique index on a2_events (session_id,
11
+ * event_id). The other indexes keep its queries fast. Every name is spelled
12
+ * out, so Drizzle's `casing` setting changes none of them.
13
+ *
14
+ * A2's tables change only when a release's changelog says so; compare your
15
+ * copy with that release's file. Drizzle can't declare functions or
16
+ * triggers: add schema.sql's `a2_batch` function, which A2 runs its writes
17
+ * through, as a custom migration (`drizzle-kit generate --custom`), and the
18
+ * triggers `listenConnectionString` needs are in notifications.sql.
19
+ */
20
+ import { sql } from 'drizzle-orm'
21
+ import {
22
+ bigint,
23
+ boolean,
24
+ index,
25
+ integer,
26
+ jsonb,
27
+ pgTable,
28
+ primaryKey,
29
+ text,
30
+ uniqueIndex,
31
+ } from 'drizzle-orm/pg-core'
32
+
33
+ const int8 = (name: string) => bigint(name, { mode: 'number' })
34
+
35
+ export const a2Events = pgTable(
36
+ 'a2_events',
37
+ {
38
+ sessionId: text('session_id').notNull(),
39
+ idx: int8('idx').notNull(),
40
+ eventType: text('event_type').notNull(),
41
+ payload: jsonb('payload').notNull(),
42
+ eventId: text('event_id').notNull(),
43
+ createdAt: int8('created_at').notNull(),
44
+ cause: jsonb('cause'),
45
+ },
46
+ (table) => [
47
+ primaryKey({
48
+ name: 'a2_events_pkey',
49
+ columns: [table.sessionId, table.idx],
50
+ }),
51
+ uniqueIndex('a2_events_session_event_id').on(
52
+ table.sessionId,
53
+ table.eventId,
54
+ ),
55
+ index('a2_events_types').on(table.sessionId, table.eventType, table.idx),
56
+ ],
57
+ )
58
+
59
+ /**
60
+ * One row per run of a cursor: an event cursor's one event, a lane's run of
61
+ * queued events, or a log cursor's position.
62
+ */
63
+ export const a2Cursors = pgTable(
64
+ 'a2_cursors',
65
+ {
66
+ sessionId: text('session_id').notNull(),
67
+ consumer: text('consumer').notNull(),
68
+ cursorKey: text('cursor_key').notNull(),
69
+ epoch: int8('epoch').notNull(),
70
+ lane: text('lane'),
71
+ kind: text('kind').notNull(),
72
+ position: int8('position').notNull(),
73
+ head: int8('head'),
74
+ types: jsonb('types'),
75
+ attemptCount: integer('attempt_count').notNull().default(0),
76
+ firstClaimedAt: int8('first_claimed_at'),
77
+ lastClaimedAt: int8('last_claimed_at'),
78
+ claimHolder: text('claim_holder'),
79
+ claimExpiresAt: int8('claim_expires_at'),
80
+ failureCount: integer('failure_count').notNull().default(0),
81
+ lastFailedAt: int8('last_failed_at'),
82
+ lastFailedAttempt: integer('last_failed_attempt'),
83
+ lastError: text('last_error'),
84
+ failedAt: int8('failed_at'),
85
+ completedIdx: int8('completed_idx'),
86
+ completedAttempt: integer('completed_attempt'),
87
+ returnedEventIds: jsonb('returned_event_ids'),
88
+ },
89
+ (table) => [
90
+ primaryKey({
91
+ name: 'a2_cursors_pkey',
92
+ columns: [table.sessionId, table.cursorKey, table.consumer, table.epoch],
93
+ }),
94
+ index('a2_cursors_heads')
95
+ .on(table.sessionId, table.head)
96
+ .where(sql`head is not null`),
97
+ index('a2_cursors_lanes')
98
+ .on(table.sessionId)
99
+ .where(sql`kind = 'lane'`),
100
+ ],
101
+ )
102
+
103
+ /** A lane cursor's queue: the events appended for the lane, in log order. */
104
+ export const a2LaneEvents = pgTable(
105
+ 'a2_lane_events',
106
+ {
107
+ sessionId: text('session_id').notNull(),
108
+ consumer: text('consumer').notNull(),
109
+ lane: text('lane').notNull(),
110
+ idx: int8('idx').notNull(),
111
+ done: boolean('done').notNull().default(false),
112
+ completedAttempt: integer('completed_attempt'),
113
+ returnedEventIds: jsonb('returned_event_ids'),
114
+ },
115
+ (table) => [
116
+ primaryKey({
117
+ name: 'a2_lane_events_pkey',
118
+ columns: [table.sessionId, table.consumer, table.lane, table.idx],
119
+ }),
120
+ ],
121
+ )
122
+
123
+ export const a2Snapshots = pgTable(
124
+ 'a2_snapshots',
125
+ {
126
+ sessionId: text('session_id').notNull(),
127
+ reducerName: text('reducer_name').notNull(),
128
+ upToIndex: int8('up_to_index').notNull(),
129
+ state: jsonb('state').notNull(),
130
+ updatedAt: int8('updated_at').notNull(),
131
+ },
132
+ (table) => [
133
+ primaryKey({
134
+ name: 'a2_snapshots_pkey',
135
+ columns: [table.sessionId, table.reducerName],
136
+ }),
137
+ ],
138
+ )
139
+
140
+ export const a2SnapshotHistory = pgTable(
141
+ 'a2_snapshot_history',
142
+ {
143
+ sessionId: text('session_id').notNull(),
144
+ reducerName: text('reducer_name').notNull(),
145
+ upToIndex: int8('up_to_index').notNull(),
146
+ state: jsonb('state').notNull(),
147
+ updatedAt: int8('updated_at').notNull(),
148
+ },
149
+ (table) => [
150
+ primaryKey({
151
+ name: 'a2_snapshot_history_pkey',
152
+ columns: [table.sessionId, table.reducerName, table.upToIndex],
153
+ }),
154
+ ],
155
+ )
156
+
157
+ export const a2SnapshotPins = pgTable(
158
+ 'a2_snapshot_pins',
159
+ {
160
+ sessionId: text('session_id').notNull(),
161
+ eventIndex: int8('event_index').notNull(),
162
+ consumer: text('consumer').notNull(),
163
+ cursorKey: text('cursor_key').notNull(),
164
+ reducerName: text('reducer_name').notNull(),
165
+ upToIndex: int8('up_to_index').notNull(),
166
+ },
167
+ (table) => [
168
+ primaryKey({
169
+ name: 'a2_snapshot_pins_pkey',
170
+ columns: [
171
+ table.sessionId,
172
+ table.eventIndex,
173
+ table.consumer,
174
+ table.cursorKey,
175
+ table.reducerName,
176
+ table.upToIndex,
177
+ ],
178
+ }),
179
+ index('a2_snapshot_pins_checkpoint').on(
180
+ table.sessionId,
181
+ table.reducerName,
182
+ table.upToIndex,
183
+ ),
184
+ ],
185
+ )
186
+
187
+ export const a2Clears = pgTable('a2_clears', {
188
+ sessionId: text('session_id').primaryKey(),
189
+ clearIndex: int8('clear_index').notNull(),
190
+ })
191
+
192
+ export const a2Presence = pgTable(
193
+ 'a2_presence',
194
+ {
195
+ ns: text('ns').notNull(),
196
+ participant: text('participant').notNull(),
197
+ field: text('field').notNull(),
198
+ value: jsonb('value').notNull(),
199
+ seen: int8('seen').notNull(),
200
+ at: int8('at').notNull(),
201
+ expiresAt: int8('expires_at').notNull(),
202
+ },
203
+ (table) => [
204
+ primaryKey({
205
+ name: 'a2_presence_pkey',
206
+ columns: [table.ns, table.participant, table.field],
207
+ }),
208
+ ],
209
+ )