@vellumai/assistant 0.4.35 → 0.4.37

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 (239) hide show
  1. package/AGENTS.md +1 -1
  2. package/ARCHITECTURE.md +44 -49
  3. package/README.md +32 -20
  4. package/docs/architecture/keychain-broker.md +186 -0
  5. package/docs/architecture/security.md +110 -116
  6. package/docs/runbook-trusted-contacts.md +2 -2
  7. package/docs/skills.md +25 -25
  8. package/package.json +5 -2
  9. package/src/__tests__/__snapshots__/ipc-snapshot.test.ts.snap +11 -2
  10. package/src/__tests__/actor-token-service.test.ts +1 -0
  11. package/src/__tests__/amazon-cdp-integration.test.ts +74 -0
  12. package/src/__tests__/assistant-feature-flags-integration.test.ts +38 -9
  13. package/src/__tests__/assistant-id-boundary-guard.test.ts +29 -0
  14. package/src/__tests__/browser-fill-credential.test.ts +1 -1
  15. package/src/__tests__/bundle-scanner.test.ts +1 -1
  16. package/src/__tests__/channel-guardian.test.ts +102 -102
  17. package/src/__tests__/channel-invite-transport.test.ts +155 -256
  18. package/src/__tests__/channel-readiness-routes.test.ts +336 -0
  19. package/src/__tests__/checker.test.ts +6 -6
  20. package/src/__tests__/chrome-cdp.test.ts +350 -0
  21. package/src/__tests__/computer-use-session-lifecycle.test.ts +3 -3
  22. package/src/__tests__/computer-use-session-working-dir.test.ts +86 -52
  23. package/src/__tests__/computer-use-skill-lifecycle-cleanup.test.ts +1 -1
  24. package/src/__tests__/config-loader-migration.test.ts +85 -0
  25. package/src/__tests__/conversation-pairing.test.ts +370 -5
  26. package/src/__tests__/credential-broker-browser-fill.test.ts +1 -10
  27. package/src/__tests__/credential-broker-server-use.test.ts +1 -10
  28. package/src/__tests__/credential-security-e2e.test.ts +7 -1
  29. package/src/__tests__/credential-security-invariants.test.ts +14 -20
  30. package/src/__tests__/credential-vault-unit.test.ts +1 -11
  31. package/src/__tests__/credential-vault.test.ts +5 -19
  32. package/src/__tests__/credentials-cli.test.ts +814 -0
  33. package/src/__tests__/dynamic-skill-workflow-prompt.test.ts +23 -4
  34. package/src/__tests__/email-invite-adapter.test.ts +78 -0
  35. package/src/__tests__/email-service-config-fallback.test.ts +102 -0
  36. package/src/__tests__/encrypted-store.test.ts +6 -6
  37. package/src/__tests__/ephemeral-permissions.test.ts +3 -3
  38. package/src/__tests__/gateway-only-enforcement.test.ts +5 -1
  39. package/src/__tests__/guardian-actions-endpoint.test.ts +70 -12
  40. package/src/__tests__/guardian-outbound-http.test.ts +53 -47
  41. package/src/__tests__/handle-user-message-secret-resume.test.ts +23 -0
  42. package/src/__tests__/handlers-add-trust-rule-metadata.test.ts +32 -23
  43. package/src/__tests__/handlers-telegram-config.test.ts +8 -2
  44. package/src/__tests__/handlers-twitter-config.test.ts +2 -2
  45. package/src/__tests__/handlers-user-message-approval-consumption.test.ts +108 -7
  46. package/src/__tests__/ingress-reconcile.test.ts +6 -0
  47. package/src/__tests__/intent-routing.test.ts +23 -4
  48. package/src/__tests__/invite-routes-http.test.ts +12 -0
  49. package/src/__tests__/ipc-snapshot.test.ts +8 -2
  50. package/src/__tests__/keychain-broker-client.test.ts +543 -0
  51. package/src/__tests__/llm-usage-store.test.ts +344 -0
  52. package/src/__tests__/mcp-client-auth.test.ts +2 -2
  53. package/src/__tests__/media-reuse-story.e2e.test.ts +1 -1
  54. package/src/__tests__/migration-transport.test.ts +49 -0
  55. package/src/__tests__/notification-broadcaster.test.ts +205 -5
  56. package/src/__tests__/notification-deep-link.test.ts +365 -1
  57. package/src/__tests__/oauth-connect-handler.test.ts +2 -2
  58. package/src/__tests__/onboarding-starter-tasks.test.ts +17 -4
  59. package/src/__tests__/proxy-approval-callback.test.ts +1 -1
  60. package/src/__tests__/recording-handler.test.ts +1 -1
  61. package/src/__tests__/recording-intent-handler.test.ts +6 -1
  62. package/src/__tests__/recording-state-machine.test.ts +1 -1
  63. package/src/__tests__/relay-server.test.ts +9 -1
  64. package/src/__tests__/ride-shotgun-handler.test.ts +499 -0
  65. package/src/__tests__/runtime-attachment-metadata.test.ts +160 -1
  66. package/src/__tests__/script-proxy-injection-runtime.test.ts +299 -2
  67. package/src/__tests__/script-proxy-profile-template-fallback.test.ts +1 -1
  68. package/src/__tests__/secret-onetime-send.test.ts +8 -2
  69. package/src/__tests__/secure-keys.test.ts +175 -216
  70. package/src/__tests__/session-confirmation-signals.test.ts +1 -1
  71. package/src/__tests__/session-messaging-secret-redirect.test.ts +1 -1
  72. package/src/__tests__/session-queue.test.ts +2 -1
  73. package/src/__tests__/session-tool-setup-app-refresh.test.ts +2 -2
  74. package/src/__tests__/skill-feature-flags-integration.test.ts +29 -4
  75. package/src/__tests__/skill-feature-flags.test.ts +12 -9
  76. package/src/__tests__/skill-load-feature-flag.test.ts +26 -5
  77. package/src/__tests__/skill-projection.benchmark.test.ts +0 -1
  78. package/src/__tests__/skills.test.ts +34 -4
  79. package/src/__tests__/slack-channel-config.test.ts +2 -2
  80. package/src/__tests__/system-prompt.test.ts +26 -4
  81. package/src/__tests__/telegram-bot-username-resolution.test.ts +212 -0
  82. package/src/__tests__/telegram-invite-adapter.test.ts +164 -0
  83. package/src/__tests__/tool-execution-pipeline.benchmark.test.ts +1 -1
  84. package/src/__tests__/tool-permission-simulate-handler.test.ts +8 -2
  85. package/src/__tests__/trusted-contact-approval-notifier.test.ts +9 -1
  86. package/src/__tests__/twitter-auth-handler.test.ts +2 -2
  87. package/src/__tests__/twitter-oauth-client.test.ts +1 -1
  88. package/src/__tests__/usage-routes.test.ts +339 -0
  89. package/src/__tests__/whatsapp-invite-adapter.test.ts +94 -0
  90. package/src/agent/loop.ts +3 -0
  91. package/src/amazon/checkout.ts +0 -1
  92. package/src/approvals/guardian-request-resolvers.ts +9 -1
  93. package/src/bundler/app-bundler.ts +28 -12
  94. package/src/bundler/bundle-scanner.ts +1 -1
  95. package/src/bundler/bundle-signer.ts +3 -3
  96. package/src/bundler/manifest.ts +1 -1
  97. package/src/bundler/signature-verifier.ts +3 -3
  98. package/src/channels/config.ts +1 -1
  99. package/src/cli/AGENTS.md +63 -0
  100. package/src/cli/__tests__/notifications.test.ts +470 -0
  101. package/src/cli/amazon.ts +344 -167
  102. package/src/cli/audit.ts +85 -0
  103. package/src/cli/autonomy.ts +369 -0
  104. package/src/cli/channels.ts +51 -0
  105. package/src/cli/completions.ts +208 -0
  106. package/src/cli/config.ts +220 -0
  107. package/src/cli/contacts.ts +471 -0
  108. package/src/cli/credentials.ts +564 -0
  109. package/src/cli/default-action.ts +14 -0
  110. package/src/cli/dev.ts +131 -0
  111. package/src/cli/doctor.ts +398 -0
  112. package/src/cli/email.ts +494 -0
  113. package/src/cli/influencer.ts +72 -0
  114. package/src/cli/integrations.ts +248 -57
  115. package/src/cli/keys.ts +114 -0
  116. package/src/cli/map.ts +46 -54
  117. package/src/cli/mcp.ts +111 -3
  118. package/src/cli/{config-commands.ts → memory.ts} +134 -245
  119. package/src/cli/notifications.ts +407 -0
  120. package/src/cli/program.ts +65 -0
  121. package/src/cli/reference.ts +48 -0
  122. package/src/cli/sequence.ts +154 -0
  123. package/src/cli/sessions.ts +262 -0
  124. package/src/cli/trust.ts +175 -0
  125. package/src/cli/twitter.ts +323 -106
  126. package/src/config/__tests__/build-cli-reference-section.test.ts +49 -0
  127. package/src/config/bundled-skills/amazon/SKILL.md +2 -2
  128. package/src/config/bundled-skills/app-builder/TOOLS.json +26 -0
  129. package/src/config/bundled-skills/app-builder/tools/app-generate-icon.ts +13 -0
  130. package/src/config/bundled-skills/contacts/SKILL.md +178 -10
  131. package/src/config/bundled-skills/doordash/doordash-cli.ts +23 -168
  132. package/src/config/bundled-skills/google-oauth-setup/SKILL.md +135 -34
  133. package/src/config/bundled-skills/messaging/tools/shared.ts +4 -1
  134. package/src/config/bundled-skills/twilio-setup/SKILL.md +70 -17
  135. package/src/config/bundled-tool-registry.ts +2 -0
  136. package/src/config/core-schema.ts +7 -0
  137. package/src/config/feature-flag-registry.json +16 -0
  138. package/src/config/loader.ts +26 -0
  139. package/src/config/schema.ts +4 -0
  140. package/src/config/skill-state.ts +0 -13
  141. package/src/config/system-prompt.ts +27 -0
  142. package/src/contacts/contact-store.ts +25 -0
  143. package/src/daemon/computer-use-session.ts +1 -1
  144. package/src/daemon/handlers/apps.ts +1 -0
  145. package/src/daemon/handlers/config-channels.ts +3 -3
  146. package/src/daemon/handlers/config-dispatch.ts +29 -0
  147. package/src/daemon/handlers/config-inbox.ts +4 -3
  148. package/src/daemon/handlers/config.ts +3 -43
  149. package/src/daemon/handlers/contacts.ts +34 -0
  150. package/src/daemon/handlers/index.ts +17 -3
  151. package/src/daemon/handlers/session-user-message.ts +7 -0
  152. package/src/daemon/handlers/sessions.ts +21 -2
  153. package/src/daemon/handlers/shared.ts +17 -0
  154. package/src/daemon/ipc-contract/apps.ts +2 -0
  155. package/src/daemon/ipc-contract/computer-use.ts +9 -0
  156. package/src/daemon/ipc-contract/contacts.ts +3 -3
  157. package/src/daemon/ipc-contract/inbox.ts +2 -0
  158. package/src/daemon/ipc-contract/messages.ts +4 -0
  159. package/src/daemon/ipc-contract/sessions.ts +8 -0
  160. package/src/daemon/ipc-contract-inventory.json +1 -0
  161. package/src/daemon/lifecycle.ts +0 -5
  162. package/src/daemon/ride-shotgun-handler.ts +139 -25
  163. package/src/daemon/session-agent-loop-handlers.ts +100 -0
  164. package/src/daemon/session-agent-loop.ts +72 -0
  165. package/src/daemon/session-tool-setup.ts +7 -0
  166. package/src/daemon/session.ts +23 -1
  167. package/src/daemon/tool-side-effects.ts +39 -1
  168. package/src/email/service.ts +59 -2
  169. package/src/index.ts +2 -60
  170. package/src/mcp/mcp-oauth-provider.ts +90 -8
  171. package/src/media/app-icon-generator.ts +86 -0
  172. package/src/memory/db-init.ts +11 -0
  173. package/src/memory/llm-usage-store.ts +186 -0
  174. package/src/memory/migrations/137-usage-dashboard-indexes.ts +26 -0
  175. package/src/memory/migrations/139-drop-usage-composite-indexes.ts +30 -0
  176. package/src/memory/migrations/index.ts +2 -0
  177. package/src/memory/schema-migration.ts +1 -0
  178. package/src/memory/shared-app-links-store.ts +1 -1
  179. package/src/messaging/registry.ts +27 -0
  180. package/src/notifications/README.md +79 -70
  181. package/src/notifications/broadcaster.ts +2 -1
  182. package/src/notifications/conversation-pairing.ts +147 -13
  183. package/src/notifications/copy-composer.ts +7 -3
  184. package/src/notifications/destination-resolver.ts +14 -1
  185. package/src/notifications/emit-signal.ts +3 -2
  186. package/src/notifications/signal.ts +105 -1
  187. package/src/notifications/types.ts +16 -0
  188. package/src/permissions/checker.ts +29 -3
  189. package/src/permissions/prompter.ts +11 -3
  190. package/src/runtime/access-request-helper.ts +2 -1
  191. package/src/runtime/auth/route-policy.ts +7 -1
  192. package/src/runtime/channel-invite-transport.ts +40 -63
  193. package/src/runtime/channel-invite-transports/email.ts +13 -39
  194. package/src/runtime/channel-invite-transports/slack.ts +5 -34
  195. package/src/runtime/channel-invite-transports/sms.ts +8 -29
  196. package/src/runtime/channel-invite-transports/telegram.ts +69 -28
  197. package/src/runtime/channel-invite-transports/voice.ts +0 -7
  198. package/src/runtime/channel-invite-transports/whatsapp.ts +43 -0
  199. package/src/runtime/channel-readiness-service.ts +202 -45
  200. package/src/runtime/confirmation-request-guardian-bridge.ts +2 -1
  201. package/src/runtime/guardian-outbound-actions.ts +8 -5
  202. package/src/runtime/http-server.ts +2 -0
  203. package/src/runtime/invite-instruction-generator.ts +178 -0
  204. package/src/runtime/invite-service.ts +22 -25
  205. package/src/runtime/migrations/migration-transport.ts +13 -0
  206. package/src/runtime/routes/app-routes.ts +1 -1
  207. package/src/runtime/routes/approval-strategies/guardian-callback-strategy.ts +8 -7
  208. package/src/runtime/routes/channel-readiness-routes.ts +30 -11
  209. package/src/runtime/routes/contact-routes.ts +54 -26
  210. package/src/runtime/routes/inbound-stages/bootstrap-intercept.ts +1 -1
  211. package/src/runtime/routes/inbound-stages/escalation-intercept.ts +2 -1
  212. package/src/runtime/routes/inbound-stages/verification-intercept.ts +2 -1
  213. package/src/runtime/routes/integration-routes.ts +1 -1
  214. package/src/runtime/routes/invite-routes.ts +1 -1
  215. package/src/runtime/routes/secret-routes.ts +31 -7
  216. package/src/runtime/routes/twilio-routes.ts +32 -1
  217. package/src/runtime/routes/usage-routes.ts +114 -0
  218. package/src/runtime/tool-grant-request-helper.ts +2 -1
  219. package/src/security/encrypted-store.ts +9 -5
  220. package/src/security/keychain-broker-client.ts +393 -0
  221. package/src/security/secure-keys.ts +106 -321
  222. package/src/tools/apps/executors.ts +73 -0
  223. package/src/tools/browser/auto-navigate.ts +15 -6
  224. package/src/tools/browser/chrome-cdp.ts +211 -0
  225. package/src/tools/browser/network-recorder.test.ts +83 -0
  226. package/src/tools/browser/network-recorder.ts +8 -7
  227. package/src/tools/browser/x-auto-navigate.ts +12 -6
  228. package/src/tools/credentials/policy-types.ts +24 -0
  229. package/src/tools/credentials/vault.ts +22 -27
  230. package/src/tools/network/script-proxy/session-manager.ts +47 -3
  231. package/src/tools/permission-checker.ts +1 -0
  232. package/src/tools/types.ts +2 -0
  233. package/src/tools/ui-surface/definitions.ts +1 -2
  234. package/src/tools/watch/watch-state.ts +2 -0
  235. package/src/__tests__/key-migration.test.ts +0 -240
  236. package/src/__tests__/keychain.test.ts +0 -286
  237. package/src/cli/core-commands.ts +0 -899
  238. package/src/security/keychain-to-encrypted-migration.ts +0 -66
  239. package/src/security/keychain.ts +0 -490
@@ -79,18 +79,18 @@ The broadcaster (`broadcaster.ts`) iterates over selected channels (vellum first
79
79
 
80
80
  Each policy defines:
81
81
 
82
- | Field | Type | Description |
83
- |-------|------|-------------|
84
- | `notification.deliveryEnabled` | `boolean` | Whether the channel can receive notification deliveries |
82
+ | Field | Type | Description |
83
+ | ----------------------------------- | ---------------------- | ----------------------------------------------------------------- |
84
+ | `notification.deliveryEnabled` | `boolean` | Whether the channel can receive notification deliveries |
85
85
  | `notification.conversationStrategy` | `ConversationStrategy` | How conversations are materialized for deliveries on this channel |
86
86
 
87
87
  ### Conversation Strategy Types
88
88
 
89
- | Strategy | Behavior | Used by |
90
- |----------|----------|---------|
91
- | `start_new_conversation` | Creates a fresh conversation per delivery. The thread is surfaced via IPC. | `vellum` |
92
- | `continue_existing_conversation` | Appends to an existing channel-scoped conversation (future: lookup by binding key). Currently materializes a background audit conversation per delivery and records the intended strategy. | `telegram`, `sms`, `whatsapp`, `slack`, `email` |
93
- | `not_deliverable` | Channel cannot receive notifications. Pairing returns null IDs. | `voice` |
89
+ | Strategy | Behavior | Used by |
90
+ | -------------------------------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | ----------------------------------------------- |
91
+ | `start_new_conversation` | Creates a fresh conversation per delivery. The thread is surfaced via IPC. | `vellum` |
92
+ | `continue_existing_conversation` | Looks up a previously bound conversation by binding key (sourceChannel + externalChatId) and appends to it. When no bound conversation exists (first delivery to a destination), creates a new one and upserts the binding for future reuse. | `telegram`, `sms`, `whatsapp`, `slack`, `email` |
93
+ | `not_deliverable` | Channel cannot receive notifications. Pairing returns null IDs. | `voice` |
94
94
 
95
95
  ### Helper Functions
96
96
 
@@ -122,7 +122,7 @@ When the decision engine selects `reuse_existing` for a channel with a valid can
122
122
  ### New Thread Path (`start_new` / default)
123
123
 
124
124
  - **`start_new_conversation`**: Creates a new conversation with `threadType: 'standard'` and `source: 'notification'`, plus an assistant message containing the thread seed. Memory indexing is skipped on the seed message to prevent notification copy from polluting conversational recall. The result has `createdNewConversation: true`.
125
- - **`continue_existing_conversation`**: Currently materializes a background audit conversation per delivery (true continuation via binding key lookup is planned for a future PR). The audit trail records the intended strategy without adding visible sidebar threads.
125
+ - **`continue_existing_conversation`**: Looks up a previously bound conversation by binding key (`sourceChannel` + `externalChatId` via `getBindingByChannelChat()`). When a valid bound conversation with `source: 'notification'` exists, the seed message is appended to it and the binding timestamp is refreshed. When no binding exists or the bound conversation is stale/invalid, a new conversation is created and the binding is upserted for future reuse. The result has `createdNewConversation: false` on reuse, `true` on fresh creation.
126
126
  - **`not_deliverable`**: Returns `{ conversationId: null, messageId: null }`.
127
127
 
128
128
  The pairing function is resilient -- errors are caught and logged. A pairing failure never breaks the delivery pipeline.
@@ -131,11 +131,11 @@ The pairing function is resilient -- errors are caught and logged. A pairing fai
131
131
 
132
132
  The system produces **three distinct copy outputs** per notification:
133
133
 
134
- | Output | Purpose | Verbosity |
135
- |--------|---------|-----------|
136
- | `title` + `body` | Native notification popup (macOS/iOS banner) | Short and glanceable |
137
- | `deliveryText` | Channel-native chat message text (Telegram) | Natural chat phrasing |
138
- | Thread seed message | Opening message in the notification thread | Richer and context-aware |
134
+ | Output | Purpose | Verbosity |
135
+ | ------------------- | -------------------------------------------- | ------------------------ |
136
+ | `title` + `body` | Native notification popup (macOS/iOS banner) | Short and glanceable |
137
+ | `deliveryText` | Channel-native chat message text (Telegram) | Natural chat phrasing |
138
+ | Thread seed message | Opening message in the notification thread | Richer and context-aware |
139
139
 
140
140
  ### How It Works
141
141
 
@@ -151,12 +151,13 @@ The system produces **three distinct copy outputs** per notification:
151
151
 
152
152
  The thread seed composer adapts verbosity to the delivery surface:
153
153
 
154
- | Channel | Default Interface | Verbosity | Style |
155
- |---------|-------------------|-----------|-------|
156
- | `vellum` | `macos` | Rich | 2-4 short sentences with context and next step |
157
- | `telegram` | `telegram` | Compact | 1-2 concise sentences |
154
+ | Channel | Default Interface | Verbosity | Style |
155
+ | ---------- | ----------------- | --------- | ---------------------------------------------- |
156
+ | `vellum` | `macos` | Rich | 2-4 short sentences with context and next step |
157
+ | `telegram` | `telegram` | Compact | 1-2 concise sentences |
158
158
 
159
159
  Interface inference strategy:
160
+
160
161
  1. Explicit `interfaceHint` in the signal's `contextPayload` (if valid `InterfaceId`).
161
162
  2. `sourceInterface` from the originating conversation (if valid `InterfaceId`).
162
163
  3. Channel default mapping (`vellum` → `macos` → rich, `telegram` → `telegram` → compact).
@@ -164,17 +165,20 @@ Interface inference strategy:
164
165
  ### Example: Reminder Notification
165
166
 
166
167
  **Native popup (vellum/macos):**
168
+
167
169
  ```
168
170
  Title: Reminder
169
171
  Body: Take out the trash
170
172
  ```
171
173
 
172
174
  **Telegram chat delivery (`deliveryText`):**
175
+
173
176
  ```
174
177
  Take out the trash
175
178
  ```
176
179
 
177
180
  **Thread seed on vellum/macos (rich):**
181
+
178
182
  ```
179
183
  Reminder. Take out the trash. Action required.
180
184
  ```
@@ -189,7 +193,10 @@ This is enforced in `broadcaster.ts` by gating the IPC emission on `pairing.crea
189
193
  // Emit notification_thread_created only when a NEW conversation was
190
194
  // actually created. Reusing an existing thread should not fire the IPC
191
195
  // event — the client already knows about the conversation.
192
- if (pairing.createdNewConversation && pairing.strategy === 'start_new_conversation') {
196
+ if (
197
+ pairing.createdNewConversation &&
198
+ pairing.strategy === "start_new_conversation"
199
+ ) {
193
200
  // ... emit IPC event
194
201
  }
195
202
  ```
@@ -226,11 +233,11 @@ Reminders carry optional routing metadata that controls how notifications fan ou
226
233
 
227
234
  The `routing_intent` field on each reminder row specifies the desired channel coverage:
228
235
 
229
- | Intent | Behavior | When to use |
230
- |--------|----------|-------------|
231
- | `single_channel` | Default LLM-driven routing (no override) | Standard reminders where the decision engine picks the best channel |
232
- | `multi_channel` | Ensures delivery on 2+ channels when 2+ are connected | Important reminders the user wants on both desktop and phone |
233
- | `all_channels` | Forces delivery on every connected channel | Critical reminders that must reach the user everywhere |
236
+ | Intent | Behavior | When to use |
237
+ | ---------------- | ----------------------------------------------------- | ------------------------------------------------------------------- |
238
+ | `single_channel` | Default LLM-driven routing (no override) | Standard reminders where the decision engine picks the best channel |
239
+ | `multi_channel` | Ensures delivery on 2+ channels when 2+ are connected | Important reminders the user wants on both desktop and phone |
240
+ | `all_channels` | Forces delivery on every connected channel | Critical reminders that must reach the user everywhere |
234
241
 
235
242
  The default is `single_channel`, preserving backward compatibility. Routing intent is persisted in the `reminders` table (`routing_intent` column) and carried through the notification signal as `routingIntent`.
236
243
 
@@ -342,10 +349,10 @@ When the decision is persisted, a `threadActions` summary is included in `valida
342
349
 
343
350
  Three columns on `notification_deliveries` record the per-channel thread decision:
344
351
 
345
- | Column | Type | Description |
346
- |--------|------|-------------|
347
- | `thread_action` | TEXT | `'start_new'` or `'reuse_existing'` — what the model decided |
348
- | `thread_target_conversation_id` | TEXT | The candidate `conversationId` when action is `reuse_existing` |
352
+ | Column | Type | Description |
353
+ | ------------------------------- | ------- | ----------------------------------------------------------------------------------------------------------- |
354
+ | `thread_action` | TEXT | `'start_new'` or `'reuse_existing'` — what the model decided |
355
+ | `thread_target_conversation_id` | TEXT | The candidate `conversationId` when action is `reuse_existing` |
349
356
  | `thread_decision_fallback_used` | INTEGER | `1` if `reuse_existing` was attempted but the target was invalid, so a new conversation was created instead |
350
357
 
351
358
  ### Query Examples
@@ -398,38 +405,38 @@ All three paths use the same pattern: look up pending deliveries by conversation
398
405
 
399
406
  All disambiguation messages are generated through `composeGuardianActionMessageGenerative()` in `guardian-action-message-composer.ts`, which uses a 2-tier priority chain (LLM generator with deterministic fallback). Three disambiguation scenarios exist:
400
407
 
401
- | Scenario | When triggered |
402
- |----------|---------------|
403
- | `guardian_disambiguation` | Multiple pending approval requests in a thread |
404
- | `guardian_expired_disambiguation` | Multiple expired requests with late replies |
408
+ | Scenario | When triggered |
409
+ | ---------------------------------- | ------------------------------------------------------ |
410
+ | `guardian_disambiguation` | Multiple pending approval requests in a thread |
411
+ | `guardian_expired_disambiguation` | Multiple expired requests with late replies |
405
412
  | `guardian_followup_disambiguation` | Multiple follow-up deliveries awaiting guardian action |
406
413
 
407
414
  ## Key Files
408
415
 
409
- | File | Purpose |
410
- |------|---------|
411
- | `../channels/config.ts` | Channel policy registry -- single source of truth for per-channel notification behavior |
412
- | `emit-signal.ts` | Single entry point for producers; orchestrates the full pipeline |
413
- | `signal.ts` | `NotificationSignal` and `AttentionHints` type definitions |
414
- | `types.ts` | Channel adapter interfaces, delivery types, decision output contract, `ThreadAction` union |
415
- | `thread-candidates.ts` | Builds per-channel candidate set of recent notification conversations for the decision engine |
416
- | `conversation-pairing.ts` | Materializes conversation + message per delivery based on channel strategy |
417
- | `decision-engine.ts` | LLM-based routing with forced tool_choice; deterministic fallback |
418
- | `deterministic-checks.ts` | Pre-send gate checks (dedupe, source-active, channel availability) |
419
- | `runtime-dispatch.ts` | Dispatch gating (no-op decisions, empty channels) |
420
- | `broadcaster.ts` | Fan-out to channel adapters with delivery audit trail; emits `notification_thread_created` IPC |
421
- | `copy-composer.ts` | Template-based fallback notification copy when LLM copy is unavailable |
422
- | `thread-seed-composer.ts` | Surface-aware thread seed generation (richer than notification copy) |
423
- | `destination-resolver.ts` | Resolves per-channel endpoints (vellum IPC, Telegram chat ID) |
424
- | `adapters/macos.ts` | Vellum adapter -- broadcasts `notification_intent` via IPC with deep-link metadata |
425
- | `adapters/telegram.ts` | Telegram adapter -- POSTs to gateway `/deliver/telegram` |
426
- | `adapters/sms.ts` | SMS adapter -- POSTs to gateway `/deliver/sms` via Twilio Messages API |
427
- | `preference-extractor.ts` | Detects notification preferences in conversation messages |
428
- | `preference-summary.ts` | Builds preference context string for the decision engine prompt |
429
- | `preferences-store.ts` | CRUD for `notification_preferences` table |
430
- | `events-store.ts` | CRUD for `notification_events` table |
431
- | `decisions-store.ts` | CRUD for `notification_decisions` table |
432
- | `deliveries-store.ts` | CRUD for `notification_deliveries` table |
416
+ | File | Purpose |
417
+ | ------------------------- | ---------------------------------------------------------------------------------------------- |
418
+ | `../channels/config.ts` | Channel policy registry -- single source of truth for per-channel notification behavior |
419
+ | `emit-signal.ts` | Single entry point for producers; orchestrates the full pipeline |
420
+ | `signal.ts` | `NotificationSignal` and `AttentionHints` type definitions |
421
+ | `types.ts` | Channel adapter interfaces, delivery types, decision output contract, `ThreadAction` union |
422
+ | `thread-candidates.ts` | Builds per-channel candidate set of recent notification conversations for the decision engine |
423
+ | `conversation-pairing.ts` | Materializes conversation + message per delivery based on channel strategy |
424
+ | `decision-engine.ts` | LLM-based routing with forced tool_choice; deterministic fallback |
425
+ | `deterministic-checks.ts` | Pre-send gate checks (dedupe, source-active, channel availability) |
426
+ | `runtime-dispatch.ts` | Dispatch gating (no-op decisions, empty channels) |
427
+ | `broadcaster.ts` | Fan-out to channel adapters with delivery audit trail; emits `notification_thread_created` IPC |
428
+ | `copy-composer.ts` | Template-based fallback notification copy when LLM copy is unavailable |
429
+ | `thread-seed-composer.ts` | Surface-aware thread seed generation (richer than notification copy) |
430
+ | `destination-resolver.ts` | Resolves per-channel endpoints (vellum IPC, Telegram chat ID) |
431
+ | `adapters/macos.ts` | Vellum adapter -- broadcasts `notification_intent` via IPC with deep-link metadata |
432
+ | `adapters/telegram.ts` | Telegram adapter -- POSTs to gateway `/deliver/telegram` |
433
+ | `adapters/sms.ts` | SMS adapter -- POSTs to gateway `/deliver/sms` via Twilio Messages API |
434
+ | `preference-extractor.ts` | Detects notification preferences in conversation messages |
435
+ | `preference-summary.ts` | Builds preference context string for the decision engine prompt |
436
+ | `preferences-store.ts` | CRUD for `notification_preferences` table |
437
+ | `events-store.ts` | CRUD for `notification_events` table |
438
+ | `decisions-store.ts` | CRUD for `notification_decisions` table |
439
+ | `deliveries-store.ts` | CRUD for `notification_deliveries` table |
433
440
 
434
441
  ## How to Add a New Notification Producer
435
442
 
@@ -437,22 +444,24 @@ All disambiguation messages are generated through `composeGuardianActionMessageG
437
444
  2. Call it with the signal parameters:
438
445
 
439
446
  ```ts
440
- import { emitNotificationSignal } from '../notifications/emit-signal.js';
447
+ import { emitNotificationSignal } from "../notifications/emit-signal.js";
441
448
 
442
449
  await emitNotificationSignal({
443
- sourceEventName: 'your_event_name',
444
- sourceChannel: 'scheduler', // where the event originated
450
+ sourceEventName: "your_event_name",
451
+ sourceChannel: "scheduler", // where the event originated
445
452
  sourceSessionId: sessionId,
446
453
  attentionHints: {
447
454
  requiresAction: true,
448
- urgency: 'high',
455
+ urgency: "high",
449
456
  isAsyncBackground: false,
450
457
  visibleInSourceNow: false,
451
458
  },
452
- contextPayload: { /* arbitrary data for the decision engine */ },
459
+ contextPayload: {
460
+ /* arbitrary data for the decision engine */
461
+ },
453
462
  // Optional: control multi-channel fanout behavior
454
- routingIntent: 'multi_channel', // 'single_channel' | 'multi_channel' | 'all_channels'
455
- routingHints: { preferredChannels: ['telegram', 'sms'] },
463
+ routingIntent: "multi_channel", // 'single_channel' | 'multi_channel' | 'all_channels'
464
+ routingHints: { preferredChannels: ["telegram", "sms"] },
456
465
  });
457
466
  ```
458
467
 
@@ -483,11 +492,11 @@ For vellum (macOS/iOS) deliveries, the audit trail now extends past the IPC broa
483
492
 
484
493
  The ack populates three columns on `notification_deliveries`:
485
494
 
486
- | Column | Type | Description |
487
- |--------|------|-------------|
488
- | `client_delivery_status` | TEXT | `'delivered'` if the OS accepted the notification, `'client_failed'` otherwise |
489
- | `client_delivery_error` | TEXT | Error description when the post failed (e.g. authorization denied) |
490
- | `client_delivery_at` | INTEGER | Epoch ms timestamp of when the client reported the outcome |
495
+ | Column | Type | Description |
496
+ | ------------------------ | ------- | ------------------------------------------------------------------------------ |
497
+ | `client_delivery_status` | TEXT | `'delivered'` if the OS accepted the notification, `'client_failed'` otherwise |
498
+ | `client_delivery_error` | TEXT | Error description when the post failed (e.g. authorization denied) |
499
+ | `client_delivery_at` | INTEGER | Epoch ms timestamp of when the client reported the outcome |
491
500
 
492
501
  This means the audit trail can now answer three questions for each vellum delivery:
493
502
 
@@ -539,8 +548,8 @@ Preferences are sanitized against prompt injection (angle brackets replaced with
539
548
 
540
549
  All settings live under the `notifications` key in `config.json`:
541
550
 
542
- | Key | Type | Default | Description |
543
- |-----|------|---------|-------------|
551
+ | Key | Type | Default | Description |
552
+ | ----------------------------------- | ------ | --------------------- | ------------------------------------------------------------------------ |
544
553
  | `notifications.decisionModelIntent` | string | `"latency-optimized"` | Model intent used for both the decision engine and preference extraction |
545
554
 
546
555
  The notification pipeline is always active -- signals are processed and dispatched as soon as the daemon is running. The audit trail (events, decisions, deliveries) is written for every signal.
@@ -185,11 +185,12 @@ export class NotificationBroadcaster {
185
185
  }
186
186
 
187
187
  // Pair the delivery with a conversation before sending, passing the thread action
188
+ // and destination binding context for channel-scoped continuation
188
189
  const pairing = await pairDeliveryWithConversation(
189
190
  signal,
190
191
  channel,
191
192
  copy,
192
- { threadAction },
193
+ { threadAction, bindingContext: destination.bindingContext },
193
194
  );
194
195
 
195
196
  // For the vellum channel, merge the conversationId into deep-link metadata
@@ -6,9 +6,12 @@
6
6
  * auditable conversation trail and enables the macOS/iOS client to
7
7
  * deep-link directly into the notification thread.
8
8
  *
9
- * When the decision engine selects `reuse_existing` for a channel and
10
- * the target conversation is valid, the seed message is appended to the
11
- * existing thread instead of creating a new one.
9
+ * Resolution order:
10
+ * 1. Explicit `reuse_existing` thread action highest precedence.
11
+ * 2. Binding-key reuse for `continue_existing_conversation` channels,
12
+ * looks up a previously bound conversation by (sourceChannel, externalChatId).
13
+ * 3. Default — creates a fresh conversation and, when binding context is
14
+ * present, upserts it into the external-conversation store for future reuse.
12
15
  */
13
16
 
14
17
  import type { ConversationStrategy } from "../channels/config.js";
@@ -19,14 +22,34 @@ import {
19
22
  createConversation,
20
23
  getConversation,
21
24
  } from "../memory/conversation-store.js";
25
+ import {
26
+ getBindingByChannelChat,
27
+ upsertOutboundBinding,
28
+ } from "../memory/external-conversation-store.js";
22
29
  import { getLogger } from "../util/logger.js";
23
30
  import type { NotificationSignal } from "./signal.js";
24
31
  import { composeThreadSeed, isThreadSeedSane } from "./thread-seed-composer.js";
25
- import type { NotificationChannel, ThreadAction } from "./types.js";
32
+ import type {
33
+ DestinationBindingContext,
34
+ NotificationChannel,
35
+ ThreadAction,
36
+ } from "./types.js";
26
37
  import type { RenderedChannelCopy } from "./types.js";
27
38
 
28
39
  const log = getLogger("notification-conversation-pairing");
29
40
 
41
+ /**
42
+ * Prefix applied to sourceChannel values in notification bindings so they
43
+ * occupy a separate namespace from messaging adapter bindings in the
44
+ * external_conversation_bindings table. Without this, notification pairing
45
+ * and messaging adapters (Telegram, SMS, etc.) would destructively overwrite
46
+ * each other's bindings since both use (sourceChannel, externalChatId) as key.
47
+ */
48
+ const NOTIFICATION_CHANNEL_PREFIX = "notification:";
49
+ function notificationChannel(sourceChannel: string): string {
50
+ return `${NOTIFICATION_CHANNEL_PREFIX}${sourceChannel}`;
51
+ }
52
+
30
53
  export interface PairingResult {
31
54
  conversationId: string | null;
32
55
  messageId: string | null;
@@ -40,6 +63,8 @@ export interface PairingResult {
40
63
  export interface PairingOptions {
41
64
  /** Per-channel thread action from the decision engine. */
42
65
  threadAction?: ThreadAction;
66
+ /** Destination binding data for channel-scoped conversation continuation. */
67
+ bindingContext?: DestinationBindingContext;
43
68
  }
44
69
 
45
70
  /**
@@ -48,11 +73,13 @@ export interface PairingOptions {
48
73
  * Looks up the channel's conversation strategy from the policy registry
49
74
  * and materializes a conversation + assistant message accordingly.
50
75
  *
51
- * When `options.threadAction` is `reuse_existing`, the function attempts
52
- * to look up the target conversation. If it exists and has the right source,
53
- * the seed message is appended to it. If the target is invalid or stale,
54
- * a new conversation is created instead (with `threadDecisionFallbackUsed`
55
- * set to true on the result).
76
+ * Resolution precedence:
77
+ * 1. `options.threadAction === "reuse_existing"` reuse the explicit target.
78
+ * 2. `continue_existing_conversation` strategy with binding context
79
+ * look up a previously bound conversation by (sourceChannel, externalChatId).
80
+ * 3. Create a new conversation (and upsert the binding when context is present).
81
+ *
82
+ * Invalid/stale targets at any level fall through to the next.
56
83
  *
57
84
  * Errors are caught and logged — this function never throws so the
58
85
  * notification pipeline is not disrupted by pairing failures.
@@ -78,10 +105,9 @@ export async function pairDeliveryWithConversation(
78
105
 
79
106
  const title = copy.threadTitle ?? copy.title ?? signal.sourceEventName;
80
107
 
81
- // Only start_new_conversation threads should be user-visible. For channels
82
- // that intend to continue an existing external conversation (e.g. Telegram),
83
- // we still materialize an auditable row but keep it background-only until
84
- // true continuation-by-key is implemented.
108
+ // Only start_new_conversation threads should be user-visible in the sidebar.
109
+ // Channels with continue_existing_conversation reuse bound external threads
110
+ // and mark them as background so they don't clutter the sidebar UI.
85
111
  const threadType =
86
112
  strategy === "start_new_conversation" ? "standard" : "background";
87
113
 
@@ -93,6 +119,7 @@ export async function pairDeliveryWithConversation(
93
119
  : composeThreadSeed(signal, channel, copy);
94
120
 
95
121
  const threadAction = options?.threadAction;
122
+ const bindingContext = options?.bindingContext;
96
123
 
97
124
  // Attempt to reuse an existing conversation when the model requests it
98
125
  if (threadAction?.action === "reuse_existing") {
@@ -109,6 +136,16 @@ export async function pairDeliveryWithConversation(
109
136
  { skipIndexing: true },
110
137
  );
111
138
 
139
+ // Rebind the destination so subsequent deliveries to the same
140
+ // (sourceChannel, externalChatId) resolve to this conversation.
141
+ if (bindingContext?.sourceChannel && bindingContext?.externalChatId) {
142
+ upsertOutboundBinding({
143
+ conversationId: existing.id,
144
+ sourceChannel: notificationChannel(bindingContext.sourceChannel),
145
+ externalChatId: bindingContext.externalChatId,
146
+ });
147
+ }
148
+
112
149
  log.info(
113
150
  {
114
151
  signalId: signal.signalId,
@@ -156,6 +193,16 @@ export async function pairDeliveryWithConversation(
156
193
  { skipIndexing: true },
157
194
  );
158
195
 
196
+ // Bind the new conversation to the destination so subsequent
197
+ // deliveries reuse it instead of creating yet another conversation.
198
+ if (bindingContext?.sourceChannel && bindingContext?.externalChatId) {
199
+ upsertOutboundBinding({
200
+ conversationId: conversation.id,
201
+ sourceChannel: notificationChannel(bindingContext.sourceChannel),
202
+ externalChatId: bindingContext.externalChatId,
203
+ });
204
+ }
205
+
159
206
  return {
160
207
  conversationId: conversation.id,
161
208
  messageId: message.id,
@@ -165,6 +212,83 @@ export async function pairDeliveryWithConversation(
165
212
  };
166
213
  }
167
214
 
215
+ // For channels with continue_existing_conversation strategy, try to
216
+ // reuse a previously bound conversation keyed by (sourceChannel, externalChatId)
217
+ // before falling through to create a new one.
218
+ if (
219
+ strategy === "continue_existing_conversation" &&
220
+ bindingContext?.sourceChannel &&
221
+ bindingContext?.externalChatId
222
+ ) {
223
+ // Look up by namespaced key first; fall back to pre-namespace key for
224
+ // bindings created before the notification: prefix was introduced.
225
+ const existingBinding =
226
+ getBindingByChannelChat(
227
+ notificationChannel(bindingContext.sourceChannel),
228
+ bindingContext.externalChatId,
229
+ ) ??
230
+ getBindingByChannelChat(
231
+ bindingContext.sourceChannel,
232
+ bindingContext.externalChatId,
233
+ );
234
+
235
+ if (existingBinding) {
236
+ const boundConversation = getConversation(
237
+ existingBinding.conversationId,
238
+ );
239
+
240
+ if (boundConversation && boundConversation.source === "notification") {
241
+ const message = await addMessage(
242
+ boundConversation.id,
243
+ "assistant",
244
+ messageContent,
245
+ undefined,
246
+ { skipIndexing: true },
247
+ );
248
+
249
+ // Touch the outbound timestamp so the binding stays fresh.
250
+ upsertOutboundBinding({
251
+ conversationId: boundConversation.id,
252
+ sourceChannel: notificationChannel(bindingContext.sourceChannel),
253
+ externalChatId: bindingContext.externalChatId,
254
+ });
255
+
256
+ log.info(
257
+ {
258
+ signalId: signal.signalId,
259
+ channel,
260
+ strategy,
261
+ conversationId: boundConversation.id,
262
+ messageId: message.id,
263
+ bindingKey: `${bindingContext.sourceChannel}:${bindingContext.externalChatId}`,
264
+ },
265
+ "Reused bound conversation for channel destination",
266
+ );
267
+
268
+ return {
269
+ conversationId: boundConversation.id,
270
+ messageId: message.id,
271
+ strategy,
272
+ createdNewConversation: false,
273
+ threadDecisionFallbackUsed: false,
274
+ };
275
+ }
276
+
277
+ // Binding exists but conversation is stale or wrong source — fall through
278
+ // to create a new one and re-bind below.
279
+ log.warn(
280
+ {
281
+ signalId: signal.signalId,
282
+ channel,
283
+ boundConversationId: existingBinding.conversationId,
284
+ boundConversationExists: !!boundConversation,
285
+ boundConversationSource: boundConversation?.source,
286
+ },
287
+ "Bound conversation stale or invalid — creating fresh conversation",
288
+ );
289
+ }
290
+ }
291
+
168
292
  // Default path: create a new conversation
169
293
  // Memory indexing is skipped on the seed message below to prevent
170
294
  // notification copy from polluting conversational recall.
@@ -184,6 +308,16 @@ export async function pairDeliveryWithConversation(
184
308
  { skipIndexing: true },
185
309
  );
186
310
 
311
+ // When binding context is available, record the new conversation so
312
+ // subsequent deliveries to the same destination reuse it.
313
+ if (bindingContext?.sourceChannel && bindingContext?.externalChatId) {
314
+ upsertOutboundBinding({
315
+ conversationId: conversation.id,
316
+ sourceChannel: notificationChannel(bindingContext.sourceChannel),
317
+ externalChatId: bindingContext.externalChatId,
318
+ });
319
+ }
320
+
187
321
  log.info(
188
322
  {
189
323
  signalId: signal.signalId,
@@ -13,7 +13,10 @@ import {
13
13
  buildGuardianRequestCodeInstruction,
14
14
  resolveGuardianQuestionInstructionMode,
15
15
  } from "./guardian-question-mode.js";
16
- import type { NotificationSignal } from "./signal.js";
16
+ import type {
17
+ NotificationSignal,
18
+ NotificationSourceEventName,
19
+ } from "./signal.js";
17
20
  import type { NotificationChannel, RenderedChannelCopy } from "./types.js";
18
21
 
19
22
  type CopyTemplate = (payload: Record<string, unknown>) => RenderedChannelCopy;
@@ -229,7 +232,7 @@ export function buildAccessRequestContractText(
229
232
  }
230
233
 
231
234
  // Templates keyed by dot-separated sourceEventName strings matching producers.
232
- const TEMPLATES: Record<string, CopyTemplate> = {
235
+ const TEMPLATES: Partial<Record<NotificationSourceEventName, CopyTemplate>> = {
233
236
  "reminder.fired": (payload) => ({
234
237
  title: "Reminder",
235
238
  body: str(payload.message, str(payload.label, "A reminder has fired")),
@@ -367,7 +370,8 @@ export function composeFallbackCopy(
367
370
  signal: NotificationSignal,
368
371
  channels: NotificationChannel[],
369
372
  ): Partial<Record<NotificationChannel, RenderedChannelCopy>> {
370
- const template = TEMPLATES[signal.sourceEventName];
373
+ const template =
374
+ TEMPLATES[signal.sourceEventName as NotificationSourceEventName];
371
375
 
372
376
  const baseCopy: RenderedChannelCopy = template
373
377
  ? template(signal.contextPayload)
@@ -66,12 +66,19 @@ export function resolveDestinations(
66
66
  case "sms": {
67
67
  const guardianResult = findGuardianForChannel(channel);
68
68
  if (guardianResult && guardianResult.channel.externalChatId) {
69
+ const externalChatId = guardianResult.channel.externalChatId;
69
70
  result.set(channel as NotificationChannel, {
70
71
  channel: channel as NotificationChannel,
71
- endpoint: guardianResult.channel.externalChatId ?? undefined,
72
+ endpoint: externalChatId,
72
73
  metadata: {
73
74
  externalUserId: guardianResult.channel.externalUserId,
74
75
  },
76
+ bindingContext: {
77
+ sourceChannel: channel as NotificationChannel,
78
+ externalChatId,
79
+ externalUserId:
80
+ guardianResult.channel.externalUserId ?? undefined,
81
+ },
75
82
  });
76
83
  }
77
84
  log.debug(
@@ -97,6 +104,12 @@ export function resolveDestinations(
97
104
  metadata: {
98
105
  externalUserId: guardianResult.channel.externalUserId,
99
106
  },
107
+ bindingContext: {
108
+ sourceChannel: "slack",
109
+ externalChatId: chatId,
110
+ externalUserId:
111
+ guardianResult.channel.externalUserId ?? undefined,
112
+ },
100
113
  });
101
114
  } else if (guardianResult && chatId) {
102
115
  log.warn(
@@ -34,6 +34,7 @@ import type {
34
34
  AttentionHints,
35
35
  NotificationContextPayload,
36
36
  NotificationSignal,
37
+ NotificationSourceChannel,
37
38
  RoutingIntent,
38
39
  } from "./signal.js";
39
40
  import type {
@@ -151,8 +152,8 @@ function getConnectedChannels(): NotificationChannel[] {
151
152
  export interface EmitSignalParams<TEventName extends string = string> {
152
153
  /** Free-form event name, e.g. 'reminder.fired', 'schedule.complete'. */
153
154
  sourceEventName: TEventName;
154
- /** Source channel that produced the event. */
155
- sourceChannel: string;
155
+ /** Source channel that produced the event — must be a registered channel. */
156
+ sourceChannel: NotificationSourceChannel;
156
157
  /** Session or conversation ID from the source context. */
157
158
  sourceSessionId: string;
158
159
  /** Attention hints for the decision engine. */