@vellumai/assistant 0.3.14 → 0.3.16

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 (295) hide show
  1. package/ARCHITECTURE.md +142 -0
  2. package/Dockerfile +2 -2
  3. package/README.md +5 -5
  4. package/docs/architecture/http-token-refresh.md +252 -0
  5. package/docs/architecture/memory.md +5 -4
  6. package/docs/architecture/scheduling.md +4 -88
  7. package/docs/runbook-trusted-contacts.md +283 -0
  8. package/docs/trusted-contact-access.md +247 -0
  9. package/package.json +1 -1
  10. package/scripts/ipc/check-swift-decoder-drift.ts +2 -0
  11. package/src/__tests__/__snapshots__/ipc-snapshot.test.ts.snap +2 -6
  12. package/src/__tests__/access-request-decision.test.ts +331 -0
  13. package/src/__tests__/asset-materialize-tool.test.ts +7 -7
  14. package/src/__tests__/asset-search-tool.test.ts +15 -15
  15. package/src/__tests__/attachments-store.test.ts +13 -13
  16. package/src/__tests__/call-controller.test.ts +150 -4
  17. package/src/__tests__/call-conversation-messages.test.ts +2 -2
  18. package/src/__tests__/call-pointer-messages.test.ts +28 -0
  19. package/src/__tests__/call-start-guardian-guard.test.ts +93 -0
  20. package/src/__tests__/channel-approval-routes.test.ts +108 -12
  21. package/src/__tests__/channel-guardian.test.ts +16 -14
  22. package/src/__tests__/checker.test.ts +24 -0
  23. package/src/__tests__/computer-use-skill-manifest-regression.test.ts +2 -2
  24. package/src/__tests__/config-watcher.test.ts +358 -0
  25. package/src/__tests__/conversation-pairing.test.ts +24 -24
  26. package/src/__tests__/conversation-store.test.ts +36 -36
  27. package/src/__tests__/date-context.test.ts +179 -1
  28. package/src/__tests__/db-migration-rollback.test.ts +4 -7
  29. package/src/__tests__/deterministic-verification-control-plane.test.ts +5 -5
  30. package/src/__tests__/emit-signal-routing-intent.test.ts +179 -0
  31. package/src/__tests__/gateway-only-guard.test.ts +188 -0
  32. package/src/__tests__/guardian-action-conversation-turn.test.ts +451 -0
  33. package/src/__tests__/guardian-action-copy-generator.test.ts +197 -0
  34. package/src/__tests__/guardian-action-followup-executor.test.ts +379 -0
  35. package/src/__tests__/guardian-action-followup-store.test.ts +376 -0
  36. package/src/__tests__/guardian-action-late-reply.test.ts +294 -0
  37. package/src/__tests__/guardian-action-no-hardcoded-copy.test.ts +71 -0
  38. package/src/__tests__/guardian-action-sweep.test.ts +9 -9
  39. package/src/__tests__/guardian-control-plane-policy.test.ts +1 -3
  40. package/src/__tests__/guardian-outbound-http.test.ts +202 -10
  41. package/src/__tests__/guardian-verification-intent-routing.test.ts +179 -0
  42. package/src/__tests__/guardian-verify-setup-skill-regression.test.ts +141 -0
  43. package/src/__tests__/handlers-telegram-config.test.ts +6 -6
  44. package/src/__tests__/hooks-runner.test.ts +13 -4
  45. package/src/__tests__/ingress-routes-http.test.ts +443 -0
  46. package/src/__tests__/intent-routing.test.ts +14 -0
  47. package/src/__tests__/ipc-snapshot.test.ts +2 -5
  48. package/src/__tests__/media-reuse-story.e2e.test.ts +7 -7
  49. package/src/__tests__/memory-regressions.test.ts +16 -12
  50. package/src/__tests__/non-member-access-request.test.ts +282 -0
  51. package/src/__tests__/notification-decision-strategy.test.ts +136 -0
  52. package/src/__tests__/notification-routing-intent.test.ts +11 -2
  53. package/src/__tests__/notification-thread-candidates.test.ts +166 -0
  54. package/src/__tests__/recording-intent-fallback.test.ts +0 -1
  55. package/src/__tests__/recording-intent-handler.test.ts +6 -3
  56. package/src/__tests__/recording-intent.test.ts +3 -2
  57. package/src/__tests__/recording-state-machine.test.ts +337 -26
  58. package/src/__tests__/registry.test.ts +17 -8
  59. package/src/__tests__/relay-server.test.ts +105 -0
  60. package/src/__tests__/reminder.test.ts +13 -0
  61. package/src/__tests__/runtime-attachment-metadata.test.ts +4 -4
  62. package/src/__tests__/scheduler-recurrence.test.ts +50 -0
  63. package/src/__tests__/server-history-render.test.ts +8 -8
  64. package/src/__tests__/session-agent-loop.test.ts +1 -0
  65. package/src/__tests__/session-runtime-assembly.test.ts +49 -0
  66. package/src/__tests__/session-skill-tools.test.ts +1 -0
  67. package/src/__tests__/skill-projection.benchmark.test.ts +11 -3
  68. package/src/__tests__/slack-channel-config.test.ts +230 -0
  69. package/src/__tests__/subagent-manager-notify.test.ts +4 -4
  70. package/src/__tests__/swarm-session-integration.test.ts +2 -2
  71. package/src/__tests__/system-prompt.test.ts +43 -0
  72. package/src/__tests__/task-management-tools.test.ts +3 -3
  73. package/src/__tests__/task-tools.test.ts +3 -3
  74. package/src/__tests__/trust-store.test.ts +17 -1
  75. package/src/__tests__/trusted-contact-lifecycle-notifications.test.ts +491 -0
  76. package/src/__tests__/trusted-contact-multichannel.test.ts +409 -0
  77. package/src/__tests__/trusted-contact-verification.test.ts +360 -0
  78. package/src/__tests__/update-bulletin-format.test.ts +119 -0
  79. package/src/__tests__/update-bulletin-state.test.ts +129 -0
  80. package/src/__tests__/update-bulletin.test.ts +260 -0
  81. package/src/__tests__/update-template-contract.test.ts +29 -0
  82. package/src/agent/loop.ts +2 -2
  83. package/src/amazon/client.ts +2 -3
  84. package/src/calls/call-controller.ts +115 -34
  85. package/src/calls/call-conversation-messages.ts +2 -2
  86. package/src/calls/call-domain.ts +10 -3
  87. package/src/calls/call-pointer-messages.ts +17 -5
  88. package/src/calls/guardian-action-sweep.ts +77 -36
  89. package/src/calls/relay-server.ts +51 -12
  90. package/src/calls/twilio-routes.ts +3 -1
  91. package/src/calls/types.ts +1 -1
  92. package/src/calls/voice-session-bridge.ts +4 -4
  93. package/src/cli/core-commands.ts +3 -3
  94. package/src/cli/map.ts +8 -5
  95. package/src/config/bundled-skills/phone-calls/SKILL.md +16 -1
  96. package/src/config/bundled-skills/tasks/SKILL.md +1 -1
  97. package/src/config/bundled-skills/tasks/TOOLS.json +4 -4
  98. package/src/config/bundled-skills/time-based-actions/SKILL.md +11 -1
  99. package/src/config/computer-use-prompt.ts +1 -0
  100. package/src/config/core-schema.ts +16 -0
  101. package/src/config/env-registry.ts +1 -0
  102. package/src/config/env.ts +16 -1
  103. package/src/config/memory-schema.ts +5 -0
  104. package/src/config/schema.ts +4 -0
  105. package/src/config/system-prompt.ts +69 -2
  106. package/src/config/templates/BOOTSTRAP.md +1 -1
  107. package/src/config/templates/IDENTITY.md +8 -4
  108. package/src/config/templates/SOUL.md +14 -0
  109. package/src/config/templates/UPDATES.md +16 -0
  110. package/src/config/templates/USER.md +5 -1
  111. package/src/config/types.ts +1 -0
  112. package/src/config/update-bulletin-format.ts +52 -0
  113. package/src/config/update-bulletin-state.ts +49 -0
  114. package/src/config/update-bulletin.ts +82 -0
  115. package/src/config/vellum-skills/catalog.json +6 -0
  116. package/src/config/vellum-skills/chatgpt-import/tools/chatgpt-import.ts +1 -1
  117. package/src/config/vellum-skills/guardian-verify-setup/SKILL.md +44 -10
  118. package/src/config/vellum-skills/telegram-setup/SKILL.md +4 -4
  119. package/src/config/vellum-skills/trusted-contacts/SKILL.md +147 -0
  120. package/src/config/vellum-skills/twilio-setup/SKILL.md +2 -2
  121. package/src/context/window-manager.ts +43 -3
  122. package/src/daemon/config-watcher.ts +1 -0
  123. package/src/daemon/connection-policy.ts +21 -1
  124. package/src/daemon/daemon-control.ts +164 -7
  125. package/src/daemon/date-context.ts +174 -1
  126. package/src/daemon/guardian-action-generators.ts +175 -0
  127. package/src/daemon/guardian-verification-intent.ts +120 -0
  128. package/src/daemon/handlers/apps.ts +1 -3
  129. package/src/daemon/handlers/config-channels.ts +8 -8
  130. package/src/daemon/handlers/config-heartbeat.ts +1 -1
  131. package/src/daemon/handlers/config-inbox.ts +55 -159
  132. package/src/daemon/handlers/config-ingress.ts +1 -1
  133. package/src/daemon/handlers/config-integrations.ts +1 -1
  134. package/src/daemon/handlers/config-platform.ts +1 -1
  135. package/src/daemon/handlers/config-scheduling.ts +2 -2
  136. package/src/daemon/handlers/config-slack-channel.ts +190 -0
  137. package/src/daemon/handlers/config-telegram.ts +1 -1
  138. package/src/daemon/handlers/config-twilio.ts +1 -1
  139. package/src/daemon/handlers/config-voice.ts +100 -0
  140. package/src/daemon/handlers/config.ts +3 -0
  141. package/src/daemon/handlers/index.ts +1 -1
  142. package/src/daemon/handlers/misc.ts +84 -6
  143. package/src/daemon/handlers/navigate-settings.ts +27 -0
  144. package/src/daemon/handlers/recording.ts +270 -144
  145. package/src/daemon/handlers/sessions.ts +107 -24
  146. package/src/daemon/handlers/subagents.ts +3 -3
  147. package/src/daemon/handlers/work-items.ts +10 -7
  148. package/src/daemon/ipc-contract/integrations.ts +9 -1
  149. package/src/daemon/ipc-contract/messages.ts +4 -0
  150. package/src/daemon/ipc-contract/sessions.ts +1 -1
  151. package/src/daemon/ipc-contract/settings.ts +26 -0
  152. package/src/daemon/ipc-contract/shared.ts +2 -0
  153. package/src/daemon/ipc-contract/work-items.ts +1 -7
  154. package/src/daemon/ipc-contract-inventory.json +5 -1
  155. package/src/daemon/ipc-contract.ts +5 -1
  156. package/src/daemon/lifecycle.ts +306 -266
  157. package/src/daemon/recording-executor.ts +1 -1
  158. package/src/daemon/recording-intent.ts +0 -41
  159. package/src/daemon/response-tier.ts +2 -2
  160. package/src/daemon/server.ts +6 -6
  161. package/src/daemon/session-agent-loop-handlers.ts +34 -9
  162. package/src/daemon/session-agent-loop.ts +15 -8
  163. package/src/daemon/session-history.ts +3 -2
  164. package/src/daemon/session-media-retry.ts +3 -0
  165. package/src/daemon/session-messaging.ts +38 -4
  166. package/src/daemon/session-notifiers.ts +2 -2
  167. package/src/daemon/session-process.ts +256 -23
  168. package/src/daemon/session-queue-manager.ts +2 -0
  169. package/src/daemon/session-runtime-assembly.ts +39 -0
  170. package/src/daemon/session-skill-tools.ts +13 -4
  171. package/src/daemon/session-tool-setup.ts +6 -7
  172. package/src/daemon/session.ts +19 -8
  173. package/src/daemon/tls-certs.ts +55 -13
  174. package/src/daemon/tool-side-effects.ts +13 -5
  175. package/src/gallery/default-gallery.ts +32 -9
  176. package/src/influencer/client.ts +2 -1
  177. package/src/memory/channel-delivery-store.ts +37 -567
  178. package/src/memory/channel-guardian-store.ts +66 -1317
  179. package/src/memory/conflict-store.ts +4 -4
  180. package/src/memory/conversation-attention-store.ts +4 -7
  181. package/src/memory/conversation-crud.ts +668 -0
  182. package/src/memory/conversation-queries.ts +361 -0
  183. package/src/memory/conversation-store.ts +45 -983
  184. package/src/memory/db-connection.ts +3 -0
  185. package/src/memory/db-init.ts +25 -0
  186. package/src/memory/delivery-channels.ts +175 -0
  187. package/src/memory/delivery-crud.ts +211 -0
  188. package/src/memory/delivery-status.ts +199 -0
  189. package/src/memory/embedding-backend.ts +70 -4
  190. package/src/memory/embedding-local.ts +12 -2
  191. package/src/memory/entity-extractor.ts +3 -8
  192. package/src/memory/fts-reconciler.ts +121 -0
  193. package/src/memory/guardian-action-store.ts +366 -3
  194. package/src/memory/guardian-approvals.ts +569 -0
  195. package/src/memory/guardian-bindings.ts +130 -0
  196. package/src/memory/guardian-rate-limits.ts +196 -0
  197. package/src/memory/guardian-verification.ts +520 -0
  198. package/src/memory/job-handlers/index-maintenance.ts +2 -1
  199. package/src/memory/job-utils.ts +8 -5
  200. package/src/memory/jobs-store.ts +66 -6
  201. package/src/memory/jobs-worker.ts +23 -1
  202. package/src/memory/migrations/030-guardian-action-followup.ts +21 -0
  203. package/src/memory/migrations/030-guardian-verification-purpose.ts +17 -0
  204. package/src/memory/migrations/031-conversations-thread-type-index.ts +5 -0
  205. package/src/memory/migrations/100-core-tables.ts +1 -1
  206. package/src/memory/migrations/101-watchers-and-logs.ts +4 -0
  207. package/src/memory/migrations/108-tasks-and-work-items.ts +1 -1
  208. package/src/memory/migrations/112-assistant-inbox.ts +1 -1
  209. package/src/memory/migrations/113-late-migrations.ts +1 -1
  210. package/src/memory/migrations/116-messages-fts.ts +13 -0
  211. package/src/memory/migrations/119-schema-indexes-and-columns.ts +37 -0
  212. package/src/memory/migrations/120-fk-cascade-rebuilds.ts +161 -0
  213. package/src/memory/migrations/index.ts +8 -3
  214. package/src/memory/migrations/validate-migration-state.ts +114 -15
  215. package/src/memory/qdrant-circuit-breaker.ts +105 -0
  216. package/src/memory/retriever.ts +46 -13
  217. package/src/memory/schema-migration.ts +3 -0
  218. package/src/memory/schema.ts +25 -7
  219. package/src/memory/search/semantic.ts +8 -90
  220. package/src/notifications/README.md +1 -1
  221. package/src/notifications/broadcaster.ts +20 -2
  222. package/src/notifications/conversation-pairing.ts +3 -3
  223. package/src/notifications/decision-engine.ts +173 -8
  224. package/src/notifications/deliveries-store.ts +27 -8
  225. package/src/notifications/preferences-store.ts +7 -7
  226. package/src/notifications/thread-candidates.ts +234 -0
  227. package/src/notifications/types.ts +18 -0
  228. package/src/permissions/defaults.ts +11 -1
  229. package/src/permissions/prompter.ts +17 -0
  230. package/src/permissions/trust-store.ts +2 -0
  231. package/src/providers/failover.ts +19 -0
  232. package/src/providers/registry.ts +46 -1
  233. package/src/runtime/approval-message-composer.ts +1 -1
  234. package/src/runtime/channel-guardian-service.ts +15 -3
  235. package/src/runtime/channel-retry-sweep.ts +7 -2
  236. package/src/runtime/guardian-action-conversation-turn.ts +85 -0
  237. package/src/runtime/guardian-action-followup-executor.ts +301 -0
  238. package/src/runtime/guardian-action-message-composer.ts +245 -0
  239. package/src/runtime/guardian-outbound-actions.ts +35 -15
  240. package/src/runtime/guardian-verification-templates.ts +15 -9
  241. package/src/runtime/http-errors.ts +93 -0
  242. package/src/runtime/http-server.ts +140 -51
  243. package/src/runtime/http-types.ts +53 -0
  244. package/src/runtime/ingress-service.ts +237 -0
  245. package/src/runtime/middleware/error-handler.ts +4 -3
  246. package/src/runtime/middleware/rate-limiter.ts +160 -0
  247. package/src/runtime/middleware/request-logger.ts +71 -0
  248. package/src/runtime/middleware/twilio-validation.ts +7 -6
  249. package/src/runtime/pending-interactions.ts +12 -0
  250. package/src/runtime/routes/access-request-decision.ts +215 -0
  251. package/src/runtime/routes/app-routes.ts +25 -18
  252. package/src/runtime/routes/approval-routes.ts +18 -47
  253. package/src/runtime/routes/attachment-routes.ts +15 -41
  254. package/src/runtime/routes/call-routes.ts +20 -20
  255. package/src/runtime/routes/channel-delivery-routes.ts +6 -5
  256. package/src/runtime/routes/contact-routes.ts +4 -9
  257. package/src/runtime/routes/conversation-attention-routes.ts +5 -4
  258. package/src/runtime/routes/conversation-routes.ts +26 -57
  259. package/src/runtime/routes/debug-routes.ts +71 -0
  260. package/src/runtime/routes/events-routes.ts +3 -2
  261. package/src/runtime/routes/guardian-approval-interception.ts +221 -0
  262. package/src/runtime/routes/identity-routes.ts +14 -10
  263. package/src/runtime/routes/inbound-conversation.ts +3 -2
  264. package/src/runtime/routes/inbound-message-handler.ts +527 -62
  265. package/src/runtime/routes/ingress-routes.ts +174 -0
  266. package/src/runtime/routes/integration-routes.ts +82 -20
  267. package/src/runtime/routes/pairing-routes.ts +11 -10
  268. package/src/runtime/routes/secret-routes.ts +10 -18
  269. package/src/runtime/verification-rate-limiter.ts +83 -0
  270. package/src/schedule/schedule-store.ts +13 -1
  271. package/src/schedule/scheduler.ts +2 -2
  272. package/src/security/secret-ingress.ts +5 -2
  273. package/src/security/secret-scanner.ts +72 -6
  274. package/src/subagent/manager.ts +6 -4
  275. package/src/swarm/plan-validator.ts +4 -1
  276. package/src/tasks/task-runner.ts +3 -1
  277. package/src/tools/browser/api-map.ts +9 -6
  278. package/src/tools/calls/call-start.ts +20 -0
  279. package/src/tools/executor.ts +50 -568
  280. package/src/tools/permission-checker.ts +272 -0
  281. package/src/tools/registry.ts +14 -6
  282. package/src/tools/reminder/reminder-store.ts +7 -7
  283. package/src/tools/reminder/reminder.ts +6 -3
  284. package/src/tools/secret-detection-handler.ts +301 -0
  285. package/src/tools/subagent/message.ts +1 -1
  286. package/src/tools/system/voice-config.ts +62 -0
  287. package/src/tools/tasks/index.ts +3 -3
  288. package/src/tools/tasks/work-item-list.ts +3 -3
  289. package/src/tools/tasks/work-item-update.ts +4 -5
  290. package/src/tools/tool-approval-handler.ts +192 -0
  291. package/src/tools/tool-manifest.ts +2 -0
  292. package/src/watcher/watcher-store.ts +9 -9
  293. package/src/work-items/work-item-runner.ts +9 -6
  294. /package/src/memory/migrations/{026-embeddings-nullable-vector-json.ts → 026a-embeddings-nullable-vector-json.ts} +0 -0
  295. /package/src/memory/migrations/{027-guardian-bootstrap-token.ts → 027a-guardian-bootstrap-token.ts} +0 -0
@@ -40,6 +40,9 @@ export function getSqlite(): Database {
40
40
  * Useful in migrations and tests that receive the Drizzle instance as a parameter.
41
41
  */
42
42
  export function getSqliteFrom(drizzleDb: DrizzleDb): Database {
43
+ // Drizzle's bun:sqlite adapter stores the raw Database as $client but
44
+ // doesn't expose it in its public type. This is the single canonical
45
+ // location for this cast — all callers should use getSqlite/getSqliteFrom.
43
46
  return (drizzleDb as unknown as { $client: Database }).$client;
44
47
  }
45
48
 
@@ -17,11 +17,17 @@ import {
17
17
  createTasksAndWorkItemsTables,
18
18
  createWatchersAndLogsTables,
19
19
  migrateCallSessionMode,
20
+ migrateFkCascadeRebuilds,
20
21
  migrateChannelInboundDeliveredSegments,
22
+ migrateConversationsThreadTypeIndex,
23
+ migrateGuardianActionFollowup,
21
24
  migrateGuardianBootstrapToken,
25
+ migrateGuardianVerificationPurpose,
22
26
  migrateGuardianVerificationSessions,
23
27
  migrateMessagesFtsBackfill,
24
28
  migrateReminderRoutingIntent,
29
+ migrateSchemaIndexesAndColumns,
30
+ recoverCrashedMigrations,
25
31
  runComplexMigrations,
26
32
  runLateMigrations,
27
33
  validateMigrationState,
@@ -33,6 +39,10 @@ export function initializeDb(): void {
33
39
  // 1. Create core tables (conversations, messages, memory, etc.)
34
40
  createCoreTables(database);
35
41
 
42
+ // 1b. Clear any stalled 'started' checkpoints left by previous crashes
43
+ // so the affected migrations can re-run from scratch.
44
+ recoverCrashedMigrations(database);
45
+
36
46
  // 2. Create watchers, logs, entities, FTS, and conversation keys
37
47
  createWatchersAndLogsTables(database);
38
48
 
@@ -72,6 +82,9 @@ export function initializeDb(): void {
72
82
  // 11c. Guardian bootstrap token hash column (Telegram deep-link flow)
73
83
  migrateGuardianBootstrapToken(database);
74
84
 
85
+ // 11d. Guardian verification purpose discriminator (guardian vs trusted_contact)
86
+ migrateGuardianVerificationPurpose(database);
87
+
75
88
  // 12. Media assets
76
89
  createMediaAssetsTables(database);
77
90
 
@@ -84,6 +97,12 @@ export function initializeDb(): void {
84
97
  // 14b. Track per-segment delivery progress for split channel replies
85
98
  migrateChannelInboundDeliveredSegments(database);
86
99
 
100
+ // 14c. Guardian action follow-up lifecycle columns (timeout reason, late answers)
101
+ migrateGuardianActionFollowup(database);
102
+
103
+ // 14d. Index on conversations.thread_type for frequent WHERE filters
104
+ migrateConversationsThreadTypeIndex(database);
105
+
87
106
  // 15. Notification system
88
107
  createNotificationTables(database);
89
108
 
@@ -100,5 +119,11 @@ export function initializeDb(): void {
100
119
  // 19. Reminder routing metadata (routing_intent + routing_hints_json columns)
101
120
  migrateReminderRoutingIntent(database);
102
121
 
122
+ // 20. Schema indexes, columns, and constraints
123
+ migrateSchemaIndexesAndColumns(database);
124
+
125
+ // 21. Rebuild tables to add ON DELETE CASCADE to FK constraints
126
+ migrateFkCascadeRebuilds(database);
127
+
103
128
  validateMigrationState(database);
104
129
  }
@@ -0,0 +1,175 @@
1
+ /**
2
+ * Channel-specific delivery logic for inbound events.
3
+ *
4
+ * Handles verification reply persistence, per-segment delivery progress
5
+ * tracking, and the deliver-once guard for terminal reply idempotency.
6
+ */
7
+
8
+ import { eq } from 'drizzle-orm';
9
+
10
+ import { getDb } from './db.js';
11
+ import { channelInboundEvents } from './schema.js';
12
+
13
+ // ── Pending verification reply helpers ───────────────────────────────
14
+ //
15
+ // When a guardian verification succeeds but the confirmation reply fails
16
+ // to deliver, we persist the reply details on the inbound event so that
17
+ // gateway retries (which arrive as duplicates) can re-attempt delivery.
18
+
19
+ export interface PendingVerificationReply {
20
+ __pendingVerificationReply: true;
21
+ chatId: string;
22
+ text: string;
23
+ assistantId: string;
24
+ }
25
+
26
+ /**
27
+ * Store a pending verification reply on an inbound event. Called when
28
+ * `deliverChannelReply` fails after challenge consumption so the reply
29
+ * can be retried on subsequent duplicate deliveries.
30
+ */
31
+ export function storePendingVerificationReply(
32
+ eventId: string,
33
+ reply: Omit<PendingVerificationReply, '__pendingVerificationReply'>,
34
+ ): void {
35
+ const db = getDb();
36
+ const payload: PendingVerificationReply = { __pendingVerificationReply: true, ...reply };
37
+ db.update(channelInboundEvents)
38
+ .set({ rawPayload: JSON.stringify(payload), updatedAt: Date.now() })
39
+ .where(eq(channelInboundEvents.id, eventId))
40
+ .run();
41
+ }
42
+
43
+ /**
44
+ * Retrieve a pending verification reply for a given event, if one exists.
45
+ */
46
+ export function getPendingVerificationReply(
47
+ eventId: string,
48
+ ): PendingVerificationReply | null {
49
+ const db = getDb();
50
+ const row = db
51
+ .select({ rawPayload: channelInboundEvents.rawPayload })
52
+ .from(channelInboundEvents)
53
+ .where(eq(channelInboundEvents.id, eventId))
54
+ .get();
55
+
56
+ if (!row?.rawPayload) return null;
57
+ try {
58
+ const parsed = JSON.parse(row.rawPayload);
59
+ if (parsed && parsed.__pendingVerificationReply === true) {
60
+ return parsed as PendingVerificationReply;
61
+ }
62
+ return null;
63
+ } catch {
64
+ return null;
65
+ }
66
+ }
67
+
68
+ /**
69
+ * Clear a pending verification reply after successful delivery.
70
+ */
71
+ export function clearPendingVerificationReply(eventId: string): void {
72
+ const db = getDb();
73
+ db.update(channelInboundEvents)
74
+ .set({ rawPayload: null, updatedAt: Date.now() })
75
+ .where(eq(channelInboundEvents.id, eventId))
76
+ .run();
77
+ }
78
+
79
+ // ── Per-segment delivery progress ──────────────────────────────────
80
+ //
81
+ // When a split reply (multiple text segments from tool boundaries) fails
82
+ // partway through delivery, we persist how many segments were sent so
83
+ // the retry can resume from where it left off.
84
+
85
+ /**
86
+ * Read the number of reply segments already delivered for an event.
87
+ */
88
+ export function getDeliveredSegmentCount(eventId: string): number {
89
+ const db = getDb();
90
+ const row = db
91
+ .select({ count: channelInboundEvents.deliveredSegmentCount })
92
+ .from(channelInboundEvents)
93
+ .where(eq(channelInboundEvents.id, eventId))
94
+ .get();
95
+ return row?.count ?? 0;
96
+ }
97
+
98
+ /**
99
+ * Update the delivered segment count after successful delivery of one
100
+ * or more segments. Called incrementally as segments are sent.
101
+ */
102
+ export function updateDeliveredSegmentCount(eventId: string, count: number): void {
103
+ const db = getDb();
104
+ db.update(channelInboundEvents)
105
+ .set({ deliveredSegmentCount: count, updatedAt: Date.now() })
106
+ .where(eq(channelInboundEvents.id, eventId))
107
+ .run();
108
+ }
109
+
110
+ // ── Deliver-once guard for terminal reply idempotency ────────────────
111
+ //
112
+ // When both the main poll (processChannelMessageWithApprovals) and the
113
+ // post-decision poll (schedulePostDecisionDelivery) race to deliver the
114
+ // final assistant reply for the same run, this guard ensures only one
115
+ // of them actually sends the message. The guard is run-scoped so old
116
+ // assistant messages from previous runs are not affected.
117
+
118
+ /** Map from runId to insertion timestamp (ms). */
119
+ const deliveredRuns = new Map<string, number>();
120
+
121
+ /** TTL for delivery claims — 10 minutes, well beyond the poll max-wait. */
122
+ const CLAIM_TTL_MS = 10 * 60 * 1000;
123
+
124
+ /** Hard cap to bound memory even under sustained high throughput within the TTL window. */
125
+ const MAX_DELIVERED_RUNS = 10_000;
126
+
127
+ /**
128
+ * Atomically claim the right to deliver the final reply for a run.
129
+ * Returns `true` if this caller won the claim (and should proceed with
130
+ * delivery). Returns `false` if another caller already claimed it.
131
+ *
132
+ * This is an in-memory guard — sufficient because both racing pollers
133
+ * execute within the same process. The Map is never persisted; on restart
134
+ * there are no in-flight pollers to race.
135
+ *
136
+ * Claims are evicted after CLAIM_TTL_MS. When the hard cap is reached,
137
+ * only TTL-expired entries are evicted — active claims are never removed
138
+ * early, preserving the at-most-once delivery guarantee.
139
+ */
140
+ export function claimRunDelivery(runId: string): boolean {
141
+ if (deliveredRuns.has(runId)) return false;
142
+ if (deliveredRuns.size >= MAX_DELIVERED_RUNS) {
143
+ // Only evict entries whose TTL has expired. Map iteration order
144
+ // matches insertion order, so oldest entries come first.
145
+ const now = Date.now();
146
+ for (const [id, insertedAt] of deliveredRuns) {
147
+ if (now - insertedAt >= CLAIM_TTL_MS) {
148
+ deliveredRuns.delete(id);
149
+ } else {
150
+ // Remaining entries are newer; stop scanning.
151
+ break;
152
+ }
153
+ }
154
+ }
155
+ const now = Date.now();
156
+ deliveredRuns.set(runId, now);
157
+ setTimeout(() => deliveredRuns.delete(runId), CLAIM_TTL_MS);
158
+ return true;
159
+ }
160
+
161
+ /**
162
+ * Reset the deliver-once guard for a run. Used to release a claim when
163
+ * delivery fails (so the other racing poller can retry) and in tests
164
+ * for isolation between test cases.
165
+ */
166
+ export function resetRunDeliveryClaim(runId: string): void {
167
+ deliveredRuns.delete(runId);
168
+ }
169
+
170
+ /**
171
+ * Clear all delivery claims. Used in tests for full isolation.
172
+ */
173
+ export function resetAllRunDeliveryClaims(): void {
174
+ deliveredRuns.clear();
175
+ }
@@ -0,0 +1,211 @@
1
+ /**
2
+ * Core CRUD operations for channel inbound events.
3
+ *
4
+ * Handles recording inbound messages, linking them to internal message IDs,
5
+ * finding messages by source identifiers, and managing raw payload storage.
6
+ */
7
+
8
+ import { and, desc, eq, isNotNull } from 'drizzle-orm';
9
+ import { v4 as uuid } from 'uuid';
10
+
11
+ import { getConversationByKey, getOrCreateConversation, setConversationKeyIfAbsent } from './conversation-key-store.js';
12
+ import { getDb } from './db.js';
13
+ import { channelInboundEvents, conversations } from './schema.js';
14
+
15
+ export interface InboundResult {
16
+ accepted: boolean;
17
+ eventId: string;
18
+ conversationId: string;
19
+ duplicate: boolean;
20
+ }
21
+
22
+ export interface RecordInboundOptions {
23
+ sourceMessageId?: string;
24
+ assistantId?: string;
25
+ }
26
+
27
+ /**
28
+ * Record an inbound channel event. Returns `duplicate: true` if this
29
+ * exact (channel, chat, message) combination was already seen.
30
+ */
31
+ export function recordInbound(
32
+ sourceChannel: string,
33
+ externalChatId: string,
34
+ externalMessageId: string,
35
+ options?: RecordInboundOptions,
36
+ ): InboundResult {
37
+ const db = getDb();
38
+
39
+ const existing = db
40
+ .select({
41
+ id: channelInboundEvents.id,
42
+ conversationId: channelInboundEvents.conversationId,
43
+ })
44
+ .from(channelInboundEvents)
45
+ .where(
46
+ and(
47
+ eq(channelInboundEvents.sourceChannel, sourceChannel),
48
+ eq(channelInboundEvents.externalChatId, externalChatId),
49
+ eq(channelInboundEvents.externalMessageId, externalMessageId),
50
+ ),
51
+ )
52
+ .get();
53
+
54
+ if (existing) {
55
+ return {
56
+ accepted: true,
57
+ eventId: existing.id,
58
+ conversationId: existing.conversationId,
59
+ duplicate: true,
60
+ };
61
+ }
62
+
63
+ const assistantId = options?.assistantId;
64
+ const legacyKey = `${sourceChannel}:${externalChatId}`;
65
+ const scopedKey = assistantId ? `asst:${assistantId}:${sourceChannel}:${externalChatId}` : legacyKey;
66
+
67
+ // Resolve conversation mapping with assistant-scoped keying:
68
+ // 1. If scoped key exists, use it directly.
69
+ // 2. If assistantId is "self" and legacy key exists, reuse the legacy
70
+ // conversation and create a scoped alias to prevent future bleed.
71
+ // 3. Otherwise, create/get conversation from the scoped key.
72
+ let mapping: { conversationId: string; created: boolean };
73
+ const scopedMapping = assistantId ? getConversationByKey(scopedKey) : null;
74
+ if (scopedMapping) {
75
+ mapping = { conversationId: scopedMapping.conversationId, created: false };
76
+ } else if (assistantId === 'self') {
77
+ const legacyMapping = getConversationByKey(legacyKey);
78
+ if (legacyMapping) {
79
+ mapping = { conversationId: legacyMapping.conversationId, created: false };
80
+ setConversationKeyIfAbsent(scopedKey, legacyMapping.conversationId);
81
+ } else {
82
+ mapping = getOrCreateConversation(scopedKey);
83
+ }
84
+ } else {
85
+ mapping = getOrCreateConversation(scopedKey);
86
+ }
87
+ const now = Date.now();
88
+ const eventId = uuid();
89
+
90
+ db.transaction((tx) => {
91
+ tx.update(conversations)
92
+ .set({ updatedAt: now })
93
+ .where(eq(conversations.id, mapping.conversationId))
94
+ .run();
95
+ tx.insert(channelInboundEvents)
96
+ .values({
97
+ id: eventId,
98
+ sourceChannel,
99
+ externalChatId,
100
+ externalMessageId,
101
+ sourceMessageId: options?.sourceMessageId ?? null,
102
+ conversationId: mapping.conversationId,
103
+ deliveryStatus: 'pending',
104
+ createdAt: now,
105
+ updatedAt: now,
106
+ })
107
+ .run();
108
+ });
109
+
110
+ return {
111
+ accepted: true,
112
+ eventId,
113
+ conversationId: mapping.conversationId,
114
+ duplicate: false,
115
+ };
116
+ }
117
+
118
+ /**
119
+ * Link an inbound event to the user message it created, so edits can
120
+ * later find the correct message by source_message_id -> message_id.
121
+ */
122
+ export function linkMessage(eventId: string, messageId: string): void {
123
+ const db = getDb();
124
+ db.update(channelInboundEvents)
125
+ .set({ messageId, updatedAt: Date.now() })
126
+ .where(eq(channelInboundEvents.id, eventId))
127
+ .run();
128
+ }
129
+
130
+ /**
131
+ * Find the message ID linked to the original inbound event for a given
132
+ * platform-level message identifier (e.g. Telegram message_id).
133
+ */
134
+ export function findMessageBySourceId(
135
+ sourceChannel: string,
136
+ externalChatId: string,
137
+ sourceMessageId: string,
138
+ ): { messageId: string; conversationId: string } | null {
139
+ const db = getDb();
140
+ const row = db
141
+ .select({
142
+ messageId: channelInboundEvents.messageId,
143
+ conversationId: channelInboundEvents.conversationId,
144
+ })
145
+ .from(channelInboundEvents)
146
+ .where(
147
+ and(
148
+ eq(channelInboundEvents.sourceChannel, sourceChannel),
149
+ eq(channelInboundEvents.externalChatId, externalChatId),
150
+ eq(channelInboundEvents.sourceMessageId, sourceMessageId),
151
+ isNotNull(channelInboundEvents.messageId),
152
+ ),
153
+ )
154
+ .get();
155
+
156
+ if (!row || !row.messageId) return null;
157
+ return { messageId: row.messageId, conversationId: row.conversationId };
158
+ }
159
+
160
+ /**
161
+ * Store the raw request payload on an inbound event so it can be
162
+ * replayed later if processing fails.
163
+ */
164
+ export function storePayload(eventId: string, payload: Record<string, unknown>): void {
165
+ const db = getDb();
166
+ db.update(channelInboundEvents)
167
+ .set({ rawPayload: JSON.stringify(payload), updatedAt: Date.now() })
168
+ .where(eq(channelInboundEvents.id, eventId))
169
+ .run();
170
+ }
171
+
172
+ /**
173
+ * Clear a previously stored payload. Used when the ingress check
174
+ * detects secret-bearing content — the payload must not remain on disk.
175
+ */
176
+ export function clearPayload(eventId: string): void {
177
+ const db = getDb();
178
+ db.update(channelInboundEvents)
179
+ .set({ rawPayload: null, updatedAt: Date.now() })
180
+ .where(eq(channelInboundEvents.id, eventId))
181
+ .run();
182
+ }
183
+
184
+ /**
185
+ * Retrieve the stored raw payload for a given conversation's most recent
186
+ * inbound event. Used by the escalation decide flow to recover the
187
+ * original message content after an approve/deny decision.
188
+ */
189
+ export function getLatestStoredPayload(conversationId: string): Record<string, unknown> | null {
190
+ const db = getDb();
191
+ const row = db
192
+ .select({
193
+ rawPayload: channelInboundEvents.rawPayload,
194
+ })
195
+ .from(channelInboundEvents)
196
+ .where(
197
+ and(
198
+ eq(channelInboundEvents.conversationId, conversationId),
199
+ isNotNull(channelInboundEvents.rawPayload),
200
+ ),
201
+ )
202
+ .orderBy(desc(channelInboundEvents.createdAt))
203
+ .get();
204
+
205
+ if (!row?.rawPayload) return null;
206
+ try {
207
+ return JSON.parse(row.rawPayload) as Record<string, unknown>;
208
+ } catch {
209
+ return null;
210
+ }
211
+ }
@@ -0,0 +1,199 @@
1
+ /**
2
+ * Processing status tracking and dead-letter queue management for
3
+ * channel inbound events.
4
+ *
5
+ * Handles marking events as processed/failed/dead-lettered, fetching
6
+ * retryable and dead-lettered events, and replaying dead letters.
7
+ */
8
+
9
+ import { and, eq, lte } from 'drizzle-orm';
10
+
11
+ import { getDb } from './db.js';
12
+ import {
13
+ classifyError,
14
+ RETRY_MAX_ATTEMPTS,
15
+ retryDelayForAttempt,
16
+ } from './job-utils.js';
17
+ import { channelInboundEvents } from './schema.js';
18
+
19
+ /**
20
+ * Acknowledge delivery of an outbound message for a channel event.
21
+ */
22
+ export function acknowledgeDelivery(
23
+ sourceChannel: string,
24
+ externalChatId: string,
25
+ externalMessageId: string,
26
+ ): boolean {
27
+ const db = getDb();
28
+ const now = Date.now();
29
+
30
+ const existing = db
31
+ .select({ id: channelInboundEvents.id })
32
+ .from(channelInboundEvents)
33
+ .where(
34
+ and(
35
+ eq(channelInboundEvents.sourceChannel, sourceChannel),
36
+ eq(channelInboundEvents.externalChatId, externalChatId),
37
+ eq(channelInboundEvents.externalMessageId, externalMessageId),
38
+ ),
39
+ )
40
+ .get();
41
+
42
+ if (!existing) return false;
43
+
44
+ db.update(channelInboundEvents)
45
+ .set({
46
+ deliveryStatus: 'delivered',
47
+ updatedAt: now,
48
+ })
49
+ .where(eq(channelInboundEvents.id, existing.id))
50
+ .run();
51
+
52
+ return true;
53
+ }
54
+
55
+ /** Mark an event as successfully processed. */
56
+ export function markProcessed(eventId: string): void {
57
+ const db = getDb();
58
+ db.update(channelInboundEvents)
59
+ .set({ processingStatus: 'processed', updatedAt: Date.now() })
60
+ .where(eq(channelInboundEvents.id, eventId))
61
+ .run();
62
+ }
63
+
64
+ /**
65
+ * Record a processing failure. Classifies the error to decide whether
66
+ * the event should be retried (status='failed') or dead-lettered
67
+ * (status='dead_letter') when the error is fatal or max attempts
68
+ * are exhausted.
69
+ */
70
+ export function recordProcessingFailure(eventId: string, err: unknown): void {
71
+ const db = getDb();
72
+ const now = Date.now();
73
+
74
+ const row = db
75
+ .select({ attempts: channelInboundEvents.processingAttempts })
76
+ .from(channelInboundEvents)
77
+ .where(eq(channelInboundEvents.id, eventId))
78
+ .get();
79
+
80
+ const attempts = (row?.attempts ?? 0) + 1;
81
+ const category = classifyError(err);
82
+ const errorMsg = err instanceof Error ? err.message : String(err);
83
+
84
+ if (category === 'fatal' || attempts >= RETRY_MAX_ATTEMPTS) {
85
+ db.update(channelInboundEvents)
86
+ .set({
87
+ processingStatus: 'dead_letter',
88
+ processingAttempts: attempts,
89
+ lastProcessingError: errorMsg,
90
+ retryAfter: null,
91
+ updatedAt: now,
92
+ })
93
+ .where(eq(channelInboundEvents.id, eventId))
94
+ .run();
95
+ } else {
96
+ const delay = retryDelayForAttempt(attempts);
97
+ db.update(channelInboundEvents)
98
+ .set({
99
+ processingStatus: 'failed',
100
+ processingAttempts: attempts,
101
+ lastProcessingError: errorMsg,
102
+ retryAfter: now + delay,
103
+ updatedAt: now,
104
+ })
105
+ .where(eq(channelInboundEvents.id, eventId))
106
+ .run();
107
+ }
108
+ }
109
+
110
+ /** Fetch events eligible for automatic retry (failed + past their backoff). */
111
+ export function getRetryableEvents(limit = 20): Array<{
112
+ id: string;
113
+ conversationId: string;
114
+ processingAttempts: number;
115
+ rawPayload: string | null;
116
+ }> {
117
+ const db = getDb();
118
+ const now = Date.now();
119
+ return db
120
+ .select({
121
+ id: channelInboundEvents.id,
122
+ conversationId: channelInboundEvents.conversationId,
123
+ processingAttempts: channelInboundEvents.processingAttempts,
124
+ rawPayload: channelInboundEvents.rawPayload,
125
+ })
126
+ .from(channelInboundEvents)
127
+ .where(
128
+ and(
129
+ eq(channelInboundEvents.processingStatus, 'failed'),
130
+ lte(channelInboundEvents.retryAfter, now),
131
+ ),
132
+ )
133
+ .limit(limit)
134
+ .all();
135
+ }
136
+
137
+ /** Fetch dead-lettered events. */
138
+ export function getDeadLetterEvents(): Array<{
139
+ id: string;
140
+ sourceChannel: string;
141
+ externalChatId: string;
142
+ externalMessageId: string;
143
+ conversationId: string;
144
+ processingAttempts: number;
145
+ lastProcessingError: string | null;
146
+ createdAt: number;
147
+ }> {
148
+ const db = getDb();
149
+ return db
150
+ .select({
151
+ id: channelInboundEvents.id,
152
+ sourceChannel: channelInboundEvents.sourceChannel,
153
+ externalChatId: channelInboundEvents.externalChatId,
154
+ externalMessageId: channelInboundEvents.externalMessageId,
155
+ conversationId: channelInboundEvents.conversationId,
156
+ processingAttempts: channelInboundEvents.processingAttempts,
157
+ lastProcessingError: channelInboundEvents.lastProcessingError,
158
+ createdAt: channelInboundEvents.createdAt,
159
+ })
160
+ .from(channelInboundEvents)
161
+ .where(eq(channelInboundEvents.processingStatus, 'dead_letter'))
162
+ .all();
163
+ }
164
+
165
+ /**
166
+ * Reset dead-lettered events back to 'failed' so the sweep can retry
167
+ * them. Resets attempt counter and sets an immediate retry_after.
168
+ */
169
+ export function replayDeadLetters(eventIds: string[]): number {
170
+ const db = getDb();
171
+ const now = Date.now();
172
+ let count = 0;
173
+ for (const id of eventIds) {
174
+ const existing = db
175
+ .select({ id: channelInboundEvents.id })
176
+ .from(channelInboundEvents)
177
+ .where(
178
+ and(
179
+ eq(channelInboundEvents.id, id),
180
+ eq(channelInboundEvents.processingStatus, 'dead_letter'),
181
+ ),
182
+ )
183
+ .get();
184
+ if (!existing) continue;
185
+
186
+ db.update(channelInboundEvents)
187
+ .set({
188
+ processingStatus: 'failed',
189
+ processingAttempts: 0,
190
+ lastProcessingError: null,
191
+ retryAfter: now,
192
+ updatedAt: now,
193
+ })
194
+ .where(eq(channelInboundEvents.id, id))
195
+ .run();
196
+ count++;
197
+ }
198
+ return count;
199
+ }