@vellumai/assistant 0.11.8 → 0.11.9-staging.2

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 (326) hide show
  1. package/ARCHITECTURE.md +2 -2
  2. package/Dockerfile +8 -48
  3. package/docker-entrypoint.sh +5 -1
  4. package/docker-kata-apt-env.sh +3 -0
  5. package/docker-kata-apt-shims.sh +127 -0
  6. package/docker-kata-apt-wrapper.sh +45 -0
  7. package/docker-kata-chroot-exec.sh +35 -0
  8. package/docker-kata-pip.sh +8 -2
  9. package/docs/architecture/memory.md +8 -4
  10. package/docs/guardian-request-flow.md +18 -14
  11. package/docs/trusted-contact-access.md +1 -7
  12. package/node_modules/@vellumai/avatar-catalog/src/catalog.ts +7 -1
  13. package/node_modules/@vellumai/avatar-catalog/src/index.ts +1 -1
  14. package/node_modules/@vellumai/ces-client/node_modules/@vellumai/service-contracts/package.json +4 -1
  15. package/node_modules/@vellumai/ces-client/node_modules/@vellumai/service-contracts/src/guardian-requests.ts +18 -0
  16. package/node_modules/@vellumai/ces-client/node_modules/@vellumai/service-contracts/src/index.ts +1 -0
  17. package/node_modules/@vellumai/ces-client/node_modules/@vellumai/service-contracts/src/platform-credential.ts +41 -0
  18. package/node_modules/@vellumai/ces-client/node_modules/@vellumai/service-contracts/src/reactions.ts +60 -0
  19. package/node_modules/@vellumai/gateway-client/node_modules/@vellumai/service-contracts/package.json +4 -1
  20. package/node_modules/@vellumai/gateway-client/node_modules/@vellumai/service-contracts/src/guardian-requests.ts +18 -0
  21. package/node_modules/@vellumai/gateway-client/node_modules/@vellumai/service-contracts/src/index.ts +1 -0
  22. package/node_modules/@vellumai/gateway-client/node_modules/@vellumai/service-contracts/src/platform-credential.ts +41 -0
  23. package/node_modules/@vellumai/gateway-client/node_modules/@vellumai/service-contracts/src/reactions.ts +60 -0
  24. package/node_modules/@vellumai/gateway-client/src/__tests__/guardian-request-contract.test.ts +0 -18
  25. package/node_modules/@vellumai/gateway-client/src/__tests__/inbound-event-kind.test.ts +110 -5
  26. package/node_modules/@vellumai/gateway-client/src/guardian-request-contract.ts +5 -33
  27. package/node_modules/@vellumai/gateway-client/src/inbound-contract.ts +3 -0
  28. package/node_modules/@vellumai/gateway-client/src/inbound-event-kind.ts +100 -9
  29. package/node_modules/@vellumai/gateway-client/src/index.ts +1 -2
  30. package/node_modules/@vellumai/gateway-client/src/outbound-contract.ts +9 -0
  31. package/node_modules/@vellumai/service-contracts/package.json +4 -1
  32. package/node_modules/@vellumai/service-contracts/src/guardian-requests.ts +18 -0
  33. package/node_modules/@vellumai/service-contracts/src/index.ts +1 -0
  34. package/node_modules/@vellumai/service-contracts/src/platform-credential.ts +41 -0
  35. package/node_modules/@vellumai/service-contracts/src/reactions.ts +60 -0
  36. package/openapi.yaml +193 -4
  37. package/package.json +2 -2
  38. package/src/__tests__/access-request-card-view.test.ts +6 -5
  39. package/src/__tests__/access-request-seed-content-blocks.test.ts +5 -2
  40. package/src/__tests__/agent-loop-mutable-latest-user-message.test.ts +43 -82
  41. package/src/__tests__/always-loaded-tools-guard.test.ts +8 -2
  42. package/src/__tests__/attachment-stored-path-annotation.test.ts +80 -0
  43. package/src/__tests__/attachments-store.test.ts +27 -0
  44. package/src/__tests__/attachments.test.ts +43 -0
  45. package/src/__tests__/canned-reply-release.test.ts +48 -5
  46. package/src/__tests__/channel-delivery-store.test.ts +129 -18
  47. package/src/__tests__/channel-reply-delivery.test.ts +553 -93
  48. package/src/__tests__/channel-retry-sweep.test.ts +199 -0
  49. package/src/__tests__/client-os-metadata-persistence.test.ts +32 -3
  50. package/src/__tests__/conversation-error.test.ts +15 -0
  51. package/src/__tests__/conversation-load-history-repair.test.ts +118 -0
  52. package/src/__tests__/conversation-pairing.test.ts +141 -1
  53. package/src/__tests__/conversation-routes-disk-view.test.ts +28 -1
  54. package/src/__tests__/conversation-routes-slash-commands.test.ts +125 -0
  55. package/src/__tests__/conversation-runtime-assembly.test.ts +79 -0
  56. package/src/__tests__/conversation-store-ephemeral.test.ts +323 -10
  57. package/src/__tests__/conversation-sync-tags.test.ts +2 -37
  58. package/src/__tests__/credential-health-service.test.ts +98 -10
  59. package/src/__tests__/delete-propagation.test.ts +469 -0
  60. package/src/__tests__/dm-persistence.test.ts +16 -0
  61. package/src/__tests__/docker-kata-apt-shims.test.ts +135 -0
  62. package/src/__tests__/forbidden-legacy-symbols.test.ts +12 -0
  63. package/src/__tests__/gemini-provider.test.ts +46 -0
  64. package/src/__tests__/guardian-card-withdrawal.test.ts +0 -22
  65. package/src/__tests__/guardian-gateway-sim.ts +0 -23
  66. package/src/__tests__/guardian-question-mode.test.ts +108 -0
  67. package/src/__tests__/guardian-reply-router-answer-mode.test.ts +0 -2
  68. package/src/__tests__/guardian-routing-invariants.test.ts +26 -12
  69. package/src/__tests__/host-proxy-interface.test.ts +10 -0
  70. package/src/__tests__/inbound-slack-persistence.test.ts +16 -0
  71. package/src/__tests__/injector-v3-suppression.test.ts +43 -9
  72. package/src/__tests__/list-messages-page-latest.test.ts +127 -0
  73. package/src/__tests__/list-messages-system-card.test.ts +103 -0
  74. package/src/__tests__/media-resolve-image-validation.test.ts +77 -0
  75. package/src/__tests__/notification-decision-fallback.test.ts +199 -77
  76. package/src/__tests__/notification-decision-strategy.test.ts +198 -213
  77. package/src/__tests__/notification-discord-adapter.test.ts +25 -0
  78. package/src/__tests__/notification-slack-adapter.test.ts +133 -0
  79. package/src/__tests__/notification-telegram-adapter.test.ts +36 -0
  80. package/src/__tests__/openai-provider.test.ts +5 -5
  81. package/src/__tests__/outbound-slack-persistence.test.ts +85 -72
  82. package/src/__tests__/persist-user-message-set-processing-failure.test.ts +83 -65
  83. package/src/__tests__/platform-client-verify-credential.test.ts +100 -0
  84. package/src/__tests__/plugin-import-boundary-guard.test.ts +1 -1
  85. package/src/__tests__/pricing.test.ts +11 -0
  86. package/src/__tests__/process-message-display-content.test.ts +20 -9
  87. package/src/__tests__/processing-acquire-fenced-guard.test.ts +56 -0
  88. package/src/__tests__/provider-meta-persistence.test.ts +67 -0
  89. package/src/__tests__/reaction-persistence.test.ts +22 -198
  90. package/src/__tests__/run-conversation-turn-persistence.test.ts +5 -2
  91. package/src/__tests__/scripted-turn-metadata-persistence.test.ts +16 -0
  92. package/src/__tests__/skill-load-tool.test.ts +27 -0
  93. package/src/__tests__/skills.test.ts +34 -1
  94. package/src/__tests__/strip-memory-injections.test.ts +3 -4
  95. package/src/__tests__/terminal-tools.test.ts +9 -0
  96. package/src/__tests__/thread-backfill.test.ts +5 -3
  97. package/src/__tests__/unified-turn-context-location.test.ts +76 -0
  98. package/src/__tests__/voice-session-bridge.test.ts +18 -0
  99. package/src/__tests__/watch-retro-report-payload.test.ts +185 -0
  100. package/src/__tests__/watch-retro-tool-availability.test.ts +82 -0
  101. package/src/__tests__/workspace-migration-151-repair-renamed-fireworks-deepseek-pro-model-id.test.ts +235 -0
  102. package/src/__tests__/workspace-migration-152-repair-retired-fireworks-minimax-m2p7-model-id.test.ts +233 -0
  103. package/src/agent/attachments.ts +13 -2
  104. package/src/agent/loop.ts +3 -19
  105. package/src/api/README.md +9 -5
  106. package/src/api/index.ts +9 -8
  107. package/src/api/package.json +1 -0
  108. package/src/api/responses/conversation-message.ts +8 -0
  109. package/src/api/responses/home.ts +7 -18
  110. package/src/api/surfaces.ts +114 -7
  111. package/src/approvals/AGENTS.md +1 -1
  112. package/src/channels/__tests__/gateway-guardian-requests.test.ts +1 -23
  113. package/src/channels/__tests__/types.test.ts +22 -1
  114. package/src/channels/gateway-guardian-requests.ts +0 -22
  115. package/src/channels/types.ts +8 -6
  116. package/src/cli/__tests__/catalog-search-help.test.ts +18 -0
  117. package/src/cli/commands/channels/__tests__/channels.test.ts +26 -0
  118. package/src/cli/commands/channels/index.help.ts +19 -6
  119. package/src/cli/commands/channels/index.ts +6 -2
  120. package/src/cli/commands/db/__tests__/status.test.ts +22 -0
  121. package/src/cli/commands/db/index.help.ts +1 -1
  122. package/src/cli/commands/db/status.ts +172 -1
  123. package/src/cli/commands/platform/__tests__/connect.test.ts +29 -0
  124. package/src/cli/commands/platform/connect.ts +14 -5
  125. package/src/cli/commands/plugins.help.ts +6 -5
  126. package/src/cli/lib/__tests__/install-from-github.test.ts +0 -8
  127. package/src/cli/lib/__tests__/install-from-platform.test.ts +72 -0
  128. package/src/cli/lib/__tests__/plugin-catalog-local.test.ts +33 -0
  129. package/src/cli/lib/bundled-marketplace.json +14 -0
  130. package/src/cli/lib/install-from-github.ts +9 -5
  131. package/src/cli/lib/install-from-platform.ts +12 -1
  132. package/src/config/__tests__/assistant-initiated-threads-gate.test.ts +61 -0
  133. package/src/config/assistant-initiated-threads-gate.ts +50 -0
  134. package/src/config/bundled-skills/acp/SKILL.md +10 -3
  135. package/src/config/bundled-skills/schedule/SKILL.md +25 -11
  136. package/src/config/bundled-skills/schedule/references/SCRIPT_MODE_PATTERNS.md +3 -1
  137. package/src/config/call-site-defaults.ts +4 -1
  138. package/src/config/feature-flag-registry.json +21 -4
  139. package/src/config/schemas/memory-v3.ts +4 -3
  140. package/src/context/strip-injections.ts +17 -56
  141. package/src/conversations/__tests__/message-consolidation.test.ts +39 -0
  142. package/src/conversations/message-consolidation.ts +4 -3
  143. package/src/credential-health/credential-health-service.ts +130 -0
  144. package/src/daemon/__tests__/conversation-tool-setup.test.ts +31 -0
  145. package/src/daemon/conversation-agent-loop-handlers.ts +55 -59
  146. package/src/daemon/conversation-error.ts +2 -0
  147. package/src/daemon/conversation-messaging.ts +195 -47
  148. package/src/daemon/conversation-process.ts +20 -5
  149. package/src/daemon/conversation-runtime-assembly.ts +48 -39
  150. package/src/daemon/conversation-store.ts +159 -11
  151. package/src/daemon/conversation-tool-setup.ts +9 -2
  152. package/src/daemon/conversation.ts +290 -19
  153. package/src/daemon/dictation-text-processing.ts +2 -7
  154. package/src/daemon/handlers/config-channels.ts +33 -3
  155. package/src/daemon/handlers/config-model.test.ts +1 -0
  156. package/src/daemon/handlers/shared.ts +9 -0
  157. package/src/daemon/port-oversized-content.test.ts +117 -0
  158. package/src/daemon/port-oversized-content.ts +110 -0
  159. package/src/daemon/process-message.ts +87 -16
  160. package/src/daemon/reaction-record.test.ts +23 -11
  161. package/src/daemon/reaction-record.ts +6 -9
  162. package/src/documents/document-store.ts +1 -5
  163. package/src/home/conversation-starter-validation.ts +1 -5
  164. package/src/live-voice/__tests__/live-voice-photo.test.ts +910 -50
  165. package/src/live-voice/__tests__/live-voice-sight-frame-inline.test.ts +31 -25
  166. package/src/live-voice/__tests__/live-voice-sight-frame.test.ts +70 -1
  167. package/src/live-voice/live-voice-photo.ts +544 -61
  168. package/src/live-voice/live-voice-session.ts +6 -2
  169. package/src/live-voice/protocol.ts +20 -1
  170. package/src/messaging/provider-message-metadata.ts +40 -1
  171. package/src/messaging/providers/__tests__/transport-dispatch.test.ts +10 -2
  172. package/src/messaging/providers/channel-transport.ts +28 -0
  173. package/src/messaging/providers/discord/send.ts +3 -2
  174. package/src/messaging/providers/slack/api.ts +11 -1
  175. package/src/messaging/providers/slack/message-metadata.test.ts +133 -0
  176. package/src/messaging/providers/slack/message-metadata.ts +141 -1
  177. package/src/messaging/providers/slack/send.test.ts +58 -0
  178. package/src/messaging/providers/slack/send.ts +4 -4
  179. package/src/messaging/providers/slack/transport.ts +28 -2
  180. package/src/messaging/providers/telegram-bot/send.test.ts +159 -0
  181. package/src/messaging/providers/telegram-bot/send.ts +93 -0
  182. package/src/messaging/providers/telegram-bot/transport.ts +71 -0
  183. package/src/messaging/reaction-envelopes.test.ts +73 -0
  184. package/src/messaging/reaction-envelopes.ts +33 -5
  185. package/src/messaging/read-provider-metadata.ts +4 -3
  186. package/src/monitoring/recovery/__tests__/stranded-delivery-events.test.ts +208 -0
  187. package/src/monitoring/recovery/db.ts +35 -0
  188. package/src/monitoring/recovery/orphaned-channel-events.ts +3 -22
  189. package/src/monitoring/recovery/run-recovery.ts +6 -2
  190. package/src/monitoring/recovery/stale-processing.ts +3 -20
  191. package/src/monitoring/recovery/stranded-delivery-events.ts +68 -0
  192. package/src/notifications/AGENTS.md +2 -2
  193. package/src/notifications/README.md +2 -2
  194. package/src/notifications/__tests__/assistant-reply-producer.test.ts +11 -9
  195. package/src/notifications/__tests__/broadcaster.test.ts +35 -4
  196. package/src/notifications/__tests__/notification-utils.test.ts +31 -0
  197. package/src/notifications/access-request-copy.ts +126 -114
  198. package/src/notifications/adapters/discord.ts +9 -5
  199. package/src/notifications/adapters/shared.ts +17 -4
  200. package/src/notifications/adapters/slack.ts +14 -4
  201. package/src/notifications/adapters/telegram.ts +8 -4
  202. package/src/notifications/approval-card-data.ts +5 -2
  203. package/src/notifications/assistant-reply-producer.ts +7 -7
  204. package/src/notifications/broadcaster.ts +57 -25
  205. package/src/notifications/conversation-pairing.ts +68 -2
  206. package/src/notifications/copy-composer.ts +13 -32
  207. package/src/notifications/decision-engine.ts +56 -237
  208. package/src/notifications/guardian-delivery-recorder.ts +5 -9
  209. package/src/notifications/guardian-feed-projection.ts +0 -15
  210. package/src/notifications/guardian-question-mode.ts +147 -6
  211. package/src/notifications/notification-utils.ts +117 -1
  212. package/src/permissions/confirmation-guardian-request.ts +2 -2
  213. package/src/permissions/prompter.ts +1 -1
  214. package/src/persistence/conversation-crud.ts +93 -14
  215. package/src/persistence/conversation-queries.ts +144 -17
  216. package/src/persistence/conversation-types.ts +39 -5
  217. package/src/persistence/delivery-crud.ts +224 -28
  218. package/src/persistence/delivery-status.ts +21 -2
  219. package/src/persistence/migrations/374-channel-inbound-message-id-index.ts +26 -0
  220. package/src/persistence/migrations/375-create-channel-outbound-posts.ts +52 -0
  221. package/src/persistence/migrations/__tests__/375-create-channel-outbound-posts.test.ts +84 -0
  222. package/src/persistence/schema/conversations.ts +71 -2
  223. package/src/persistence/schema-contract.test.ts +78 -0
  224. package/src/persistence/schema-contract.ts +96 -0
  225. package/src/persistence/steps.ts +4 -0
  226. package/src/platform/client.ts +55 -0
  227. package/src/plugins/__tests__/mcp-servers.test.ts +46 -0
  228. package/src/plugins/defaults/injector-order.ts +2 -1
  229. package/src/plugins/defaults/memory/__tests__/memory-retrospective-accounting.test.ts +2 -2
  230. package/src/plugins/defaults/memory/context-search/sources/conversations.ts +13 -15
  231. package/src/plugins/defaults/memory/hooks/user-prompt-submit.ts +10 -0
  232. package/src/plugins/defaults/memory/injectors.ts +1 -1
  233. package/src/plugins/defaults/memory/memory-marker.ts +17 -7
  234. package/src/plugins/defaults/memory/substrate/__tests__/consolidation-prompt-flag-gating-guard.test.ts +1 -5
  235. package/src/plugins/defaults/memory/substrate/__tests__/skill-content.test.ts +17 -0
  236. package/src/plugins/defaults/memory/substrate/skill-content.ts +7 -0
  237. package/src/plugins/defaults/memory/v3/__tests__/carry-integration.test.ts +54 -41
  238. package/src/plugins/defaults/memory/v3/__tests__/injection.test.ts +3 -3
  239. package/src/plugins/defaults/memory/v3/__tests__/render-injection.test.ts +22 -0
  240. package/src/plugins/defaults/memory/v3/injector.ts +14 -15
  241. package/src/plugins/defaults/memory/v3/prune.test.ts +20 -2
  242. package/src/plugins/defaults/memory/v3/render-injection.ts +10 -1
  243. package/src/plugins/defaults/memory/v3/types.ts +15 -4
  244. package/src/plugins/defaults/turn-context/injectors.ts +3 -0
  245. package/src/plugins/defaults/turn-context/unified-turn-context.ts +24 -0
  246. package/src/plugins/mcp-servers.ts +14 -2
  247. package/src/providers/__tests__/dispatch-connection-routing.test.ts +50 -0
  248. package/src/providers/__tests__/registry-native-web-search.test.ts +49 -2
  249. package/src/providers/anthropic/client.ts +21 -24
  250. package/src/providers/call-site-routing.ts +5 -4
  251. package/src/providers/connection-resolution.ts +29 -1
  252. package/src/providers/content-block-size.test.ts +82 -0
  253. package/src/providers/content-block-size.ts +99 -0
  254. package/src/providers/file-block-text.test.ts +64 -0
  255. package/src/providers/file-block-text.ts +28 -0
  256. package/src/providers/gemini/client.ts +15 -9
  257. package/src/providers/inference/auth.ts +6 -6
  258. package/src/providers/media-resolve.ts +14 -0
  259. package/src/providers/model-catalog.ts +47 -34
  260. package/src/providers/openai/chat-completions-provider.ts +9 -25
  261. package/src/providers/openai/responses-provider.ts +11 -18
  262. package/src/providers/registry.ts +10 -7
  263. package/src/providers/routing-identity.ts +2 -1
  264. package/src/providers/types.ts +1 -4
  265. package/src/providers/vellum-model-routing.ts +3 -2
  266. package/src/runtime/AGENTS.md +40 -15
  267. package/src/runtime/approval-message-composer.ts +1 -6
  268. package/src/runtime/assistant-event-hub.ts +2 -39
  269. package/src/runtime/channel-approval-types.ts +1 -5
  270. package/src/runtime/channel-reply-delivery.ts +182 -115
  271. package/src/runtime/{slack-reply-session.test.ts → channel-reply-session.test.ts} +294 -156
  272. package/src/runtime/{slack-reply-session.ts → channel-reply-session.ts} +107 -86
  273. package/src/runtime/channel-retry-sweep.ts +102 -1
  274. package/src/runtime/finalize-event-delivery.ts +4 -4
  275. package/src/runtime/guardian-action-message-composer.ts +1 -4
  276. package/src/runtime/guardian-reply-router.ts +4 -75
  277. package/src/runtime/http-router.ts +1 -5
  278. package/src/runtime/question-request-guardian-bridge.ts +2 -3
  279. package/src/runtime/routes/__tests__/conversation-list-assistant-section.test.ts +359 -0
  280. package/src/runtime/routes/__tests__/dictation-command-mode.test.ts +120 -0
  281. package/src/runtime/routes/__tests__/sight-frame-routes.test.ts +487 -0
  282. package/src/runtime/routes/acp-routes.ts +1 -1
  283. package/src/runtime/routes/canned-reply-release.ts +19 -9
  284. package/src/runtime/routes/channel-route-shared.ts +0 -39
  285. package/src/runtime/routes/channel-verification-routes.ts +4 -1
  286. package/src/runtime/routes/conversation-list-routes.ts +44 -3
  287. package/src/runtime/routes/conversation-management-routes.ts +2 -1
  288. package/src/runtime/routes/conversation-routes.ts +138 -59
  289. package/src/runtime/routes/diagnostics-routes.ts +111 -60
  290. package/src/runtime/routes/guardian-action-routes.ts +10 -14
  291. package/src/runtime/routes/guardian-approval-interception.ts +0 -5
  292. package/src/runtime/routes/inbound-message-handler.ts +170 -48
  293. package/src/runtime/routes/inbound-stages/acl-enforcement.ts +4 -3
  294. package/src/runtime/routes/inbound-stages/background-dispatch.test.ts +169 -8
  295. package/src/runtime/routes/inbound-stages/background-dispatch.ts +82 -14
  296. package/src/runtime/routes/inbound-stages/guardian-reply-intercept.ts +23 -48
  297. package/src/runtime/routes/inbound-stages/reaction-intercept.test.ts +454 -94
  298. package/src/runtime/routes/inbound-stages/reaction-intercept.ts +309 -102
  299. package/src/runtime/routes/index.ts +2 -0
  300. package/src/runtime/routes/platform-routes.ts +99 -10
  301. package/src/runtime/routes/sight-frame-routes.ts +136 -0
  302. package/src/runtime/sync/resource-sync-events.ts +14 -37
  303. package/src/runtime/sync/sync-publisher.test.ts +5 -4
  304. package/src/tools/__tests__/tool-input-schemas.test.ts +2 -0
  305. package/src/tools/client-os.ts +11 -1
  306. package/src/tools/host-filesystem/edit.ts +2 -2
  307. package/src/tools/host-filesystem/read.ts +2 -2
  308. package/src/tools/host-filesystem/transfer.ts +2 -2
  309. package/src/tools/host-filesystem/write.ts +2 -2
  310. package/src/tools/host-terminal/host-shell.ts +2 -2
  311. package/src/tools/skills/load.ts +1 -1
  312. package/src/tools/terminal/safe-env.ts +4 -0
  313. package/src/tools/tool-input-schemas.ts +2 -0
  314. package/src/tools/tool-manifest.ts +2 -0
  315. package/src/tools/ui-surface/definitions.ts +4 -3
  316. package/src/tools/watch/watch-retro-report.ts +205 -0
  317. package/src/watch/__tests__/watch-retro.test.ts +268 -40
  318. package/src/watch/watch-retro.ts +190 -77
  319. package/src/workspace/migrations/151-repair-renamed-fireworks-deepseek-pro-model-id.ts +195 -0
  320. package/src/workspace/migrations/152-repair-retired-fireworks-minimax-m2p7-model-id.ts +198 -0
  321. package/src/workspace/migrations/__tests__/150-stt-flux-provider-to-model-family.test.ts +0 -10
  322. package/src/workspace/migrations/registry.ts +4 -0
  323. package/docker-kata-pip-chroot.sh +0 -22
  324. package/src/__tests__/slack-reaction-approvals.test.ts +0 -97
  325. package/src/__tests__/slack-reaction-guardian-approval.test.ts +0 -307
  326. package/src/api/events/conversation-list-invalidated.ts +0 -38
@@ -1,8 +1,10 @@
1
1
  import type { KnownBlock } from "@slack/types";
2
2
  import { ChannelDeliveryError } from "@vellumai/gateway-client/http-delivery";
3
3
 
4
+ import { extractThreadTsFromCallbackUrl } from "../../../channels/slack-callback-url.js";
4
5
  import { getLogger } from "../../../util/logger.js";
5
6
  import type { ChannelTransport } from "../channel-transport.js";
7
+ import { SLACK_STREAM_MARKDOWN_LIMIT } from "./api.js";
6
8
  import {
7
9
  sendSlackAgentSessionStatus,
8
10
  sendSlackAttachments,
@@ -94,7 +96,31 @@ export const slackTransport: ChannelTransport = {
94
96
  return { ok };
95
97
  },
96
98
 
97
- async streamReply(_ctx, chatId, op) {
98
- return sendSlackStreamOp(chatId, op);
99
+ // `chat.startStream` and `chat.appendStream` both cap `markdown_text`, so
100
+ // the caller splits a wider delta and advances its delivered mark once per
101
+ // operation this transport confirms.
102
+ maxStreamTextChars: SLACK_STREAM_MARKDOWN_LIMIT,
103
+
104
+ // `chat.stopStream` finalizes the streamed message in place, so what the
105
+ // stream leaves behind IS the reply and durable delivery must not resend it.
106
+ streamPersists: true,
107
+
108
+ /**
109
+ * `chat.startStream` streams into a thread, so a turn with no thread to
110
+ * open under cannot stream. Resolving that here, from this channel's own
111
+ * callback, is what keeps Slack's addressing out of the shared session:
112
+ * a start with no thread reports not-ok and the caller sends the finished
113
+ * reply instead.
114
+ */
115
+ async streamReply(ctx, chatId, op) {
116
+ if (op.action !== "start") {
117
+ return sendSlackStreamOp(chatId, op);
118
+ }
119
+ const threadTs =
120
+ op.anchorMessageId ?? extractThreadTsFromCallbackUrl(ctx.callbackUrl);
121
+ if (!threadTs) {
122
+ return { ok: false };
123
+ }
124
+ return sendSlackStreamOp(chatId, { ...op, anchorMessageId: threadTs });
99
125
  },
100
126
  };
@@ -35,6 +35,7 @@ const {
35
35
  sendTelegramReaction,
36
36
  sendTelegramReply,
37
37
  sendTelegramRichReply,
38
+ TELEGRAM_DRAFT_TEXT_LIMIT,
38
39
  } = await import("./send.js");
39
40
  const { telegramTransport } = await import("./transport.js");
40
41
 
@@ -444,3 +445,161 @@ describe("editTelegramMessage", () => {
444
445
  expect(sendMessageCalls()).toHaveLength(0);
445
446
  });
446
447
  });
448
+
449
+ describe("telegramTransport.streamReply", () => {
450
+ const ctx: CallbackContext = {
451
+ callbackUrl: "https://example.test/deliver/telegram?chatId=123",
452
+ params: {},
453
+ };
454
+
455
+ test("opens a draft carrying the whole partial reply", async () => {
456
+ const result = await telegramTransport.streamReply?.(ctx, "123", {
457
+ action: "start",
458
+ text: "Looking that up",
459
+ appended: "Looking that up",
460
+ });
461
+
462
+ const calls = callsTo("sendMessageDraft");
463
+ expect(calls).toHaveLength(1);
464
+ expect(calls[0]![1]).toMatchObject({
465
+ chat_id: 123,
466
+ text: "Looking that up",
467
+ });
468
+ // The id is minted here, not handed back by Telegram, and must be usable
469
+ // as the stream id the later append addresses.
470
+ expect(Number(result?.ts)).toBeGreaterThan(0);
471
+ expect(result?.ok).toBe(true);
472
+ });
473
+
474
+ test("advances one draft by resending the whole text under the same id", async () => {
475
+ await telegramTransport.streamReply?.(ctx, "123", {
476
+ action: "append",
477
+ streamId: "4242",
478
+ text: "Looking that up. Found it.",
479
+ appended: ". Found it.",
480
+ });
481
+
482
+ const calls = callsTo("sendMessageDraft");
483
+ expect(calls).toHaveLength(1);
484
+ // Telegram animates between drafts sharing an id, so the call carries the
485
+ // whole reply so far rather than the delta.
486
+ expect(calls[0]![1]).toMatchObject({
487
+ chat_id: 123,
488
+ draft_id: 4242,
489
+ text: "Looking that up. Found it.",
490
+ });
491
+ });
492
+
493
+ test("draws a plan into the draft, since Telegram has no task primitive", async () => {
494
+ await telegramTransport.streamReply?.(ctx, "123", {
495
+ action: "append",
496
+ streamId: "4242",
497
+ text: "Working.",
498
+ plan: {
499
+ title: "Answering",
500
+ steps: [
501
+ { label: "Search docs", status: "completed" },
502
+ { label: "Summarize", status: "in_progress" },
503
+ { label: "Reply", status: "pending" },
504
+ ],
505
+ },
506
+ });
507
+
508
+ const body = callsTo("sendMessageDraft")[0]![1] as { text: string };
509
+ expect(body.text).toBe(
510
+ "Working.\n\nAnswering\n✓ Search docs\n▸ Summarize\n· Reply",
511
+ );
512
+ });
513
+
514
+ test("stopping does nothing, because sending the reply clears the draft", async () => {
515
+ const result = await telegramTransport.streamReply?.(ctx, "123", {
516
+ action: "stop",
517
+ streamId: "4242",
518
+ text: "All done.",
519
+ });
520
+
521
+ expect(callsTo("sendMessageDraft")).toHaveLength(0);
522
+ expect(result).toEqual({ ok: true, ts: "4242" });
523
+ });
524
+
525
+ test("sends the chat id as the integer the draft method requires", async () => {
526
+ // `sendMessageDraft` takes an Integer chat_id, not the "Integer or String"
527
+ // most methods accept, so the string the transport carries has to become a
528
+ // number on the wire.
529
+ await telegramTransport.streamReply?.(ctx, "123", {
530
+ action: "start",
531
+ text: "Hi",
532
+ appended: "Hi",
533
+ });
534
+
535
+ const body = callsTo("sendMessageDraft")[0]![1] as { chat_id: unknown };
536
+ expect(body.chat_id).toBe(123);
537
+ });
538
+
539
+ test("refuses a chat id that cannot be an integer, without calling out", async () => {
540
+ const result = await telegramTransport.streamReply?.(ctx, "@somechannel", {
541
+ action: "start",
542
+ text: "Hi",
543
+ appended: "Hi",
544
+ });
545
+
546
+ expect(callsTo("sendMessageDraft")).toHaveLength(0);
547
+ expect(result).toEqual({ ok: false });
548
+ });
549
+
550
+ test("a draft past the cap keeps its live tail, not a frozen prefix", async () => {
551
+ // Telegram caps a draft at 4096. Keeping the head would freeze the preview
552
+ // the moment the reply passed the cap, and would cut off anything drawn
553
+ // beneath it; the tail is the part still moving.
554
+ const body = "HEADMARK" + "a".repeat(5_000);
555
+ await telegramTransport.streamReply?.(ctx, "123", {
556
+ action: "append",
557
+ streamId: "4242",
558
+ text: body + "TAILMARK",
559
+ appended: "TAILMARK",
560
+ plan: { steps: [{ label: "Summarize", status: "in_progress" }] },
561
+ });
562
+
563
+ const sent = (callsTo("sendMessageDraft")[0]![1] as { text: string }).text;
564
+ expect(sent.length).toBe(TELEGRAM_DRAFT_TEXT_LIMIT);
565
+ // The newest text and the plan beneath it survive; the stale head is what
566
+ // gets dropped, which is the opposite of a frozen prefix.
567
+ expect(sent).toContain("TAILMARK");
568
+ expect(sent).toContain("Summarize");
569
+ expect(sent).not.toContain("HEADMARK");
570
+ });
571
+
572
+ test("a trimmed draft never begins with half of a character", async () => {
573
+ // The cap counts UTF-16 code units, so a tail cut can land between the
574
+ // halves of an emoji and send a lone surrogate.
575
+ const emoji = "\u{1F600}";
576
+ const body = emoji.repeat(3_000);
577
+ await telegramTransport.streamReply?.(ctx, "123", {
578
+ action: "append",
579
+ streamId: "4242",
580
+ text: body,
581
+ appended: emoji,
582
+ });
583
+
584
+ const sent = (callsTo("sendMessageDraft")[0]![1] as { text: string }).text;
585
+ const first = sent.charCodeAt(0);
586
+ expect(first >= 0xdc00 && first <= 0xdfff).toBe(false);
587
+ expect(sent.length).toBeLessThanOrEqual(TELEGRAM_DRAFT_TEXT_LIMIT);
588
+ });
589
+
590
+ test("a refused draft reports not-ok so the caller falls back", async () => {
591
+ // Telegram offers drafts in private chats only; anywhere else the call is
592
+ // rejected, and that rejection is the whole of the per-conversation rule.
593
+ callTelegramBotApiMock.mockImplementation(async () => {
594
+ throw new Error("Bad Request: chat type is not supported");
595
+ });
596
+
597
+ const result = await telegramTransport.streamReply?.(ctx, "123", {
598
+ action: "start",
599
+ text: "Looking that up",
600
+ appended: "Looking that up",
601
+ });
602
+
603
+ expect(result).toEqual({ ok: false });
604
+ });
605
+ });
@@ -444,3 +444,96 @@ export async function sendTelegramTypingIndicator(
444
444
  return false;
445
445
  }
446
446
  }
447
+
448
+ // ---------------------------------------------------------------------------
449
+ // Live message drafts (sendMessageDraft)
450
+ // ---------------------------------------------------------------------------
451
+
452
+ /**
453
+ * Telegram caps a message, and so a draft's text, at 4096 characters.
454
+ *
455
+ * A draft is a preview rather than the reply, so an over-long partial keeps
456
+ * its tail rather than being split across drafts: splitting would animate the
457
+ * reader back to the start of the reply every time it grew past the cap. The
458
+ * tail is also the live end of the draft, so a reply past the cap keeps
459
+ * moving instead of freezing on a prefix, and anything drawn beneath it
460
+ * stays visible.
461
+ */
462
+ export const TELEGRAM_DRAFT_TEXT_LIMIT = 4096;
463
+
464
+ /**
465
+ * The tail of a draft that Telegram will accept, cut on a character boundary.
466
+ *
467
+ * The cap counts UTF-16 code units, so slicing to it can land between the two
468
+ * halves of an astral character (an emoji, which a plan's status glyphs and a
469
+ * reply's own text both carry) and send a lone surrogate. Dropping a leading
470
+ * low surrogate costs one character and keeps the text well-formed.
471
+ */
472
+ function draftTail(text: string): string {
473
+ if (text.length <= TELEGRAM_DRAFT_TEXT_LIMIT) {
474
+ return text;
475
+ }
476
+ const tail = text.slice(-TELEGRAM_DRAFT_TEXT_LIMIT);
477
+ const first = tail.charCodeAt(0);
478
+ return first >= 0xdc00 && first <= 0xdfff ? tail.slice(1) : tail;
479
+ }
480
+
481
+ /**
482
+ * How long a draft survives without being re-sent: Telegram describes it as
483
+ * "a temporary 30-second preview". A draft that stops being advanced
484
+ * disappears rather than lingering with stale text.
485
+ */
486
+ export const TELEGRAM_DRAFT_TTL_MS = 30_000;
487
+
488
+ /**
489
+ * Show, or advance, the live draft of a reply still being written.
490
+ *
491
+ * `sendMessageDraft` takes the whole partial reply rather than a delta and
492
+ * lets Telegram's clients animate the difference, which is why every call
493
+ * passes the full text. Reusing one `draft_id` is what makes those calls read
494
+ * as one growing draft: a different id replaces the draft without animation.
495
+ * Empty text is meaningful, rendering Telegram's own "Thinking..." placeholder,
496
+ * so it is passed through rather than skipped.
497
+ *
498
+ * The draft is a preview and never the reply. Telegram's own words: "once the
499
+ * output is finalized, you must call sendMessage with the complete message to
500
+ * persist it". It also clears the moment the bot sends a real message, so
501
+ * nothing has to clear it, and it survives only {@link TELEGRAM_DRAFT_TTL_MS}
502
+ * without being re-sent.
503
+ *
504
+ * Private chats only, and `chat_id` here is an Integer rather than the
505
+ * "Integer or String" most methods accept, so a non-numeric chat id cannot
506
+ * address a draft at all and is refused rather than sent to be rejected.
507
+ *
508
+ * @see https://core.telegram.org/bots/api#sendmessagedraft
509
+ */
510
+ export async function sendTelegramMessageDraft(
511
+ chatId: string,
512
+ draftId: number,
513
+ text: string,
514
+ opts?: TelegramSendOptions,
515
+ ): Promise<boolean> {
516
+ const numericChatId = Number(chatId);
517
+ if (!Number.isSafeInteger(numericChatId)) {
518
+ log.debug(
519
+ { chatId, draftId },
520
+ "Telegram drafts address a chat by integer id; skipping draft",
521
+ );
522
+ return false;
523
+ }
524
+ try {
525
+ await callTelegramBotApi("sendMessageDraft", {
526
+ chat_id: numericChatId,
527
+ draft_id: draftId,
528
+ text: draftTail(text),
529
+ ...threadIdPayloadFields(opts),
530
+ });
531
+ return true;
532
+ } catch (err) {
533
+ log.debug(
534
+ { err, chatId, draftId },
535
+ "Failed to send Telegram message draft",
536
+ );
537
+ return false;
538
+ }
539
+ }
@@ -1,3 +1,4 @@
1
+ import type { StreamPlan, StreamPlanStep } from "@vellumai/gateway-client";
1
2
  import { ChannelDeliveryError } from "@vellumai/gateway-client/http-delivery";
2
3
 
3
4
  import { getLogger } from "../../../util/logger.js";
@@ -10,6 +11,7 @@ import type { TelegramSendOptions } from "./send.js";
10
11
  import {
11
12
  editTelegramMessage,
12
13
  sendTelegramAttachments,
14
+ sendTelegramMessageDraft,
13
15
  sendTelegramReaction,
14
16
  sendTelegramReply,
15
17
  sendTelegramRichReply,
@@ -27,12 +29,50 @@ function threadOptions(ctx: CallbackContext): TelegramSendOptions | undefined {
27
29
  return threadId ? { messageThreadId: threadId } : undefined;
28
30
  }
29
31
 
32
+ /**
33
+ * A plan drawn into the draft's own text, because Telegram has no task
34
+ * primitive a bot may use: `sendChecklist` is business-account only. Rendering
35
+ * it here rather than above the seam is the point of the seam. The glyphs
36
+ * carry the status without a legend, and a step keeps its place as it
37
+ * advances so the reader watches one list move rather than a new one appear.
38
+ */
39
+ function renderPlanAsText(plan: StreamPlan): string {
40
+ const glyph: Record<StreamPlanStep["status"], string> = {
41
+ completed: "\u2713",
42
+ in_progress: "\u25b8",
43
+ pending: "\u00b7",
44
+ failed: "\u2717",
45
+ };
46
+ const lines = plan.steps.map((step) => `${glyph[step.status]} ${step.label}`);
47
+ return [plan.title, ...lines].filter(Boolean).join("\n");
48
+ }
49
+
50
+ /** The draft's whole text: the reply so far, with any plan beneath it. */
51
+ function draftText(text: string, plan: StreamPlan | undefined): string {
52
+ const body = text.trim();
53
+ const planText = plan ? renderPlanAsText(plan) : "";
54
+ return [body, planText].filter(Boolean).join("\n\n");
55
+ }
56
+
57
+ /**
58
+ * Telegram's draft id is minted by the caller, unlike a stream id a platform
59
+ * hands back, and only has to be non-zero and stable for the life of one
60
+ * draft. The clock supplies that without any state to keep between calls.
61
+ */
62
+ function mintDraftId(): number {
63
+ return Date.now();
64
+ }
65
+
30
66
  export const telegramTransport: ChannelTransport = {
31
67
  channel: "telegram",
32
68
 
33
69
  // Telegram clears a chat action after about five seconds.
34
70
  activityRefreshMs: 4_000,
35
71
 
72
+ // The draft is a preview: it expires on its own, and the moment the bot
73
+ // sends the real message, so the reply is still owed after the stream ends.
74
+ streamPersists: false,
75
+
36
76
  async react(target) {
37
77
  return sendTelegramReaction(
38
78
  target.chatId,
@@ -86,6 +126,37 @@ export const telegramTransport: ChannelTransport = {
86
126
  return { ok: true };
87
127
  },
88
128
 
129
+ /**
130
+ * Show the reply as it is written, in Telegram's own live draft.
131
+ *
132
+ * Every call carries the whole partial reply rather than a delta: Telegram
133
+ * animates the difference between drafts sharing a `draft_id`, so sending
134
+ * only what is new would replace the draft with the fragment. `stop` has
135
+ * nothing to do, since the draft clears itself when the real reply sends.
136
+ *
137
+ * A start that Telegram refuses (a chat that is not private, where drafts
138
+ * are not offered) reports not-ok, and the caller falls back to sending the
139
+ * finished reply. That is the whole of the per-conversation rule: it is
140
+ * answered here, where the platform's constraint lives.
141
+ */
142
+ async streamReply(ctx, chatId, op) {
143
+ const opts = threadOptions(ctx);
144
+ if (op.action === "stop") {
145
+ return { ok: true, ts: op.streamId };
146
+ }
147
+ const draftId = op.action === "start" ? mintDraftId() : Number(op.streamId);
148
+ if (!Number.isFinite(draftId) || draftId === 0) {
149
+ return { ok: false };
150
+ }
151
+ const sent = await sendTelegramMessageDraft(
152
+ chatId,
153
+ draftId,
154
+ draftText(op.text, op.plan),
155
+ opts,
156
+ );
157
+ return sent ? { ok: true, ts: String(draftId) } : { ok: false };
158
+ },
159
+
89
160
  async setActivity(ctx, target) {
90
161
  // Telegram's chat action expires by itself after a few seconds, so a phase
91
162
  // that is not running needs no clearing call.
@@ -0,0 +1,73 @@
1
+ import { describe, expect, test } from "bun:test";
2
+
3
+ import { writeSlackMetadata } from "./providers/slack/message-metadata.js";
4
+ import {
5
+ buildNeutralReactionMeta,
6
+ buildSlackReactionMeta,
7
+ type ReactionEnvelopeFacts,
8
+ } from "./reaction-envelopes.js";
9
+ import { readProviderMetadata } from "./read-provider-metadata.js";
10
+
11
+ const typedFacts: ReactionEnvelopeFacts = {
12
+ channel: "slack",
13
+ chatId: "C1",
14
+ targetMessageId: "1700000000.000100",
15
+ emoji: "blob_wave",
16
+ emojiKind: "shortcode",
17
+ emojiName: "blob_wave",
18
+ op: "added",
19
+ actorExternalId: "U1",
20
+ actorDisplayName: "Alice",
21
+ };
22
+
23
+ describe("a reaction's typed emoji survives the stored envelope", () => {
24
+ test("the Slack envelope writes the fields and reads them back", () => {
25
+ const read = readProviderMetadata(
26
+ JSON.stringify({
27
+ slackMeta: writeSlackMetadata(buildSlackReactionMeta(typedFacts)),
28
+ }),
29
+ );
30
+
31
+ expect(read?.reaction).toMatchObject({
32
+ emoji: "blob_wave",
33
+ emojiKind: "shortcode",
34
+ emojiName: "blob_wave",
35
+ op: "added",
36
+ targetMessageId: "1700000000.000100",
37
+ });
38
+ });
39
+
40
+ test("the neutral envelope writes the fields and reads them back", () => {
41
+ const stored = buildNeutralReactionMeta({
42
+ ...typedFacts,
43
+ channel: "discord",
44
+ emoji: "<:party_blob:111>",
45
+ emojiKind: "custom",
46
+ emojiName: "party_blob",
47
+ emojiId: "111",
48
+ emojiAnimated: true,
49
+ });
50
+ const read = readProviderMetadata(
51
+ JSON.stringify({ providerMeta: JSON.stringify(stored) }),
52
+ );
53
+
54
+ expect(read?.reaction).toMatchObject({
55
+ emojiKind: "custom",
56
+ emojiName: "party_blob",
57
+ emojiId: "111",
58
+ emojiAnimated: true,
59
+ });
60
+ });
61
+
62
+ test("a Slack row carrying only the spelling still reads", () => {
63
+ const { emojiKind: _k, emojiName: _n, ...spellingOnly } = typedFacts;
64
+ const read = readProviderMetadata(
65
+ JSON.stringify({
66
+ slackMeta: writeSlackMetadata(buildSlackReactionMeta(spellingOnly)),
67
+ }),
68
+ );
69
+
70
+ expect(read?.reaction?.emoji).toBe("blob_wave");
71
+ expect(read?.reaction?.emojiKind).toBeUndefined();
72
+ });
73
+ });
@@ -3,16 +3,27 @@
3
3
  * facts so the inbound intercept and the assistant's own reaction records
4
4
  * cannot drift apart.
5
5
  *
6
- * Slack keeps its own envelope because its transcript context builds
7
- * provider history from rows and reads only `slackMeta`; every other channel
8
- * writes the neutral shape `readProviderMetadata` serves to channel-agnostic
9
- * readers.
6
+ * The assistant's own reaction rows write the neutral shape on every channel,
7
+ * as every row the daemon authors does. Inbound Slack reaction rows still
8
+ * write Slack's own envelope, which `readProviderMetadata` maps on read; the
9
+ * Slack transcript reads the neutral envelope through its Slack view.
10
10
  */
11
+ import {
12
+ pickReactionEmojiFields,
13
+ type ReactionEmojiFields,
14
+ } from "@vellumai/service-contracts/reactions";
15
+
11
16
  import type { ChannelId } from "../channels/types.js";
12
17
  import type { ProviderMessageMetadata } from "./provider-message-metadata.js";
13
18
  import type { SlackMessageMetadata } from "./providers/slack/message-metadata.js";
19
+ import { writeSlackMetadata } from "./providers/slack/message-metadata.js";
14
20
 
15
- export interface ReactionEnvelopeFacts {
21
+ /**
22
+ * The emoji's typed identity is optional on the facts because the assistant's
23
+ * own reaction carries only the spelling it chose: it names an emoji rather
24
+ * than reporting one a channel described.
25
+ */
26
+ export interface ReactionEnvelopeFacts extends ReactionEmojiFields {
16
27
  channel: ChannelId;
17
28
  /** Provider id of the chat the reaction belongs to. */
18
29
  chatId: string;
@@ -40,6 +51,7 @@ export function buildNeutralReactionMeta(
40
51
  reaction: {
41
52
  targetMessageId: facts.targetMessageId,
42
53
  emoji: facts.emoji,
54
+ ...pickReactionEmojiFields(facts),
43
55
  op: facts.op,
44
56
  ...(facts.actorDisplayName
45
57
  ? { actorDisplayName: facts.actorDisplayName }
@@ -48,6 +60,21 @@ export function buildNeutralReactionMeta(
48
60
  };
49
61
  }
50
62
 
63
+ /**
64
+ * The serialized metadata key an inbound reaction row stores, chosen per
65
+ * channel: Slack rows write `slackMeta`, every other channel the neutral
66
+ * `providerMeta`. The one owner of that choice, so the two inbound writers
67
+ * (the intercept and the reaction-wake turn) cannot drift. The assistant's
68
+ * own reaction records write `buildNeutralReactionMeta` directly.
69
+ */
70
+ export function buildReactionRowEnvelope(
71
+ facts: ReactionEnvelopeFacts,
72
+ ): { slackMeta: string } | { providerMeta: string } {
73
+ return facts.channel === "slack"
74
+ ? { slackMeta: writeSlackMetadata(buildSlackReactionMeta(facts)) }
75
+ : { providerMeta: JSON.stringify(buildNeutralReactionMeta(facts)) };
76
+ }
77
+
51
78
  export function buildSlackReactionMeta(
52
79
  facts: ReactionEnvelopeFacts,
53
80
  ): SlackMessageMetadata {
@@ -69,6 +96,7 @@ export function buildSlackReactionMeta(
69
96
  ...(facts.actorDisplayName ? { displayName: facts.actorDisplayName } : {}),
70
97
  reaction: {
71
98
  emoji: facts.emoji,
99
+ ...pickReactionEmojiFields(facts),
72
100
  targetChannelTs: facts.targetMessageId,
73
101
  op: facts.op,
74
102
  ...(facts.actorDisplayName
@@ -20,9 +20,10 @@ import {
20
20
  * understands them. History assembly, the conversation route and the
21
21
  * transcript renderer then work for a channel nobody here has heard of.
22
22
  *
23
- * `slackMeta` is Slack's own envelope, mapped on read. It holds fields no
24
- * other channel has an equivalent for, so Slack keeps writing it and loses
25
- * nothing. A channel that writes `providerMeta` needs no adapter at all.
23
+ * `slackMeta` is Slack's own envelope, mapped on read. Inbound Slack rows
24
+ * still write it; the Slack-only fields it holds have no neutral equivalent
25
+ * and ride the neutral envelope's passthrough on the rows the daemon
26
+ * authors. A channel that writes `providerMeta` needs no adapter at all.
26
27
  *
27
28
  * Normalizing on read is what allows both. Writing stays where provider
28
29
  * detail legitimately lives, and nothing is stored twice.