@vellumai/assistant 0.3.15 → 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 (290) hide show
  1. package/ARCHITECTURE.md +142 -0
  2. package/Dockerfile +1 -1
  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-outbound-http.test.ts +194 -2
  40. package/src/__tests__/guardian-verification-intent-routing.test.ts +179 -0
  41. package/src/__tests__/guardian-verify-setup-skill-regression.test.ts +141 -0
  42. package/src/__tests__/handlers-telegram-config.test.ts +6 -6
  43. package/src/__tests__/hooks-runner.test.ts +13 -4
  44. package/src/__tests__/ingress-routes-http.test.ts +443 -0
  45. package/src/__tests__/intent-routing.test.ts +14 -0
  46. package/src/__tests__/ipc-snapshot.test.ts +2 -5
  47. package/src/__tests__/media-reuse-story.e2e.test.ts +7 -7
  48. package/src/__tests__/memory-regressions.test.ts +16 -12
  49. package/src/__tests__/non-member-access-request.test.ts +282 -0
  50. package/src/__tests__/notification-decision-strategy.test.ts +136 -0
  51. package/src/__tests__/notification-routing-intent.test.ts +11 -1
  52. package/src/__tests__/notification-thread-candidates.test.ts +166 -0
  53. package/src/__tests__/recording-intent.test.ts +1 -0
  54. package/src/__tests__/recording-state-machine.test.ts +328 -17
  55. package/src/__tests__/registry.test.ts +17 -8
  56. package/src/__tests__/relay-server.test.ts +105 -0
  57. package/src/__tests__/reminder.test.ts +13 -0
  58. package/src/__tests__/runtime-attachment-metadata.test.ts +4 -4
  59. package/src/__tests__/scheduler-recurrence.test.ts +50 -0
  60. package/src/__tests__/server-history-render.test.ts +8 -8
  61. package/src/__tests__/session-agent-loop.test.ts +1 -0
  62. package/src/__tests__/session-runtime-assembly.test.ts +49 -0
  63. package/src/__tests__/session-skill-tools.test.ts +1 -0
  64. package/src/__tests__/skill-projection.benchmark.test.ts +11 -3
  65. package/src/__tests__/slack-channel-config.test.ts +230 -0
  66. package/src/__tests__/subagent-manager-notify.test.ts +4 -4
  67. package/src/__tests__/swarm-session-integration.test.ts +2 -2
  68. package/src/__tests__/system-prompt.test.ts +43 -0
  69. package/src/__tests__/task-management-tools.test.ts +3 -3
  70. package/src/__tests__/task-tools.test.ts +3 -3
  71. package/src/__tests__/trust-store.test.ts +17 -1
  72. package/src/__tests__/trusted-contact-lifecycle-notifications.test.ts +491 -0
  73. package/src/__tests__/trusted-contact-multichannel.test.ts +409 -0
  74. package/src/__tests__/trusted-contact-verification.test.ts +360 -0
  75. package/src/__tests__/update-bulletin-format.test.ts +119 -0
  76. package/src/__tests__/update-bulletin-state.test.ts +129 -0
  77. package/src/__tests__/update-bulletin.test.ts +260 -0
  78. package/src/__tests__/update-template-contract.test.ts +29 -0
  79. package/src/agent/loop.ts +2 -2
  80. package/src/amazon/client.ts +2 -3
  81. package/src/calls/call-controller.ts +115 -34
  82. package/src/calls/call-conversation-messages.ts +2 -2
  83. package/src/calls/call-domain.ts +10 -3
  84. package/src/calls/call-pointer-messages.ts +17 -5
  85. package/src/calls/guardian-action-sweep.ts +77 -36
  86. package/src/calls/relay-server.ts +51 -12
  87. package/src/calls/twilio-routes.ts +3 -1
  88. package/src/calls/types.ts +1 -1
  89. package/src/calls/voice-session-bridge.ts +4 -4
  90. package/src/cli/core-commands.ts +3 -3
  91. package/src/cli/map.ts +8 -5
  92. package/src/config/bundled-skills/phone-calls/SKILL.md +16 -1
  93. package/src/config/bundled-skills/tasks/SKILL.md +1 -1
  94. package/src/config/bundled-skills/tasks/TOOLS.json +4 -4
  95. package/src/config/bundled-skills/time-based-actions/SKILL.md +11 -1
  96. package/src/config/computer-use-prompt.ts +1 -0
  97. package/src/config/core-schema.ts +16 -0
  98. package/src/config/env-registry.ts +1 -0
  99. package/src/config/env.ts +16 -1
  100. package/src/config/memory-schema.ts +5 -0
  101. package/src/config/schema.ts +4 -0
  102. package/src/config/system-prompt.ts +69 -2
  103. package/src/config/templates/BOOTSTRAP.md +1 -1
  104. package/src/config/templates/IDENTITY.md +8 -4
  105. package/src/config/templates/SOUL.md +14 -0
  106. package/src/config/templates/UPDATES.md +16 -0
  107. package/src/config/templates/USER.md +5 -1
  108. package/src/config/types.ts +1 -0
  109. package/src/config/update-bulletin-format.ts +52 -0
  110. package/src/config/update-bulletin-state.ts +49 -0
  111. package/src/config/update-bulletin.ts +82 -0
  112. package/src/config/vellum-skills/catalog.json +6 -0
  113. package/src/config/vellum-skills/chatgpt-import/tools/chatgpt-import.ts +1 -1
  114. package/src/config/vellum-skills/guardian-verify-setup/SKILL.md +44 -10
  115. package/src/config/vellum-skills/telegram-setup/SKILL.md +4 -4
  116. package/src/config/vellum-skills/trusted-contacts/SKILL.md +147 -0
  117. package/src/config/vellum-skills/twilio-setup/SKILL.md +2 -2
  118. package/src/context/window-manager.ts +43 -3
  119. package/src/daemon/config-watcher.ts +1 -0
  120. package/src/daemon/connection-policy.ts +21 -1
  121. package/src/daemon/daemon-control.ts +164 -7
  122. package/src/daemon/date-context.ts +174 -1
  123. package/src/daemon/guardian-action-generators.ts +175 -0
  124. package/src/daemon/guardian-verification-intent.ts +120 -0
  125. package/src/daemon/handlers/apps.ts +1 -3
  126. package/src/daemon/handlers/config-channels.ts +2 -2
  127. package/src/daemon/handlers/config-heartbeat.ts +1 -1
  128. package/src/daemon/handlers/config-inbox.ts +55 -159
  129. package/src/daemon/handlers/config-ingress.ts +1 -1
  130. package/src/daemon/handlers/config-integrations.ts +1 -1
  131. package/src/daemon/handlers/config-platform.ts +1 -1
  132. package/src/daemon/handlers/config-scheduling.ts +2 -2
  133. package/src/daemon/handlers/config-slack-channel.ts +190 -0
  134. package/src/daemon/handlers/config-telegram.ts +1 -1
  135. package/src/daemon/handlers/config-twilio.ts +1 -1
  136. package/src/daemon/handlers/config-voice.ts +100 -0
  137. package/src/daemon/handlers/config.ts +3 -0
  138. package/src/daemon/handlers/misc.ts +83 -5
  139. package/src/daemon/handlers/navigate-settings.ts +27 -0
  140. package/src/daemon/handlers/recording.ts +270 -144
  141. package/src/daemon/handlers/sessions.ts +100 -17
  142. package/src/daemon/handlers/subagents.ts +3 -3
  143. package/src/daemon/handlers/work-items.ts +10 -7
  144. package/src/daemon/ipc-contract/integrations.ts +9 -1
  145. package/src/daemon/ipc-contract/messages.ts +4 -0
  146. package/src/daemon/ipc-contract/sessions.ts +1 -1
  147. package/src/daemon/ipc-contract/settings.ts +26 -0
  148. package/src/daemon/ipc-contract/shared.ts +2 -0
  149. package/src/daemon/ipc-contract/work-items.ts +1 -7
  150. package/src/daemon/ipc-contract-inventory.json +5 -1
  151. package/src/daemon/ipc-contract.ts +5 -1
  152. package/src/daemon/lifecycle.ts +306 -266
  153. package/src/daemon/recording-intent.ts +0 -41
  154. package/src/daemon/response-tier.ts +2 -2
  155. package/src/daemon/server.ts +6 -6
  156. package/src/daemon/session-agent-loop-handlers.ts +34 -9
  157. package/src/daemon/session-agent-loop.ts +15 -8
  158. package/src/daemon/session-history.ts +3 -2
  159. package/src/daemon/session-media-retry.ts +3 -0
  160. package/src/daemon/session-messaging.ts +38 -4
  161. package/src/daemon/session-notifiers.ts +2 -2
  162. package/src/daemon/session-process.ts +256 -23
  163. package/src/daemon/session-queue-manager.ts +2 -0
  164. package/src/daemon/session-runtime-assembly.ts +39 -0
  165. package/src/daemon/session-skill-tools.ts +13 -4
  166. package/src/daemon/session-tool-setup.ts +5 -6
  167. package/src/daemon/session.ts +19 -8
  168. package/src/daemon/tls-certs.ts +55 -13
  169. package/src/daemon/tool-side-effects.ts +13 -5
  170. package/src/gallery/default-gallery.ts +32 -9
  171. package/src/influencer/client.ts +2 -1
  172. package/src/memory/channel-delivery-store.ts +37 -567
  173. package/src/memory/channel-guardian-store.ts +66 -1317
  174. package/src/memory/conflict-store.ts +4 -4
  175. package/src/memory/conversation-attention-store.ts +0 -3
  176. package/src/memory/conversation-crud.ts +668 -0
  177. package/src/memory/conversation-queries.ts +361 -0
  178. package/src/memory/conversation-store.ts +45 -983
  179. package/src/memory/db-connection.ts +3 -0
  180. package/src/memory/db-init.ts +25 -0
  181. package/src/memory/delivery-channels.ts +175 -0
  182. package/src/memory/delivery-crud.ts +211 -0
  183. package/src/memory/delivery-status.ts +199 -0
  184. package/src/memory/embedding-backend.ts +70 -4
  185. package/src/memory/embedding-local.ts +12 -2
  186. package/src/memory/entity-extractor.ts +3 -8
  187. package/src/memory/fts-reconciler.ts +121 -0
  188. package/src/memory/guardian-action-store.ts +366 -3
  189. package/src/memory/guardian-approvals.ts +569 -0
  190. package/src/memory/guardian-bindings.ts +130 -0
  191. package/src/memory/guardian-rate-limits.ts +196 -0
  192. package/src/memory/guardian-verification.ts +520 -0
  193. package/src/memory/job-handlers/index-maintenance.ts +2 -1
  194. package/src/memory/job-utils.ts +8 -5
  195. package/src/memory/jobs-store.ts +66 -6
  196. package/src/memory/jobs-worker.ts +23 -1
  197. package/src/memory/migrations/030-guardian-action-followup.ts +21 -0
  198. package/src/memory/migrations/030-guardian-verification-purpose.ts +17 -0
  199. package/src/memory/migrations/031-conversations-thread-type-index.ts +5 -0
  200. package/src/memory/migrations/100-core-tables.ts +1 -1
  201. package/src/memory/migrations/101-watchers-and-logs.ts +4 -0
  202. package/src/memory/migrations/108-tasks-and-work-items.ts +1 -1
  203. package/src/memory/migrations/112-assistant-inbox.ts +1 -1
  204. package/src/memory/migrations/113-late-migrations.ts +1 -1
  205. package/src/memory/migrations/116-messages-fts.ts +13 -0
  206. package/src/memory/migrations/119-schema-indexes-and-columns.ts +37 -0
  207. package/src/memory/migrations/120-fk-cascade-rebuilds.ts +161 -0
  208. package/src/memory/migrations/index.ts +8 -3
  209. package/src/memory/migrations/validate-migration-state.ts +114 -15
  210. package/src/memory/qdrant-circuit-breaker.ts +105 -0
  211. package/src/memory/retriever.ts +46 -13
  212. package/src/memory/schema-migration.ts +3 -0
  213. package/src/memory/schema.ts +25 -7
  214. package/src/memory/search/semantic.ts +8 -90
  215. package/src/notifications/README.md +1 -1
  216. package/src/notifications/broadcaster.ts +20 -2
  217. package/src/notifications/conversation-pairing.ts +3 -3
  218. package/src/notifications/decision-engine.ts +173 -8
  219. package/src/notifications/deliveries-store.ts +27 -8
  220. package/src/notifications/preferences-store.ts +7 -7
  221. package/src/notifications/thread-candidates.ts +234 -0
  222. package/src/notifications/types.ts +18 -0
  223. package/src/permissions/defaults.ts +11 -1
  224. package/src/permissions/prompter.ts +17 -0
  225. package/src/permissions/trust-store.ts +2 -0
  226. package/src/providers/failover.ts +19 -0
  227. package/src/providers/registry.ts +46 -1
  228. package/src/runtime/approval-message-composer.ts +1 -1
  229. package/src/runtime/channel-guardian-service.ts +15 -3
  230. package/src/runtime/channel-retry-sweep.ts +7 -2
  231. package/src/runtime/guardian-action-conversation-turn.ts +85 -0
  232. package/src/runtime/guardian-action-followup-executor.ts +301 -0
  233. package/src/runtime/guardian-action-message-composer.ts +245 -0
  234. package/src/runtime/guardian-outbound-actions.ts +26 -6
  235. package/src/runtime/guardian-verification-templates.ts +15 -9
  236. package/src/runtime/http-errors.ts +93 -0
  237. package/src/runtime/http-server.ts +133 -44
  238. package/src/runtime/http-types.ts +53 -0
  239. package/src/runtime/ingress-service.ts +237 -0
  240. package/src/runtime/middleware/error-handler.ts +4 -3
  241. package/src/runtime/middleware/rate-limiter.ts +160 -0
  242. package/src/runtime/middleware/request-logger.ts +71 -0
  243. package/src/runtime/middleware/twilio-validation.ts +7 -6
  244. package/src/runtime/pending-interactions.ts +12 -0
  245. package/src/runtime/routes/access-request-decision.ts +215 -0
  246. package/src/runtime/routes/app-routes.ts +25 -18
  247. package/src/runtime/routes/approval-routes.ts +18 -47
  248. package/src/runtime/routes/attachment-routes.ts +15 -41
  249. package/src/runtime/routes/call-routes.ts +20 -20
  250. package/src/runtime/routes/channel-delivery-routes.ts +6 -5
  251. package/src/runtime/routes/contact-routes.ts +4 -9
  252. package/src/runtime/routes/conversation-attention-routes.ts +2 -1
  253. package/src/runtime/routes/conversation-routes.ts +26 -57
  254. package/src/runtime/routes/debug-routes.ts +71 -0
  255. package/src/runtime/routes/events-routes.ts +3 -2
  256. package/src/runtime/routes/guardian-approval-interception.ts +221 -0
  257. package/src/runtime/routes/identity-routes.ts +14 -10
  258. package/src/runtime/routes/inbound-conversation.ts +3 -2
  259. package/src/runtime/routes/inbound-message-handler.ts +527 -62
  260. package/src/runtime/routes/ingress-routes.ts +174 -0
  261. package/src/runtime/routes/integration-routes.ts +78 -16
  262. package/src/runtime/routes/pairing-routes.ts +11 -10
  263. package/src/runtime/routes/secret-routes.ts +10 -18
  264. package/src/runtime/verification-rate-limiter.ts +83 -0
  265. package/src/schedule/schedule-store.ts +13 -1
  266. package/src/schedule/scheduler.ts +1 -1
  267. package/src/security/secret-ingress.ts +5 -2
  268. package/src/security/secret-scanner.ts +72 -6
  269. package/src/subagent/manager.ts +6 -4
  270. package/src/swarm/plan-validator.ts +4 -1
  271. package/src/tasks/task-runner.ts +3 -1
  272. package/src/tools/browser/api-map.ts +9 -6
  273. package/src/tools/calls/call-start.ts +20 -0
  274. package/src/tools/executor.ts +50 -568
  275. package/src/tools/permission-checker.ts +272 -0
  276. package/src/tools/registry.ts +14 -6
  277. package/src/tools/reminder/reminder-store.ts +7 -7
  278. package/src/tools/reminder/reminder.ts +6 -3
  279. package/src/tools/secret-detection-handler.ts +301 -0
  280. package/src/tools/subagent/message.ts +1 -1
  281. package/src/tools/system/voice-config.ts +62 -0
  282. package/src/tools/tasks/index.ts +3 -3
  283. package/src/tools/tasks/work-item-list.ts +3 -3
  284. package/src/tools/tasks/work-item-update.ts +4 -5
  285. package/src/tools/tool-approval-handler.ts +192 -0
  286. package/src/tools/tool-manifest.ts +2 -0
  287. package/src/watcher/watcher-store.ts +9 -9
  288. package/src/work-items/work-item-runner.ts +9 -6
  289. /package/src/memory/migrations/{026-embeddings-nullable-vector-json.ts → 026a-embeddings-nullable-vector-json.ts} +0 -0
  290. /package/src/memory/migrations/{027-guardian-bootstrap-token.ts → 027a-guardian-bootstrap-token.ts} +0 -0
@@ -34,11 +34,11 @@ export interface PairingResult {
34
34
  * Errors are caught and logged — this function never throws so the
35
35
  * notification pipeline is not disrupted by pairing failures.
36
36
  */
37
- export function pairDeliveryWithConversation(
37
+ export async function pairDeliveryWithConversation(
38
38
  signal: NotificationSignal,
39
39
  channel: NotificationChannel,
40
40
  copy: RenderedChannelCopy,
41
- ): PairingResult {
41
+ ): Promise<PairingResult> {
42
42
  try {
43
43
  const strategy = getConversationStrategy(channel as ChannelId);
44
44
 
@@ -78,7 +78,7 @@ export function pairDeliveryWithConversation(
78
78
  : composeThreadSeed(signal, channel, copy);
79
79
  // Skip memory indexing — notification audit messages are not conversational
80
80
  // memory and should not pollute recall or incur embedding/extraction overhead.
81
- const message = addMessage(conversation.id, 'assistant', messageContent, undefined, { skipIndexing: true });
81
+ const message = await addMessage(conversation.id, 'assistant', messageContent, undefined, { skipIndexing: true });
82
82
 
83
83
  log.info(
84
84
  {
@@ -19,18 +19,20 @@ import { getLogger } from '../util/logger.js';
19
19
  import { createDecision } from './decisions-store.js';
20
20
  import { getPreferenceSummary } from './preference-summary.js';
21
21
  import type { NotificationSignal, RoutingIntent } from './signal.js';
22
- import type { NotificationChannel, NotificationDecision, RenderedChannelCopy } from './types.js';
22
+ import { type ThreadCandidateSet, buildThreadCandidates, serializeCandidatesForPrompt } from './thread-candidates.js';
23
+ import type { NotificationChannel, NotificationDecision, RenderedChannelCopy, ThreadAction } from './types.js';
23
24
 
24
25
  const log = getLogger('notification-decision-engine');
25
26
 
26
27
  const DECISION_TIMEOUT_MS = 15_000;
27
- const PROMPT_VERSION = 'v3';
28
+ const PROMPT_VERSION = 'v4';
28
29
 
29
30
  // ── System prompt ──────────────────────────────────────────────────────
30
31
 
31
32
  function buildSystemPrompt(
32
33
  availableChannels: NotificationChannel[],
33
34
  preferenceContext?: string,
35
+ candidateContext?: string,
34
36
  ): string {
35
37
  const sections: string[] = [
36
38
  `You are a notification routing engine. Given a signal describing an event, decide whether the user should be notified, on which channel(s), and compose the notification copy.`,
@@ -73,6 +75,26 @@ function buildSystemPrompt(
73
75
  `- \`threadSeedMessage\` is the opening message in the internal notification thread — it can be richer and more contextual.`,
74
76
  ` - For vellum (desktop): 2-4 short sentences with useful context and clear next step if action is required.`,
75
77
  ` - Never dump raw JSON. Include only human-readable context.`,
78
+ ``,
79
+ `Thread reuse guidelines:`,
80
+ `- For each selected channel, decide whether to start a new conversation thread or reuse an existing one.`,
81
+ `- Set \`threadActions\` keyed by channel name with \`action\` = "start_new" or "reuse_existing" (with \`conversationId\` from the candidates).`,
82
+ `- Prefer \`reuse_existing\` when the signal is clearly a continuation or update of an existing notification thread (same event type, related context).`,
83
+ `- Prefer \`start_new\` when the signal is a distinct event that deserves its own thread.`,
84
+ `- You may ONLY reuse a conversationId that appears in the provided candidate list. Any other ID will be rejected and downgraded to start_new.`,
85
+ `- When no candidates are available for a channel, always use start_new.`,
86
+ );
87
+
88
+ if (candidateContext) {
89
+ sections.push(
90
+ ``,
91
+ `<thread-candidates>`,
92
+ candidateContext,
93
+ `</thread-candidates>`,
94
+ );
95
+ }
96
+
97
+ sections.push(
76
98
  ``,
77
99
  `You MUST respond using the \`record_notification_decision\` tool. Do not respond with text.`,
78
100
  );
@@ -158,6 +180,30 @@ function buildDecisionTool(availableChannels: NotificationChannel[]) {
158
180
  ]),
159
181
  ),
160
182
  },
183
+ threadActions: {
184
+ type: 'object',
185
+ description: 'Per-channel thread action: start a new thread or reuse an existing candidate. Keyed by channel name.',
186
+ properties: Object.fromEntries(
187
+ availableChannels.map((ch) => [
188
+ ch,
189
+ {
190
+ type: 'object',
191
+ properties: {
192
+ action: {
193
+ type: 'string',
194
+ enum: ['start_new', 'reuse_existing'],
195
+ description: 'Whether to start a new thread or reuse an existing one.',
196
+ },
197
+ conversationId: {
198
+ type: 'string',
199
+ description: 'Required when action is reuse_existing. Must be a conversationId from the provided thread candidates.',
200
+ },
201
+ },
202
+ required: ['action'],
203
+ },
204
+ ]),
205
+ ),
206
+ },
161
207
  deepLinkTarget: {
162
208
  type: 'object',
163
209
  description: 'Optional deep link metadata for navigating to the source context',
@@ -237,6 +283,7 @@ const VALID_CHANNELS = new Set<string>(getDeliverableChannels());
237
283
  function validateDecisionOutput(
238
284
  input: Record<string, unknown>,
239
285
  availableChannels: NotificationChannel[],
286
+ candidateSet?: ThreadCandidateSet,
240
287
  ): NotificationDecision | null {
241
288
  if (typeof input.shouldNotify !== 'boolean') return null;
242
289
  if (typeof input.reasoningSummary !== 'string') return null;
@@ -277,6 +324,9 @@ function validateDecisionOutput(
277
324
  }
278
325
  }
279
326
 
327
+ // Validate threadActions — strictly against the provided candidate set
328
+ const threadActions = validateThreadActions(input.threadActions, validChannels, candidateSet);
329
+
280
330
  const deepLinkTarget = input.deepLinkTarget && typeof input.deepLinkTarget === 'object'
281
331
  ? input.deepLinkTarget as Record<string, unknown>
282
332
  : undefined;
@@ -286,6 +336,7 @@ function validateDecisionOutput(
286
336
  selectedChannels: validChannels,
287
337
  reasoningSummary: input.reasoningSummary,
288
338
  renderedCopy,
339
+ threadActions: Object.keys(threadActions).length > 0 ? threadActions : undefined,
289
340
  deepLinkTarget,
290
341
  dedupeKey: input.dedupeKey,
291
342
  confidence,
@@ -293,6 +344,74 @@ function validateDecisionOutput(
293
344
  };
294
345
  }
295
346
 
347
+ // ── Thread action validation ───────────────────────────────────────────
348
+
349
+ /**
350
+ * Validate and sanitize thread actions from LLM output.
351
+ *
352
+ * - reuse_existing targets are checked against the candidate set; invalid
353
+ * targets are downgraded to start_new with a warning.
354
+ * - Channels not in the selected set are ignored.
355
+ * - Missing actions for selected channels default to start_new (handled
356
+ * downstream, not materialized here to keep the output compact).
357
+ */
358
+ export function validateThreadActions(
359
+ raw: unknown,
360
+ validChannels: NotificationChannel[],
361
+ candidateSet?: ThreadCandidateSet,
362
+ ): Partial<Record<NotificationChannel, ThreadAction>> {
363
+ const result: Partial<Record<NotificationChannel, ThreadAction>> = {};
364
+
365
+ if (!raw || typeof raw !== 'object') return result;
366
+
367
+ const actionsObj = raw as Record<string, unknown>;
368
+ const channelSet = new Set(validChannels);
369
+
370
+ // Build a lookup of valid candidate conversationIds per channel
371
+ const validCandidateIds = new Map<NotificationChannel, Set<string>>();
372
+ if (candidateSet) {
373
+ for (const [ch, candidates] of Object.entries(candidateSet) as [NotificationChannel, { conversationId: string }[]][]) {
374
+ validCandidateIds.set(ch, new Set(candidates.map((c) => c.conversationId)));
375
+ }
376
+ }
377
+
378
+ for (const [ch, actionRaw] of Object.entries(actionsObj)) {
379
+ if (!channelSet.has(ch as NotificationChannel)) continue;
380
+ if (!actionRaw || typeof actionRaw !== 'object') continue;
381
+
382
+ const channel = ch as NotificationChannel;
383
+ const action = actionRaw as Record<string, unknown>;
384
+
385
+ if (action.action === 'start_new') {
386
+ result[channel] = { action: 'start_new' };
387
+ } else if (action.action === 'reuse_existing') {
388
+ const conversationId = action.conversationId;
389
+ if (typeof conversationId !== 'string' || !conversationId.trim()) {
390
+ log.warn({ channel }, 'LLM returned reuse_existing without conversationId — downgrading to start_new');
391
+ result[channel] = { action: 'start_new' };
392
+ continue;
393
+ }
394
+
395
+ // Strict validation: the conversationId must exist in the candidate set
396
+ const candidateIds = validCandidateIds.get(channel);
397
+ if (!candidateIds || !candidateIds.has(conversationId)) {
398
+ log.warn(
399
+ { channel, conversationId },
400
+ 'LLM returned reuse_existing with conversationId not in candidate set — downgrading to start_new',
401
+ );
402
+ result[channel] = { action: 'start_new' };
403
+ continue;
404
+ }
405
+
406
+ result[channel] = { action: 'reuse_existing', conversationId };
407
+ }
408
+ // Unknown action values are silently ignored — the channel will default
409
+ // to start_new downstream.
410
+ }
411
+
412
+ return result;
413
+ }
414
+
296
415
  // ── Core evaluation function ───────────────────────────────────────────
297
416
 
298
417
  export async function evaluateSignal(
@@ -317,6 +436,16 @@ export async function evaluateSignal(
317
436
  }
318
437
  }
319
438
 
439
+ // Build thread candidate set for reuse decisions. Wrapped in try/catch
440
+ // so candidate lookup failures do not block the decision path.
441
+ let candidateSet: ThreadCandidateSet | undefined;
442
+ try {
443
+ candidateSet = buildThreadCandidates(availableChannels, signal.assistantId);
444
+ } catch (err) {
445
+ const errMsg = err instanceof Error ? err.message : String(err);
446
+ log.warn({ err: errMsg }, 'Failed to build thread candidates, proceeding without candidates');
447
+ }
448
+
320
449
  const provider = getConfiguredProvider();
321
450
  if (!provider) {
322
451
  log.warn('Configured provider unavailable for notification decision, using fallback');
@@ -327,7 +456,7 @@ export async function evaluateSignal(
327
456
 
328
457
  let decision: NotificationDecision;
329
458
  try {
330
- decision = await classifyWithLLM(signal, availableChannels, resolvedPreferenceContext, decisionModelIntent);
459
+ decision = await classifyWithLLM(signal, availableChannels, resolvedPreferenceContext, decisionModelIntent, candidateSet);
331
460
  } catch (err) {
332
461
  const errMsg = err instanceof Error ? err.message : String(err);
333
462
  log.warn({ err: errMsg }, 'Notification decision LLM call failed, using fallback');
@@ -346,11 +475,13 @@ async function classifyWithLLM(
346
475
  availableChannels: NotificationChannel[],
347
476
  preferenceContext: string | undefined,
348
477
  modelIntent: ModelIntent,
478
+ candidateSet?: ThreadCandidateSet,
349
479
  ): Promise<NotificationDecision> {
350
480
  const provider = getConfiguredProvider()!;
351
481
  const { signal: abortSignal, cleanup } = createTimeout(DECISION_TIMEOUT_MS);
352
482
 
353
- const systemPrompt = buildSystemPrompt(availableChannels, preferenceContext);
483
+ const candidateContext = candidateSet ? serializeCandidatesForPrompt(candidateSet) ?? undefined : undefined;
484
+ const systemPrompt = buildSystemPrompt(availableChannels, preferenceContext, candidateContext);
354
485
  const prompt = buildUserPrompt(signal);
355
486
  const tool = buildDecisionTool(availableChannels);
356
487
 
@@ -379,6 +510,7 @@ async function classifyWithLLM(
379
510
  const validated = validateDecisionOutput(
380
511
  toolBlock.input as Record<string, unknown>,
381
512
  availableChannels,
513
+ candidateSet,
382
514
  );
383
515
  if (!validated) {
384
516
  log.warn('Invalid notification decision output from LLM, using fallback');
@@ -432,12 +564,31 @@ export function enforceRoutingIntent(
432
564
  if (routingIntent === 'multi_channel') {
433
565
  // Ensure at least 2 channels when 2+ are connected
434
566
  if (connectedChannels.length >= 2 && decision.selectedChannels.length < 2) {
567
+ const connectedSet = new Set<NotificationChannel>(connectedChannels);
568
+ const selectedConnected = decision.selectedChannels.filter((ch) => connectedSet.has(ch));
569
+ const expanded: NotificationChannel[] = [];
570
+ const seen = new Set<NotificationChannel>();
571
+
572
+ // Preserve the decision's selected channels first, then add connected
573
+ // channels until we reach two channels total.
574
+ for (const ch of selectedConnected) {
575
+ if (seen.has(ch)) continue;
576
+ expanded.push(ch);
577
+ seen.add(ch);
578
+ }
579
+ for (const ch of connectedChannels) {
580
+ if (seen.has(ch)) continue;
581
+ expanded.push(ch);
582
+ seen.add(ch);
583
+ if (expanded.length >= 2) break;
584
+ }
585
+
435
586
  const enforced = { ...decision };
436
- enforced.selectedChannels = [...connectedChannels];
437
- enforced.reasoningSummary = `${decision.reasoningSummary} [routing_intent=multi_channel enforced: expanded to ${connectedChannels.join(', ')}]`;
587
+ enforced.selectedChannels = expanded;
588
+ enforced.reasoningSummary = `${decision.reasoningSummary} [routing_intent=multi_channel enforced: expanded to ${expanded.join(', ')}]`;
438
589
  log.info(
439
- { routingIntent, connectedChannels, originalChannels: decision.selectedChannels },
440
- 'Routing intent enforcement: multi_channel → expanded to all connected channels',
590
+ { routingIntent, connectedChannels, originalChannels: decision.selectedChannels, enforcedChannels: expanded },
591
+ 'Routing intent enforcement: multi_channel → expanded to at least two channels',
441
592
  );
442
593
  return enforced;
443
594
  }
@@ -451,6 +602,19 @@ export function enforceRoutingIntent(
451
602
  function persistDecision(signal: NotificationSignal, decision: NotificationDecision): string | undefined {
452
603
  try {
453
604
  const decisionId = uuid();
605
+
606
+ // Summarize thread actions for the audit trail
607
+ const threadActionSummary: Record<string, string> = {};
608
+ if (decision.threadActions) {
609
+ for (const [ch, ta] of Object.entries(decision.threadActions)) {
610
+ if (ta.action === 'reuse_existing') {
611
+ threadActionSummary[ch] = `reuse:${ta.conversationId}`;
612
+ } else {
613
+ threadActionSummary[ch] = 'start_new';
614
+ }
615
+ }
616
+ }
617
+
454
618
  createDecision({
455
619
  id: decisionId,
456
620
  notificationEventId: signal.signalId,
@@ -464,6 +628,7 @@ function persistDecision(signal: NotificationSignal, decision: NotificationDecis
464
628
  dedupeKey: decision.dedupeKey,
465
629
  channelCount: decision.selectedChannels.length,
466
630
  hasCopy: Object.keys(decision.renderedCopy).length > 0,
631
+ ...(Object.keys(threadActionSummary).length > 0 ? { threadActions: threadActionSummary } : {}),
467
632
  },
468
633
  });
469
634
  return decisionId;
@@ -6,9 +6,9 @@
6
6
  * (decision, channel, destination) are tracked via the `attempt` counter.
7
7
  */
8
8
 
9
- import { eq } from 'drizzle-orm';
9
+ import { and, eq } from 'drizzle-orm';
10
10
 
11
- import { getDb } from '../memory/db.js';
11
+ import { getDb, rawChanges } from '../memory/db.js';
12
12
  import { notificationDeliveries } from '../memory/schema.js';
13
13
  import type { NotificationChannel, NotificationDeliveryStatus } from './types.js';
14
14
 
@@ -131,13 +131,13 @@ export function updateDeliveryStatus(
131
131
  updates.errorMessage = error.message;
132
132
  }
133
133
 
134
- const result = db
134
+ db
135
135
  .update(notificationDeliveries)
136
136
  .set(updates)
137
137
  .where(eq(notificationDeliveries.id, id))
138
- .run() as unknown as { changes?: number };
138
+ .run();
139
139
 
140
- return (result.changes ?? 0) > 0;
140
+ return rawChanges() > 0;
141
141
  }
142
142
 
143
143
  /**
@@ -171,13 +171,13 @@ export function updateDeliveryClientOutcome(
171
171
  updates.clientDeliveryError = error.code;
172
172
  }
173
173
 
174
- const result = db
174
+ db
175
175
  .update(notificationDeliveries)
176
176
  .set(updates)
177
177
  .where(eq(notificationDeliveries.id, deliveryId))
178
- .run() as unknown as { changes?: number };
178
+ .run();
179
179
 
180
- return (result.changes ?? 0) > 0;
180
+ return rawChanges() > 0;
181
181
  }
182
182
 
183
183
  /** List all delivery records for a given notification decision. */
@@ -190,3 +190,22 @@ export function listDeliveries(decisionId: string): NotificationDeliveryRow[] {
190
190
  .all();
191
191
  return rows.map(rowToDelivery);
192
192
  }
193
+
194
+ /** Check whether a delivery already exists for a given decision+channel pair. */
195
+ export function findDeliveryByDecisionAndChannel(
196
+ decisionId: string,
197
+ channel: NotificationChannel,
198
+ ): NotificationDeliveryRow | undefined {
199
+ const db = getDb();
200
+ const row = db
201
+ .select()
202
+ .from(notificationDeliveries)
203
+ .where(
204
+ and(
205
+ eq(notificationDeliveries.notificationDecisionId, decisionId),
206
+ eq(notificationDeliveries.channel, channel),
207
+ ),
208
+ )
209
+ .get();
210
+ return row ? rowToDelivery(row) : undefined;
211
+ }
@@ -10,7 +10,7 @@
10
10
  import { desc, eq } from 'drizzle-orm';
11
11
  import { v4 as uuid } from 'uuid';
12
12
 
13
- import { getDb } from '../memory/db.js';
13
+ import { getDb, rawChanges } from '../memory/db.js';
14
14
  import { notificationPreferences } from '../memory/schema.js';
15
15
 
16
16
  // ── Row type ────────────────────────────────────────────────────────────
@@ -107,13 +107,13 @@ export function updatePreference(id: string, params: UpdatePreferenceParams): bo
107
107
  if (params.appliesWhen !== undefined) updates.appliesWhenJson = JSON.stringify(params.appliesWhen);
108
108
  if (params.priority !== undefined) updates.priority = params.priority;
109
109
 
110
- const result = db
110
+ db
111
111
  .update(notificationPreferences)
112
112
  .set(updates)
113
113
  .where(eq(notificationPreferences.id, id))
114
- .run() as unknown as { changes?: number };
114
+ .run();
115
115
 
116
- return (result.changes ?? 0) > 0;
116
+ return rawChanges() > 0;
117
117
  }
118
118
 
119
119
  // ── Delete ──────────────────────────────────────────────────────────────
@@ -121,12 +121,12 @@ export function updatePreference(id: string, params: UpdatePreferenceParams): bo
121
121
  export function deletePreference(id: string): boolean {
122
122
  const db = getDb();
123
123
 
124
- const result = db
124
+ db
125
125
  .delete(notificationPreferences)
126
126
  .where(eq(notificationPreferences.id, id))
127
- .run() as unknown as { changes?: number };
127
+ .run();
128
128
 
129
- return (result.changes ?? 0) > 0;
129
+ return rawChanges() > 0;
130
130
  }
131
131
 
132
132
  // ── Get by ID ───────────────────────────────────────────────────────────
@@ -0,0 +1,234 @@
1
+ /**
2
+ * Thread candidate builder for notification thread reuse.
3
+ *
4
+ * Builds a lightweight candidate set of recent notification conversations
5
+ * per channel that the decision engine can choose to reuse instead of
6
+ * starting a new thread. Includes guardian-specific context (pending
7
+ * unresolved request count) when available.
8
+ *
9
+ * The candidate set is intentionally compact — only the fields the LLM
10
+ * needs for a routing decision, not full conversation contents.
11
+ */
12
+
13
+ import { and, desc, eq, isNotNull } from 'drizzle-orm';
14
+
15
+ import { getDb } from '../memory/db.js';
16
+ import { countPendingByConversation } from '../memory/guardian-approvals.js';
17
+ import { conversations, notificationDeliveries, notificationDecisions, notificationEvents } from '../memory/schema.js';
18
+ import { getLogger } from '../util/logger.js';
19
+ import type { NotificationChannel } from './types.js';
20
+
21
+ const log = getLogger('thread-candidates');
22
+
23
+ /** Maximum number of candidate threads to surface per channel. */
24
+ const MAX_CANDIDATES_PER_CHANNEL = 5;
25
+
26
+ /** Only consider conversations updated within this window (ms). */
27
+ const CANDIDATE_RECENCY_WINDOW_MS = 24 * 60 * 60 * 1000; // 24 hours
28
+
29
+ // -- Public types -------------------------------------------------------------
30
+
31
+ /** Guardian-specific context attached to a thread candidate when available. */
32
+ export interface GuardianCandidateContext {
33
+ /** Number of unresolved (pending) guardian approval requests in this conversation. */
34
+ pendingUnresolvedRequestCount: number;
35
+ }
36
+
37
+ /** A single candidate conversation that the decision engine can select for reuse. */
38
+ export interface ThreadCandidate {
39
+ conversationId: string;
40
+ title: string | null;
41
+ updatedAt: number;
42
+ /** The source event name from the most recent notification delivered to this conversation. */
43
+ latestSourceEventName: string | null;
44
+ channel: NotificationChannel;
45
+ /** Guardian-specific context, present only when there are relevant guardian records. */
46
+ guardianContext?: GuardianCandidateContext;
47
+ }
48
+
49
+ /** Candidate set for the decision engine, keyed by channel. */
50
+ export type ThreadCandidateSet = Partial<Record<NotificationChannel, ThreadCandidate[]>>;
51
+
52
+ // -- Core builder -------------------------------------------------------------
53
+
54
+ /**
55
+ * Build the thread candidate set for all selected channels.
56
+ *
57
+ * Queries recent notification-sourced conversations that were delivered
58
+ * to each channel and enriches them with guardian-specific metadata
59
+ * when available.
60
+ *
61
+ * Errors are caught per-channel so a failure in one channel does not
62
+ * block candidates for others.
63
+ */
64
+ export function buildThreadCandidates(
65
+ channels: NotificationChannel[],
66
+ assistantId: string,
67
+ ): ThreadCandidateSet {
68
+ const result: ThreadCandidateSet = {};
69
+ const cutoff = Date.now() - CANDIDATE_RECENCY_WINDOW_MS;
70
+
71
+ for (const channel of channels) {
72
+ try {
73
+ const candidates = buildCandidatesForChannel(channel, assistantId, cutoff);
74
+ if (candidates.length > 0) {
75
+ result[channel] = candidates;
76
+ }
77
+ } catch (err) {
78
+ const errMsg = err instanceof Error ? err.message : String(err);
79
+ log.warn({ err: errMsg, channel }, 'Failed to build thread candidates for channel');
80
+ }
81
+ }
82
+
83
+ return result;
84
+ }
85
+
86
+ // -- Per-channel query --------------------------------------------------------
87
+
88
+ /**
89
+ * Query recent notification conversations for a given channel.
90
+ *
91
+ * Joins notification_deliveries -> notification_decisions -> notification_events
92
+ * to find conversations that were created by the notification pipeline for
93
+ * this channel, then enriches with guardian context.
94
+ */
95
+ function buildCandidatesForChannel(
96
+ channel: NotificationChannel,
97
+ assistantId: string,
98
+ cutoffMs: number,
99
+ ): ThreadCandidate[] {
100
+ const db = getDb();
101
+
102
+ // Find recent notification deliveries for this channel that have a
103
+ // conversationId and were successfully sent.
104
+ const rows = db
105
+ .select({
106
+ conversationId: notificationDeliveries.conversationId,
107
+ channel: notificationDeliveries.channel,
108
+ deliverySentAt: notificationDeliveries.sentAt,
109
+ sourceEventName: notificationEvents.sourceEventName,
110
+ convTitle: conversations.title,
111
+ convUpdatedAt: conversations.updatedAt,
112
+ })
113
+ .from(notificationDeliveries)
114
+ .innerJoin(
115
+ notificationDecisions,
116
+ eq(notificationDeliveries.notificationDecisionId, notificationDecisions.id),
117
+ )
118
+ .innerJoin(
119
+ notificationEvents,
120
+ eq(notificationDecisions.notificationEventId, notificationEvents.id),
121
+ )
122
+ .innerJoin(
123
+ conversations,
124
+ eq(notificationDeliveries.conversationId, conversations.id),
125
+ )
126
+ .where(
127
+ and(
128
+ eq(notificationDeliveries.channel, channel),
129
+ eq(notificationDeliveries.assistantId, assistantId),
130
+ eq(notificationDeliveries.status, 'sent'),
131
+ isNotNull(notificationDeliveries.conversationId),
132
+ ),
133
+ )
134
+ .orderBy(desc(notificationDeliveries.sentAt))
135
+ .limit(MAX_CANDIDATES_PER_CHANNEL * 3) // over-fetch to allow deduplication
136
+ .all();
137
+
138
+ // Deduplicate by conversationId (keep the most recent delivery per conversation)
139
+ const seen = new Set<string>();
140
+ const candidates: ThreadCandidate[] = [];
141
+
142
+ for (const row of rows) {
143
+ if (!row.conversationId) continue;
144
+ if (seen.has(row.conversationId)) continue;
145
+
146
+ // Apply recency filter on the conversation's updatedAt
147
+ if (row.convUpdatedAt < cutoffMs) continue;
148
+
149
+ seen.add(row.conversationId);
150
+
151
+ const candidate: ThreadCandidate = {
152
+ conversationId: row.conversationId,
153
+ title: row.convTitle,
154
+ updatedAt: row.convUpdatedAt,
155
+ latestSourceEventName: row.sourceEventName ?? null,
156
+ channel: channel,
157
+ };
158
+
159
+ // Enrich with guardian context
160
+ const guardianContext = buildGuardianContext(row.conversationId, assistantId);
161
+ if (guardianContext) {
162
+ candidate.guardianContext = guardianContext;
163
+ }
164
+
165
+ candidates.push(candidate);
166
+
167
+ if (candidates.length >= MAX_CANDIDATES_PER_CHANNEL) break;
168
+ }
169
+
170
+ return candidates;
171
+ }
172
+
173
+ // -- Guardian context enrichment ----------------------------------------------
174
+
175
+ /**
176
+ * Build guardian-specific context for a candidate conversation.
177
+ * Returns null when there is no guardian-relevant data.
178
+ */
179
+ function buildGuardianContext(
180
+ conversationId: string,
181
+ assistantId: string,
182
+ ): GuardianCandidateContext | null {
183
+ try {
184
+ const pendingCount = countPendingByConversation(conversationId, assistantId);
185
+ if (pendingCount > 0) {
186
+ return { pendingUnresolvedRequestCount: pendingCount };
187
+ }
188
+ } catch (err) {
189
+ const errMsg = err instanceof Error ? err.message : String(err);
190
+ log.warn({ err: errMsg, conversationId }, 'Failed to query guardian context for candidate');
191
+ }
192
+
193
+ return null;
194
+ }
195
+
196
+ // -- Prompt serialization -----------------------------------------------------
197
+
198
+ /**
199
+ * Serialize a thread candidate set into a compact text block suitable for
200
+ * injection into the decision engine's user prompt.
201
+ *
202
+ * Designed to be token-efficient while giving the LLM enough context
203
+ * to make a reuse decision.
204
+ */
205
+ export function serializeCandidatesForPrompt(candidateSet: ThreadCandidateSet): string | null {
206
+ const channelEntries = Object.entries(candidateSet) as [NotificationChannel, ThreadCandidate[]][];
207
+ if (channelEntries.length === 0) return null;
208
+
209
+ const sections: string[] = [];
210
+
211
+ for (const [channel, candidates] of channelEntries) {
212
+ if (candidates.length === 0) continue;
213
+
214
+ const lines: string[] = [`Channel: ${channel}`];
215
+ for (const c of candidates) {
216
+ const parts: string[] = [
217
+ ` - id=${c.conversationId}`,
218
+ `title="${c.title ?? '(untitled)'}"`,
219
+ `updated=${new Date(c.updatedAt).toISOString()}`,
220
+ ];
221
+ if (c.latestSourceEventName) {
222
+ parts.push(`lastEvent="${c.latestSourceEventName}"`);
223
+ }
224
+ if (c.guardianContext) {
225
+ parts.push(`pendingRequests=${c.guardianContext.pendingUnresolvedRequestCount}`);
226
+ }
227
+ lines.push(parts.join(' '));
228
+ }
229
+ sections.push(lines.join('\n'));
230
+ }
231
+
232
+ if (sections.length === 0) return null;
233
+ return sections.join('\n\n');
234
+ }
@@ -79,12 +79,30 @@ export interface RenderedChannelCopy {
79
79
  threadSeedMessage?: string;
80
80
  }
81
81
 
82
+ // -- Thread action types ------------------------------------------------------
83
+
84
+ /** Start a new conversation thread for the notification delivery. */
85
+ export interface ThreadActionStartNew {
86
+ action: 'start_new';
87
+ }
88
+
89
+ /** Reuse an existing conversation thread identified by conversationId. */
90
+ export interface ThreadActionReuseExisting {
91
+ action: 'reuse_existing';
92
+ conversationId: string;
93
+ }
94
+
95
+ /** Per-channel thread action — either start a new thread or reuse an existing one. */
96
+ export type ThreadAction = ThreadActionStartNew | ThreadActionReuseExisting;
97
+
82
98
  /** Output produced by the notification decision engine for a given signal. */
83
99
  export interface NotificationDecision {
84
100
  shouldNotify: boolean;
85
101
  selectedChannels: NotificationChannel[];
86
102
  reasoningSummary: string;
87
103
  renderedCopy: Partial<Record<NotificationChannel, RenderedChannelCopy>>;
104
+ /** Per-channel thread action. When absent for a channel, defaults to start_new. */
105
+ threadActions?: Partial<Record<NotificationChannel, ThreadAction>>;
88
106
  deepLinkTarget?: Record<string, unknown>;
89
107
  dedupeKey: string;
90
108
  confidence: number;