@vellumai/assistant 0.11.1 → 0.11.2-staging.2

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (318) hide show
  1. package/Dockerfile +1 -3
  2. package/README.md +1 -1
  3. package/eslint.config.mjs +28 -8
  4. package/node_modules/@vellumai/ces-client/node_modules/@vellumai/service-contracts/package.json +1 -0
  5. package/node_modules/@vellumai/ces-client/node_modules/@vellumai/service-contracts/src/__tests__/stripe-currency.test.ts +37 -0
  6. package/node_modules/@vellumai/ces-client/node_modules/@vellumai/service-contracts/src/stripe-currency.ts +55 -0
  7. package/node_modules/@vellumai/gateway-client/node_modules/@vellumai/service-contracts/package.json +1 -0
  8. package/node_modules/@vellumai/gateway-client/node_modules/@vellumai/service-contracts/src/__tests__/stripe-currency.test.ts +37 -0
  9. package/node_modules/@vellumai/gateway-client/node_modules/@vellumai/service-contracts/src/stripe-currency.ts +55 -0
  10. package/node_modules/@vellumai/service-contracts/package.json +1 -0
  11. package/node_modules/@vellumai/service-contracts/src/__tests__/stripe-currency.test.ts +37 -0
  12. package/node_modules/@vellumai/service-contracts/src/stripe-currency.ts +55 -0
  13. package/node_modules/@vellumai/slack-text/src/index.test.ts +48 -0
  14. package/node_modules/@vellumai/slack-text/src/index.ts +25 -6
  15. package/openapi.yaml +292 -5
  16. package/package.json +1 -1
  17. package/src/__tests__/agent-loop-resume-interrupted.test.ts +223 -0
  18. package/src/__tests__/byok-default-profile-ensure.test.ts +43 -0
  19. package/src/__tests__/cli-logger-boundary-guard.test.ts +87 -0
  20. package/src/__tests__/compaction-events.test.ts +139 -2
  21. package/src/__tests__/config-loader-backfill.test.ts +35 -3
  22. package/src/__tests__/config-schema.test.ts +1 -0
  23. package/src/__tests__/conversation-agent-loop-fatal-cleanup.test.ts +30 -0
  24. package/src/__tests__/conversation-agent-loop.test.ts +180 -7
  25. package/src/__tests__/conversation-error.test.ts +211 -4
  26. package/src/__tests__/conversation-options-turn-scoped-transport.test.ts +64 -0
  27. package/src/__tests__/conversation-queue.test.ts +126 -0
  28. package/src/__tests__/conversation-retry-route.test.ts +46 -7
  29. package/src/__tests__/conversation-slash.test.ts +4 -2
  30. package/src/__tests__/conversation-summarize-route.test.ts +7 -1
  31. package/src/__tests__/conversation-summarize-up-to.test.ts +71 -5
  32. package/src/__tests__/document-create-dedupe.test.ts +2 -1
  33. package/src/__tests__/document-find-replace.test.ts +2 -1
  34. package/src/__tests__/document-tool-security.test.ts +2 -1
  35. package/src/__tests__/document-update-default-surface.test.ts +2 -1
  36. package/src/__tests__/document-workspace-file.test.ts +467 -0
  37. package/src/__tests__/emit-signal-routing-intent.test.ts +290 -49
  38. package/src/__tests__/guardian-card-withdrawal.test.ts +91 -3
  39. package/src/__tests__/http-user-message-parity.test.ts +66 -0
  40. package/src/__tests__/list-messages-attachments.test.ts +87 -0
  41. package/src/__tests__/list-messages-provider-error.test.ts +143 -0
  42. package/src/__tests__/list-messages-system-card.test.ts +100 -0
  43. package/src/__tests__/llm-resolver.test.ts +19 -9
  44. package/src/__tests__/managed-profile-guard.test.ts +23 -0
  45. package/src/__tests__/notification-platform-adapter.test.ts +130 -2
  46. package/src/__tests__/notification-telegram-adapter.test.ts +6 -0
  47. package/src/__tests__/notification-vellum-adapter.test.ts +45 -0
  48. package/src/__tests__/plugin-api-model-profiles.test.ts +10 -1
  49. package/src/__tests__/plugin-import-boundary-guard.test.ts +3 -0
  50. package/src/__tests__/provider-error-scenarios.test.ts +140 -0
  51. package/src/__tests__/provider-send-message-override-profile.test.ts +95 -0
  52. package/src/__tests__/run-conversation-turn-persistence.test.ts +66 -3
  53. package/src/__tests__/scripted-turn-metadata-persistence.test.ts +209 -0
  54. package/src/__tests__/skills.test.ts +27 -0
  55. package/src/__tests__/slack-channels-routes.test.ts +0 -2
  56. package/src/__tests__/slack-share-routes.test.ts +0 -3
  57. package/src/__tests__/slack-users-routes.test.ts +0 -2
  58. package/src/__tests__/subagent-tools.test.ts +11 -0
  59. package/src/__tests__/tool-preview-lifecycle.test.ts +58 -0
  60. package/src/__tests__/tool-result-spool.test.ts +5 -4
  61. package/src/__tests__/turn-boundary-resolution.test.ts +53 -0
  62. package/src/__tests__/turn-events-store.test.ts +26 -0
  63. package/src/__tests__/ui-visual-surface.test.ts +695 -0
  64. package/src/__tests__/unified-turn-context-visible-app.test.ts +99 -0
  65. package/src/__tests__/visible-app-context.test.ts +189 -0
  66. package/src/__tests__/workspace-git-service.test.ts +173 -33
  67. package/src/__tests__/workspace-migration-137-repair-retired-fireworks-minimax-model-id.test.ts +157 -0
  68. package/src/__tests__/workspace-migration-138-backfill-home-feed-titles.test.ts +373 -0
  69. package/src/__tests__/workspace-migration-139-clear-renamed-cost-profile-label.test.ts +137 -0
  70. package/src/agent/loop.ts +36 -7
  71. package/src/api/events/context-window-usage.ts +31 -0
  72. package/src/api/events/notification-intent.ts +8 -0
  73. package/src/api/events/ui-surface-pending.ts +35 -0
  74. package/src/api/index.ts +14 -0
  75. package/src/api/responses/conversation-message.ts +25 -4
  76. package/src/api/surfaces.ts +90 -1
  77. package/src/approvals/guardian-card-withdrawal.ts +66 -31
  78. package/src/approvals/guardian-decision-primitive.ts +4 -0
  79. package/src/calls/__tests__/call-setup-router.test.ts +156 -26
  80. package/src/calls/__tests__/voice-session-bridge.test.ts +201 -12
  81. package/src/calls/call-setup-router.ts +80 -36
  82. package/src/calls/voice-session-bridge.ts +173 -26
  83. package/src/cli/commands/inference.help.ts +3 -3
  84. package/src/cli/commands/notifications.help.ts +3 -2
  85. package/src/cli/commands/platform/__tests__/callback-routes-list.test.ts +42 -128
  86. package/src/cli/commands/platform/__tests__/credits.test.ts +9 -78
  87. package/src/cli/commands/platform/__tests__/helpers.ts +90 -0
  88. package/src/cli/commands/platform/__tests__/invoices.test.ts +238 -0
  89. package/src/cli/commands/platform/__tests__/plans.test.ts +9 -88
  90. package/src/cli/commands/platform/__tests__/status.test.ts +12 -87
  91. package/src/cli/commands/platform/__tests__/subscription.test.ts +9 -86
  92. package/src/cli/commands/platform/index.help.ts +92 -0
  93. package/src/cli/commands/platform/index.ts +7 -0
  94. package/src/cli/commands/platform/invoices.ts +132 -0
  95. package/src/cli/commands/usage.help.ts +1 -1
  96. package/src/cli/lib/list-installed-plugins.ts +2 -1
  97. package/src/config/__tests__/default-profile-catalog.test.ts +7 -10
  98. package/src/config/__tests__/deployment-context-defaults.test.ts +27 -5
  99. package/src/config/assistant-feature-flags.ts +7 -2
  100. package/src/config/bundled-skills/app-builder/SKILL.md +3 -2
  101. package/src/config/bundled-skills/subagent/SKILL.md +3 -1
  102. package/src/config/bundled-skills/subagent/TOOLS.json +3 -3
  103. package/src/config/bundled-skills/visualize/SKILL.md +163 -0
  104. package/src/config/call-site-defaults.ts +3 -2
  105. package/src/config/default-profile-catalog.ts +75 -70
  106. package/src/config/default-profile-names.ts +13 -28
  107. package/src/config/env-registry.ts +1 -0
  108. package/src/config/feature-flag-registry.json +16 -0
  109. package/src/config/llm-resolver.ts +21 -5
  110. package/src/config/loader.ts +18 -11
  111. package/src/config/schemas/llm.ts +1 -9
  112. package/src/config/schemas/memory-retrospective.ts +9 -0
  113. package/src/config/schemas/monitoring.ts +28 -2
  114. package/src/config/schemas/workspace-git.ts +15 -0
  115. package/src/config/seed-inference-profiles.ts +19 -5
  116. package/src/context/post-turn-tool-result-truncation.ts +2 -2
  117. package/src/conversations/__tests__/message-consolidation.test.ts +54 -0
  118. package/src/conversations/message-consolidation.ts +17 -15
  119. package/src/daemon/__tests__/turn-tail-assistant-reply-notify.test.ts +182 -0
  120. package/src/daemon/__tests__/turn-tail-deleted-conversation.test.ts +181 -0
  121. package/src/daemon/conversation-agent-loop-handlers.ts +90 -7
  122. package/src/daemon/conversation-agent-loop.ts +107 -22
  123. package/src/daemon/conversation-error.ts +169 -48
  124. package/src/daemon/conversation-messaging.ts +87 -4
  125. package/src/daemon/conversation-process.ts +57 -45
  126. package/src/daemon/conversation-runtime-assembly.ts +57 -0
  127. package/src/daemon/conversation-store.ts +36 -1
  128. package/src/daemon/conversation-surfaces.ts +68 -6
  129. package/src/daemon/conversation-turn-finalize.ts +76 -18
  130. package/src/daemon/conversation.ts +122 -27
  131. package/src/daemon/lifecycle.ts +9 -0
  132. package/src/daemon/message-types/conversations.ts +8 -0
  133. package/src/daemon/message-types/surfaces.ts +3 -0
  134. package/src/documents/document-store.ts +247 -10
  135. package/src/home/__tests__/feed-types.test.ts +43 -8
  136. package/src/home/__tests__/feed-writer.test.ts +59 -0
  137. package/src/home/feed-types.ts +34 -7
  138. package/src/home/feed-writer.ts +15 -6
  139. package/src/live-voice/__tests__/activity-label.test.ts +95 -0
  140. package/src/live-voice/__tests__/live-activity-reporter.test.ts +86 -5
  141. package/src/live-voice/__tests__/live-voice-agent-turn.test.ts +310 -25
  142. package/src/live-voice/__tests__/live-voice-events.test.ts +4 -4
  143. package/src/live-voice/__tests__/live-voice-triage-escalate.test.ts +8 -4
  144. package/src/live-voice/__tests__/live-voice-vad.test.ts +9 -1
  145. package/src/live-voice/activity-label.ts +169 -0
  146. package/src/live-voice/live-activity-reporter.ts +35 -6
  147. package/src/live-voice/live-voice-session.ts +282 -41
  148. package/src/live-voice/protocol.ts +35 -0
  149. package/src/messaging/providers/slack/__tests__/adapter-mention-rendering.test.ts +84 -2
  150. package/src/messaging/providers/slack/adapter.ts +86 -19
  151. package/src/messaging/providers/slack/api.test.ts +85 -1
  152. package/src/messaging/providers/slack/api.ts +121 -288
  153. package/src/messaging/providers/slack/client.ts +20 -251
  154. package/src/messaging/providers/slack/send.test.ts +4 -9
  155. package/src/messaging/providers/slack/send.ts +1 -1
  156. package/src/messaging/providers/slack/types.ts +5 -0
  157. package/src/messaging/providers/slack/web-api-transport.test.ts +181 -0
  158. package/src/messaging/providers/slack/web-api-transport.ts +367 -0
  159. package/src/messaging/providers/slack/withdraw.ts +6 -14
  160. package/src/messaging/providers/telegram-bot/send.test.ts +31 -1
  161. package/src/messaging/providers/telegram-bot/send.ts +23 -2
  162. package/src/messaging/providers/telegram-bot/withdraw.test.ts +152 -0
  163. package/src/messaging/providers/telegram-bot/withdraw.ts +166 -0
  164. package/src/monitoring/__tests__/db-integrity-sample.test.ts +18 -4
  165. package/src/monitoring/__tests__/file-descriptors.test.ts +144 -0
  166. package/src/monitoring/file-descriptors.ts +262 -0
  167. package/src/monitoring/process-memory.ts +4 -11
  168. package/src/monitoring/resource-sampler.ts +5 -0
  169. package/src/monitoring/worker.ts +12 -0
  170. package/src/notifications/__tests__/assistant-reply-producer.test.ts +740 -0
  171. package/src/notifications/__tests__/broadcaster.test.ts +347 -7
  172. package/src/notifications/__tests__/copy-composer.test.ts +53 -3
  173. package/src/notifications/__tests__/decision-engine.test.ts +349 -29
  174. package/src/notifications/__tests__/deterministic-checks.test.ts +21 -0
  175. package/src/notifications/__tests__/edit-notification.test.ts +330 -0
  176. package/src/notifications/__tests__/guardian-delivery-recorder.test.ts +71 -0
  177. package/src/notifications/__tests__/home-feed-side-effect.test.ts +170 -12
  178. package/src/notifications/adapters/macos.ts +3 -0
  179. package/src/notifications/adapters/platform.ts +90 -14
  180. package/src/notifications/adapters/telegram.ts +6 -4
  181. package/src/notifications/assistant-reply-producer.ts +219 -0
  182. package/src/notifications/broadcaster.ts +523 -317
  183. package/src/notifications/copy-composer.ts +27 -6
  184. package/src/notifications/decision-engine.ts +154 -77
  185. package/src/notifications/deterministic-checks.ts +6 -7
  186. package/src/notifications/edit-notification.ts +8 -4
  187. package/src/notifications/emit-signal.ts +49 -16
  188. package/src/notifications/guardian-delivery-recorder.ts +8 -2
  189. package/src/notifications/home-feed-side-effect.ts +43 -6
  190. package/src/notifications/notification-utils.ts +39 -4
  191. package/src/notifications/signal.ts +10 -0
  192. package/src/notifications/types.ts +17 -0
  193. package/src/persistence/bookmark-crud.ts +18 -9
  194. package/src/persistence/conversation-attention-store.ts +26 -0
  195. package/src/persistence/conversation-crud.ts +79 -32
  196. package/src/persistence/conversation-queries.ts +30 -15
  197. package/src/persistence/conversation-title-service.ts +5 -170
  198. package/src/persistence/conversation-types.ts +192 -1
  199. package/src/persistence/db-init.ts +14 -1
  200. package/src/persistence/db-maintenance.ts +5 -4
  201. package/src/persistence/embeddings/__tests__/plugin-index-qdrant-init.test.ts +219 -0
  202. package/src/persistence/embeddings/__tests__/plugin-index.test.ts +22 -0
  203. package/src/persistence/embeddings/__tests__/worker-script-version.test.ts +76 -0
  204. package/src/persistence/embeddings/embedding-local.ts +2 -1
  205. package/src/persistence/embeddings/embedding-runtime-manager.ts +29 -5
  206. package/src/persistence/embeddings/plugin-index.ts +84 -12
  207. package/src/persistence/migrations/360-add-document-workspace-path.test.ts +110 -0
  208. package/src/persistence/migrations/360-add-document-workspace-path.ts +39 -0
  209. package/src/persistence/planner-statistics.ts +14 -0
  210. package/src/persistence/schema/documents.ts +26 -11
  211. package/src/persistence/steps.ts +2 -0
  212. package/src/platform/client.test.ts +172 -2
  213. package/src/platform/client.ts +178 -55
  214. package/src/plugin-api/conversation-turn.ts +13 -0
  215. package/src/plugin-api/model-profiles.test.ts +6 -2
  216. package/src/plugins/defaults/memory/__tests__/bookmark-crud.test.ts +34 -0
  217. package/src/plugins/defaults/memory/__tests__/conversation-queries.test.ts +22 -0
  218. package/src/plugins/defaults/memory/__tests__/jobs-store-enqueue-gate.test.ts +11 -0
  219. package/src/plugins/defaults/memory/__tests__/memory-retrospective-accounting.test.ts +199 -0
  220. package/src/plugins/defaults/memory/__tests__/memory-retrospective-enqueue.test.ts +98 -1
  221. package/src/plugins/defaults/memory/__tests__/memory-retrospective-job.test.ts +105 -1
  222. package/src/plugins/defaults/memory/__tests__/memory-retrospective-sweep.test.ts +24 -2
  223. package/src/plugins/defaults/memory/__tests__/memory-retrospective-wake-chain.test.ts +524 -0
  224. package/src/plugins/defaults/memory/__tests__/memory-tier-boundary-guard.test.ts +1 -1
  225. package/src/plugins/defaults/memory/graph/__tests__/conversation-graph-memory-v2-routing.test.ts +9 -4
  226. package/src/plugins/defaults/memory/host-utils.ts +5 -0
  227. package/src/plugins/defaults/memory/memory-retrospective-accounting.ts +105 -1
  228. package/src/plugins/defaults/memory/memory-retrospective-enqueue.ts +64 -5
  229. package/src/plugins/defaults/memory/memory-retrospective-job.ts +36 -1
  230. package/src/plugins/defaults/memory/memory-retrospective-sweep.ts +12 -3
  231. package/src/plugins/defaults/memory/src/__tests__/memory-v2-simulate-route.test.ts +1 -0
  232. package/src/plugins/defaults/memory/substrate/__tests__/skill-store.test.ts +195 -1
  233. package/src/plugins/defaults/memory/substrate/consolidation-job.ts +1 -1
  234. package/src/plugins/defaults/memory/substrate/skill-store.ts +167 -29
  235. package/src/plugins/defaults/memory/v2/__tests__/injection.test.ts +131 -0
  236. package/src/plugins/defaults/memory/v2/__tests__/router.test.ts +1 -0
  237. package/src/plugins/defaults/memory/v2/activation-log-store.ts +4 -0
  238. package/src/plugins/defaults/memory/v2/injection.ts +48 -3
  239. package/src/plugins/defaults/memory/v2/rerank-local.ts +6 -2
  240. package/src/plugins/defaults/memory/v3/__tests__/pool-select.test.ts +1 -1
  241. package/src/plugins/defaults/platform-hosted/routes/reengage.ts +1 -1
  242. package/src/plugins/defaults/turn-context/injectors.ts +1 -0
  243. package/src/plugins/defaults/turn-context/unified-turn-context.ts +26 -0
  244. package/src/plugins/types.ts +17 -0
  245. package/src/prompts/templates/system-sections.ts +2 -2
  246. package/src/providers/__tests__/dispatch-connection-routing.test.ts +43 -2
  247. package/src/providers/__tests__/vellum-mismatch-routing.test.ts +8 -0
  248. package/src/providers/call-site-routing.ts +77 -14
  249. package/src/providers/connection-resolution.ts +42 -2
  250. package/src/providers/inference/__tests__/adapter-factory-openai-compatible.test.ts +127 -1
  251. package/src/providers/inference/adapter-factory.ts +70 -12
  252. package/src/providers/model-catalog.ts +0 -11
  253. package/src/providers/model-intents.ts +11 -0
  254. package/src/providers/registry.ts +8 -0
  255. package/src/providers/retry.ts +85 -17
  256. package/src/providers/types.ts +8 -1
  257. package/src/runtime/__tests__/agent-wake.test.ts +181 -0
  258. package/src/runtime/agent-wake.ts +132 -9
  259. package/src/runtime/channel-approval-types.ts +25 -0
  260. package/src/runtime/routes/__tests__/connection-routes-vs-cli-parity.test.ts +2 -2
  261. package/src/runtime/routes/__tests__/conversation-query-routes.test.ts +11 -0
  262. package/src/runtime/routes/__tests__/inference-provider-connection-routes.test.ts +121 -9
  263. package/src/runtime/routes/__tests__/platform-invoice-routes.test.ts +392 -0
  264. package/src/runtime/routes/__tests__/slack-channel-routes.test.ts +7 -0
  265. package/src/runtime/routes/__tests__/workspace-commit-routes.test.ts +63 -0
  266. package/src/runtime/routes/consolidation-routes.ts +1 -1
  267. package/src/runtime/routes/conversation-management-routes.ts +18 -6
  268. package/src/runtime/routes/conversation-query-routes.ts +14 -23
  269. package/src/runtime/routes/conversation-routes.ts +113 -8
  270. package/src/runtime/routes/credential-routes.ts +1 -5
  271. package/src/runtime/routes/documents-routes.ts +207 -12
  272. package/src/runtime/routes/identity-routes.ts +3 -93
  273. package/src/runtime/routes/inbound-stages/admission-policy.test.ts +31 -1
  274. package/src/runtime/routes/inbound-stages/admission-policy.ts +18 -0
  275. package/src/runtime/routes/inference-provider-connection-routes.ts +52 -3
  276. package/src/runtime/routes/notification-routes.ts +4 -1
  277. package/src/runtime/routes/platform-routes.ts +238 -3
  278. package/src/runtime/routes/playground/guard.ts +1 -2
  279. package/src/runtime/routes/slack-channel-routes.ts +2 -4
  280. package/src/runtime/routes/workspace-commit-routes.ts +49 -2
  281. package/src/runtime/routes/workspace-routes.ts +11 -12
  282. package/src/runtime/routes/workspace-utils.ts +49 -2
  283. package/src/runtime/services/conversation-serializer.ts +2 -4
  284. package/src/subagent/__tests__/consult-context-gating.test.ts +98 -0
  285. package/src/subagent/__tests__/consult-context-skills.test.ts +82 -0
  286. package/src/subagent/__tests__/consult-context.test.ts +84 -0
  287. package/src/subagent/__tests__/consult-prompt.test.ts +36 -0
  288. package/src/subagent/consult-context.ts +410 -0
  289. package/src/subagent/consult-prompt.ts +38 -5
  290. package/src/subagent/manager.ts +9 -4
  291. package/src/subagent/types.ts +8 -0
  292. package/src/telemetry/telemetry-event-sources.ts +7 -0
  293. package/src/telemetry/telemetry-wire-source.json +1 -1
  294. package/src/telemetry/telemetry-wire.generated.ts +1 -0
  295. package/src/telemetry/turn-events-store.ts +30 -1
  296. package/src/telemetry/types.ts +24 -0
  297. package/src/telemetry/usage-telemetry-reporter.test.ts +48 -0
  298. package/src/tools/browser/pinned-tabs.ts +3 -1
  299. package/src/tools/subagent/spawn.ts +23 -1
  300. package/src/tools/terminal/safe-env.ts +1 -0
  301. package/src/tools/ui-surface/definitions.ts +39 -1
  302. package/src/tools/ui-surface/surface-shape-docs.ts +38 -4
  303. package/src/tools/ui-surface/visual-validation.ts +787 -0
  304. package/src/tools/workflows/run-workflow.test.ts +1 -0
  305. package/src/util/__tests__/short-title.test.ts +229 -0
  306. package/src/util/__tests__/worker-compute.test.ts +65 -0
  307. package/src/util/cgroup-cpu.ts +93 -0
  308. package/src/util/errors.ts +27 -0
  309. package/src/util/process-tree.ts +19 -0
  310. package/src/util/short-title.ts +189 -0
  311. package/src/util/worker-compute.ts +83 -0
  312. package/src/workspace/byok-default-profile-ensure.ts +25 -15
  313. package/src/workspace/git-service.ts +79 -30
  314. package/src/workspace/migrations/137-repair-retired-fireworks-minimax-model-id.ts +131 -0
  315. package/src/workspace/migrations/138-backfill-home-feed-titles.ts +179 -0
  316. package/src/workspace/migrations/139-clear-renamed-cost-profile-label.ts +92 -0
  317. package/src/workspace/migrations/registry.ts +6 -0
  318. package/src/plugins/defaults/memory/substrate/constants.ts +0 -8
@@ -9,6 +9,7 @@
9
9
  * values from the context payload.
10
10
  */
11
11
 
12
+ import { normalizeTitle } from "../util/short-title.js";
12
13
  import {
13
14
  accessRequestCardTitle,
14
15
  buildAccessRequestContractText,
@@ -26,6 +27,7 @@ import {
26
27
  } from "./guardian-question-mode.js";
27
28
  import {
28
29
  nonEmpty,
30
+ NOTIFICATION_TITLE_MAX_LENGTH,
29
31
  readPayloadString,
30
32
  sanitizeIdentityField,
31
33
  } from "./notification-utils.js";
@@ -48,17 +50,35 @@ function str(value: unknown, fallback: string): string {
48
50
  /**
49
51
  * Derive a short notification title from a message body. Trims to the
50
52
  * first sentence terminator when present, then caps the result at
51
- * 60 characters with an ellipsis.
53
+ * `NOTIFICATION_TITLE_MAX_LENGTH` characters with an ellipsis.
52
54
  */
53
55
  export function deriveTitle(body: string): string {
54
56
  const firstSentenceEnd = body.search(/[.!?](\s|$)/);
55
57
  const candidate =
56
58
  firstSentenceEnd > 0 ? body.slice(0, firstSentenceEnd + 1) : body;
57
- return candidate.length > 60
58
- ? candidate.slice(0, 60).trim() + "\u2026"
59
+ return candidate.length > NOTIFICATION_TITLE_MAX_LENGTH
60
+ ? candidate.slice(0, NOTIFICATION_TITLE_MAX_LENGTH).trim() + "\u2026"
59
61
  : candidate.trim();
60
62
  }
61
63
 
64
+ /**
65
+ * Clean a producer-authored title, falling back to one derived from the body
66
+ * when `normalizeTitle` returns its empty-string rejection signal.
67
+ *
68
+ * The decision engine's pass-through path applies the same clamp, so a signal
69
+ * carrying `requestedTitle` renders the same headline whether or not the LLM
70
+ * classifier was reachable.
71
+ *
72
+ * The two branches carry different budgets. An accepted authored title is
73
+ * bounded by `normalizeTitle` at 40 characters. A rejected one falls through to
74
+ * `deriveTitle`, which never sees the normalizer and applies
75
+ * `NOTIFICATION_TITLE_MAX_LENGTH` (60) plus a trailing ellipsis, so a derived
76
+ * title can reach 61 characters.
77
+ */
78
+ export function resolveTitle(raw: string | undefined, body: string): string {
79
+ return normalizeTitle(raw ?? "") || deriveTitle(body);
80
+ }
81
+
62
82
  /**
63
83
  * Build a display label for a trusted-contact actor (requester or decider),
64
84
  * preferring the display name, then a Slack `<@id>` mention for raw Slack user
@@ -290,9 +310,10 @@ export function composeFallbackCopy(
290
310
  readPayloadString(signal.contextPayload, "requestedMessage"),
291
311
  );
292
312
  if (msg) {
293
- const title =
294
- nonEmpty(readPayloadString(signal.contextPayload, "requestedTitle")) ??
295
- deriveTitle(msg);
313
+ const title = resolveTitle(
314
+ readPayloadString(signal.contextPayload, "requestedTitle"),
315
+ msg,
316
+ );
296
317
  const baseCopy: RenderedChannelCopy = {
297
318
  title,
298
319
  body: msg,
@@ -44,7 +44,7 @@ import {
44
44
  type ConversationCandidateSet,
45
45
  serializeCandidatesForPrompt,
46
46
  } from "./conversation-candidates.js";
47
- import { composeFallbackCopy, deriveTitle } from "./copy-composer.js";
47
+ import { composeFallbackCopy, resolveTitle } from "./copy-composer.js";
48
48
  import { createDecision } from "./decisions-store.js";
49
49
  import {
50
50
  buildGuardianRequestCodeInstruction,
@@ -54,7 +54,11 @@ import {
54
54
  stripConflictingGuardianRequestInstructions,
55
55
  stripGuardianRequestCodeInstructions,
56
56
  } from "./guardian-question-mode.js";
57
- import { nonEmpty, readPayloadString } from "./notification-utils.js";
57
+ import {
58
+ nonEmpty,
59
+ readPayloadObject,
60
+ readPayloadString,
61
+ } from "./notification-utils.js";
58
62
  import { getPreferenceSummary } from "./preference-summary.js";
59
63
  import type { NotificationSignal, RoutingIntent } from "./signal.js";
60
64
  import type {
@@ -78,6 +82,16 @@ const PROMPT_VERSION = "v4";
78
82
  */
79
83
  const MAX_IDENTITY_CONTEXT_CHARS = 2000;
80
84
 
85
+ /**
86
+ * Delivery scope for `chat.assistant_reply` signals: the platform channel is a
87
+ * push_only relay that never materializes a conversation, so these signals
88
+ * reach the user exclusively as a push. Adding a channel here widens delivery
89
+ * to it.
90
+ */
91
+ const ASSISTANT_REPLY_CHANNELS = [
92
+ "platform",
93
+ ] as const satisfies readonly NotificationChannel[];
94
+
81
95
  // ── System prompt ──────────────────────────────────────────────────────
82
96
 
83
97
  function buildSystemPrompt(
@@ -215,6 +229,26 @@ function buildUserPrompt(signal: NotificationSignal): string {
215
229
 
216
230
  // ── Tool definition ────────────────────────────────────────────────────
217
231
 
232
+ /**
233
+ * Spec for the per-channel notification title. Mirrors the conversation title
234
+ * prompt (`persistence/conversation-title-service.ts`), which gets clean
235
+ * noun-phrase headlines out of this same model profile.
236
+ */
237
+ const TITLE_FIELD_DESCRIPTION = [
238
+ "Scannable headline naming the TOPIC of this notification, not a summary of it.",
239
+ "Rules:",
240
+ "- 2 to 5 words. Longer titles are unacceptable, ruthlessly compress",
241
+ "- 40 characters absolute maximum, longer titles get truncated and look broken",
242
+ "- A noun phrase naming the topic, never a sentence, question, or greeting (e.g. 'Platform Standup', 'Nightly Backup Failure')",
243
+ "- Do NOT restate, summarize, or echo the body. The title and the body must carry different information",
244
+ "- No quotes, no markdown, no trailing punctuation",
245
+ "- Never describe missing or thin context. Titles like 'Notification', 'Update', 'Missing Context' are forbidden. Extract a topic from the words that ARE present",
246
+ "Examples:",
247
+ "- Body 'Your 9am standup with the platform team starts in 5 minutes' -> 'Platform Standup', NOT 'Standup Starts In 5 Minutes'",
248
+ "- Body 'The nightly backup job failed on db-primary at 02:14' -> 'Nightly Backup Failure', NOT 'Nightly Backup Job Failed On db-primary'",
249
+ "- Body 'Alice replied about the Q3 pricing deck and wants your notes' -> 'Q3 Pricing Deck', NOT 'Alice Replied About The Pricing Deck'",
250
+ ].join("\n");
251
+
218
252
  function buildDecisionTool(availableChannels: NotificationChannel[]) {
219
253
  return {
220
254
  name: "record_notification_decision",
@@ -250,7 +284,7 @@ function buildDecisionTool(availableChannels: NotificationChannel[]) {
250
284
  properties: {
251
285
  title: {
252
286
  type: "string",
253
- description: "Short notification popup title (≤ 8 words)",
287
+ description: TITLE_FIELD_DESCRIPTION,
254
288
  },
255
289
  body: {
256
290
  type: "string",
@@ -431,7 +465,7 @@ function validateDecisionOutput(
431
465
  );
432
466
  }
433
467
  renderedCopy[ch] = {
434
- title: c.title,
468
+ title: resolveTitle(c.title, c.body),
435
469
  body: c.body,
436
470
  deliveryText:
437
471
  typeof c.deliveryText === "string" ? c.deliveryText : undefined,
@@ -855,6 +889,70 @@ function ensureInviteFlowDirectiveInCopy(
855
889
  };
856
890
  }
857
891
 
892
+ // ── Producer pass-through decisions ────────────────────────────────────
893
+
894
+ /**
895
+ * Build, guard, and persist a decision whose copy came verbatim from the
896
+ * producer, bypassing the LLM classifier. The title falls back to one derived
897
+ * from the body when the producer supplies none or supplies one that fails
898
+ * normalization.
899
+ *
900
+ * `renderedCopy` and `conversationActions` are populated for every available
901
+ * channel, not just `selectedChannels`: downstream guards (routing-intent
902
+ * expansion in `enforceRoutingIntent`, urgency-forced vellum prepending in
903
+ * `emit-signal`) may widen `selectedChannels` afterwards, and pre-seeding
904
+ * keeps the verbatim message from degrading to an empty
905
+ * `composeFallbackCopy` body.
906
+ */
907
+ function buildPassThroughDecision(params: {
908
+ signal: NotificationSignal;
909
+ availableChannels: NotificationChannel[];
910
+ selectedChannels: NotificationChannel[];
911
+ body: string;
912
+ reasoningSummary: string;
913
+ }): NotificationDecision {
914
+ const { availableChannels, body, signal } = params;
915
+ const title = resolveTitle(
916
+ readPayloadString(signal.contextPayload, "requestedTitle"),
917
+ body,
918
+ );
919
+ const deepLinkTarget = readPayloadObject(
920
+ signal.contextPayload,
921
+ "deepLinkMetadata",
922
+ );
923
+ let decision: NotificationDecision = {
924
+ shouldNotify: params.selectedChannels.length > 0,
925
+ selectedChannels: params.selectedChannels,
926
+ reasoningSummary: params.reasoningSummary,
927
+ renderedCopy: Object.fromEntries(
928
+ availableChannels.map((ch) => [
929
+ ch,
930
+ { title, body, conversationSeedMessage: body },
931
+ ]),
932
+ ) as NotificationDecision["renderedCopy"],
933
+ conversationActions: Object.fromEntries(
934
+ availableChannels.map((ch) => [ch, { action: "start_new" as const }]),
935
+ ) as NotificationDecision["conversationActions"],
936
+ // A producer-supplied key names what it wants collapsed; the signal id is
937
+ // a per-emit fallback that collapses nothing.
938
+ dedupeKey: signal.dedupeKey ?? signal.signalId,
939
+ confidence: 1.0,
940
+ fallbackUsed: false,
941
+ verbatimCopy: true,
942
+ ...(deepLinkTarget ? { deepLinkTarget } : {}),
943
+ };
944
+ decision = enforceGuardianRequestCode(decision, signal);
945
+ decision = enforceToolApprovalSeedBlocks(decision, signal);
946
+ decision = enforceAccessRequestInstructions(decision, signal);
947
+ decision = enforceGuardianRequestConversationAffinity(decision, signal);
948
+ decision = enforceConversationAffinity(
949
+ decision,
950
+ signal.conversationAffinityHint,
951
+ );
952
+ decision.persistedDecisionId = persistDecision(signal, decision);
953
+ return decision;
954
+ }
955
+
858
956
  // ── Core evaluation function ───────────────────────────────────────────
859
957
 
860
958
  export async function evaluateSignal(
@@ -862,50 +960,20 @@ export async function evaluateSignal(
862
960
  availableChannels: NotificationChannel[],
863
961
  preferenceContext?: string,
864
962
  ): Promise<NotificationDecision> {
865
- // When no explicit preference context is provided, load the user's
866
- // stored notification preferences from the memory-backed store.
867
- // Wrapped in try/catch so a DB failure doesn't break the decision path.
868
- let resolvedPreferenceContext = preferenceContext;
869
- if (resolvedPreferenceContext === undefined) {
870
- try {
871
- resolvedPreferenceContext = getPreferenceSummary() ?? undefined;
872
- } catch (err) {
873
- const errMsg = err instanceof Error ? err.message : String(err);
874
- log.warn(
875
- { err: errMsg },
876
- "Failed to load preference summary, proceeding without preferences",
877
- );
878
- resolvedPreferenceContext = undefined;
879
- }
880
- }
881
-
882
- // Build conversation candidate set for reuse decisions. Wrapped in try/catch
883
- // so candidate lookup failures do not block the decision path.
884
- let candidateSet: ConversationCandidateSet | undefined;
885
- try {
886
- candidateSet = await buildConversationCandidates(availableChannels);
887
- } catch (err) {
888
- const errMsg = err instanceof Error ? err.message : String(err);
889
- log.warn(
890
- { err: errMsg },
891
- "Failed to build conversation candidates, proceeding without candidates",
892
- );
893
- }
963
+ // Pass-through branches run before the preference and candidate loads
964
+ // below: their copy and routing are producer-supplied, so neither DB
965
+ // lookup can influence the outcome.
894
966
 
895
967
  // Assistant-tool pass-through: when a producer hands us a verbatim
896
968
  // message body via contextPayload.requestedMessage, skip the LLM
897
969
  // classifier entirely. The producer has already done the routing and
898
- // copy decisions we just enforce the standard post-decision guards
970
+ // copy decisions, we just enforce the standard post-decision guards
899
971
  // and persist the result.
900
972
  const requestedBody = nonEmpty(
901
973
  readPayloadString(signal.contextPayload, "requestedMessage"),
902
974
  );
903
975
  if (signal.sourceChannel === "assistant_tool" && requestedBody) {
904
976
  const payload = signal.contextPayload as Record<string, unknown>;
905
- const body = requestedBody;
906
- const title =
907
- nonEmpty(readPayloadString(signal.contextPayload, "requestedTitle")) ??
908
- deriveTitle(body);
909
977
  const isUrgent =
910
978
  signal.attentionHints.urgency === "critical" ||
911
979
  signal.attentionHints.urgency === "high";
@@ -939,48 +1007,57 @@ export async function evaluateSignal(
939
1007
  );
940
1008
  }
941
1009
  }
942
- // Thread `--deep-link-metadata` through when supplied as a plain object.
943
- const deepLinkTarget =
944
- payload.deepLinkMetadata != null &&
945
- typeof payload.deepLinkMetadata === "object" &&
946
- !Array.isArray(payload.deepLinkMetadata)
947
- ? (payload.deepLinkMetadata as Record<string, unknown>)
948
- : undefined;
949
- // Populate renderedCopy and conversationActions for every available
950
- // channel — not just `selectedChannels`. Downstream guards
951
- // (routing-intent expansion in `enforceRoutingIntent`, urgency-forced
952
- // vellum prepending in `emit-signal`) may widen `selectedChannels`
953
- // beyond what we picked here. Pre-seeding copy for all channels ensures
954
- // the verbatim message survives those expansions rather than falling
955
- // back to an empty `composeFallbackCopy` body.
956
- let decision: NotificationDecision = {
957
- shouldNotify: selectedChannels.length > 0,
1010
+ return buildPassThroughDecision({
1011
+ signal,
1012
+ availableChannels,
958
1013
  selectedChannels,
1014
+ body: requestedBody,
959
1015
  reasoningSummary: "assistant_tool pass-through",
960
- renderedCopy: Object.fromEntries(
961
- availableChannels.map((ch) => [
962
- ch,
963
- { title, body, conversationSeedMessage: body },
964
- ]),
965
- ) as NotificationDecision["renderedCopy"],
966
- conversationActions: Object.fromEntries(
967
- availableChannels.map((ch) => [ch, { action: "start_new" as const }]),
968
- ) as NotificationDecision["conversationActions"],
969
- dedupeKey: signal.signalId,
970
- confidence: 1.0,
971
- fallbackUsed: false,
972
- ...(deepLinkTarget ? { deepLinkTarget } : {}),
973
- };
974
- decision = enforceGuardianRequestCode(decision, signal);
975
- decision = enforceToolApprovalSeedBlocks(decision, signal);
976
- decision = enforceAccessRequestInstructions(decision, signal);
977
- decision = enforceGuardianRequestConversationAffinity(decision, signal);
978
- decision = enforceConversationAffinity(
979
- decision,
980
- signal.conversationAffinityHint,
1016
+ });
1017
+ }
1018
+
1019
+ // Assistant-reply pass-through: the delivery scope is fixed
1020
+ // (ASSISTANT_REPLY_CHANNELS), so the LLM classifier has nothing to decide.
1021
+ if (signal.sourceEventName === "chat.assistant_reply" && requestedBody) {
1022
+ return buildPassThroughDecision({
1023
+ signal,
1024
+ availableChannels,
1025
+ selectedChannels: ASSISTANT_REPLY_CHANNELS.filter((ch) =>
1026
+ availableChannels.includes(ch),
1027
+ ),
1028
+ body: requestedBody,
1029
+ reasoningSummary: "assistant_reply pass-through",
1030
+ });
1031
+ }
1032
+
1033
+ // When no explicit preference context is provided, load the user's
1034
+ // stored notification preferences from the memory-backed store.
1035
+ // Wrapped in try/catch so a DB failure doesn't break the decision path.
1036
+ let resolvedPreferenceContext = preferenceContext;
1037
+ if (resolvedPreferenceContext === undefined) {
1038
+ try {
1039
+ resolvedPreferenceContext = getPreferenceSummary() ?? undefined;
1040
+ } catch (err) {
1041
+ const errMsg = err instanceof Error ? err.message : String(err);
1042
+ log.warn(
1043
+ { err: errMsg },
1044
+ "Failed to load preference summary, proceeding without preferences",
1045
+ );
1046
+ resolvedPreferenceContext = undefined;
1047
+ }
1048
+ }
1049
+
1050
+ // Build conversation candidate set for reuse decisions. Wrapped in try/catch
1051
+ // so candidate lookup failures do not block the decision path.
1052
+ let candidateSet: ConversationCandidateSet | undefined;
1053
+ try {
1054
+ candidateSet = await buildConversationCandidates(availableChannels);
1055
+ } catch (err) {
1056
+ const errMsg = err instanceof Error ? err.message : String(err);
1057
+ log.warn(
1058
+ { err: errMsg },
1059
+ "Failed to build conversation candidates, proceeding without candidates",
981
1060
  );
982
- decision.persistedDecisionId = persistDecision(signal, decision);
983
- return decision;
984
1061
  }
985
1062
 
986
1063
  const provider = await getConfiguredProvider("notificationDecision");
@@ -269,10 +269,10 @@ function checkDedupe(
269
269
  * case, require `composeFallbackCopy` to yield a non-empty body for
270
270
  * at least one selected channel; otherwise fail-closed.
271
271
  *
272
- * The event-name-match branch is skipped for `assistant_tool`
273
- * pass-through decisions because the producer supplied the body
274
- * verbatim — a coincidental match with the event name is the user's
275
- * intent, not a fallback leak.
272
+ * The event-name-match branch is skipped for decisions flagged
273
+ * `verbatimCopy` because the producer supplied the body verbatim, so a
274
+ * coincidental match with the event name is the producer's intent, not a
275
+ * fallback leak.
276
276
  */
277
277
  function checkRenderedCopyQuality(
278
278
  signal: NotificationSignal,
@@ -282,8 +282,7 @@ function checkRenderedCopyQuality(
282
282
  return { passed: true };
283
283
  }
284
284
 
285
- const isAssistantToolPassthrough =
286
- decision.reasoningSummary === "assistant_tool pass-through";
285
+ const isPassthrough = decision.verbatimCopy === true;
287
286
  const normalizedEventName = signal.sourceEventName
288
287
  .replace(/[._]/g, " ")
289
288
  .toLowerCase()
@@ -304,7 +303,7 @@ function checkRenderedCopyQuality(
304
303
  reason: "rendered copy body is empty",
305
304
  };
306
305
  }
307
- if (isAssistantToolPassthrough) {
306
+ if (isPassthrough) {
308
307
  continue;
309
308
  }
310
309
  const normalizedBody = trimmedBody.toLowerCase();
@@ -22,6 +22,7 @@ import {
22
22
  updateDeliveryRenderedCopy,
23
23
  } from "./deliveries-store.js";
24
24
  import { getBroadcaster } from "./emit-signal.js";
25
+ import { nonEmpty } from "./notification-utils.js";
25
26
  import type { NotificationChannel } from "./types.js";
26
27
 
27
28
  const log = getLogger("edit-notification");
@@ -88,8 +89,12 @@ export async function editNotification(
88
89
  ): Promise<EditNotificationResult | null> {
89
90
  const feedItemId = normalizeFeedItemId(params.id);
90
91
 
92
+ // Titles are never cleared, so a blank one is dropped before it can
93
+ // reach the feed patch or a channel update.
94
+ const title = nonEmpty(params.title);
95
+
91
96
  const feedItem = await patchFeedItemContent(feedItemId, {
92
- title: params.title,
97
+ title,
93
98
  summary: params.body,
94
99
  urgency: params.urgency,
95
100
  status: params.status,
@@ -102,8 +107,7 @@ export async function editNotification(
102
107
  // Only edit channel messages when the user-visible text changed.
103
108
  // Urgency/status are feed-only — pushing a channel update for those
104
109
  // alone would re-deliver the same body and confuse the recipient.
105
- const shouldUpdateChannels =
106
- params.title !== undefined || params.body !== undefined;
110
+ const shouldUpdateChannels = title !== undefined || params.body !== undefined;
107
111
  if (!shouldUpdateChannels) {
108
112
  return { feedItem, channels: [] };
109
113
  }
@@ -120,7 +124,7 @@ export async function editNotification(
120
124
 
121
125
  const deliveries = findDeliveriesByDecisionId(decision.id);
122
126
  const channels = await updateChannelDeliveries(deliveries, {
123
- title: params.title,
127
+ title,
124
128
  body: params.body,
125
129
  });
126
130
 
@@ -18,6 +18,7 @@ import {
18
18
  guardianForChannel,
19
19
  } from "../contacts/guardian-delivery-reader.js";
20
20
  import type { ConversationCreateType } from "../persistence/conversation-types.js";
21
+ import { isPlatformClientConfigured } from "../platform/client.js";
21
22
  import { broadcastMessage } from "../runtime/assistant-event-hub.js";
22
23
  import { getLogger } from "../util/logger.js";
23
24
  import { VellumAdapter } from "./adapters/macos.js";
@@ -253,6 +254,7 @@ export async function emitNotificationSignal<TEventName extends string>(
253
254
  contextPayload: (params.contextPayload ??
254
255
  {}) as NotificationContextPayload<TEventName>,
255
256
  attentionHints: params.attentionHints,
257
+ dedupeKey: params.dedupeKey,
256
258
  routingIntent: params.routingIntent,
257
259
  routingHints: params.routingHints,
258
260
  conversationAffinityHint: params.conversationAffinityHint,
@@ -320,25 +322,55 @@ export async function emitNotificationSignal<TEventName extends string>(
320
322
 
321
323
  let decision = await evaluateSignal(signal, connectedChannels);
322
324
 
323
- // Step 2.5a: High/critical urgency signals always get a system
324
- // notification via the vellum channel, regardless of what the
325
- // decision engine selected. This ensures macOS surfaces a banner
326
- // even when the app is focused.
325
+ // Baseline for the re-persist check below. Captured before the policy
326
+ // steps (2.5a/2.5b/2.5c) so any of them replacing the decision triggers
327
+ // the re-persist and the stored row matches what is dispatched.
328
+ const prePolicyDecision = decision;
329
+
330
+ // Step 2.5a: High/critical urgency signals always get both the in-app
331
+ // system notification (vellum) and the remote push (platform),
332
+ // regardless of what the decision engine selected. macOS surfaces a
333
+ // banner even when the app is focused, and a suspended iOS device is
334
+ // only reachable via APNs. Platform is only forced when the daemon has
335
+ // platform credentials and an assistant id -- on unbound daemons the
336
+ // dispatch can never succeed and would write a failed delivery row per
337
+ // signal. The probe is deadline-bounded internally, so a slow credential
338
+ // backend cannot stall the urgent dispatch.
339
+ //
340
+ // Vellum PREPENDS and platform APPENDS: the broadcaster re-sorts by
341
+ // dispatch rank, so selection order only matters to single_channel
342
+ // enforcement's first-selected fallback (step 2.5b), which must keep
343
+ // the in-app banner rather than a push the server may skip.
327
344
  const urgency = signal.attentionHints.urgency;
328
345
  if (
329
346
  (urgency === "high" || urgency === "critical") &&
330
- decision.shouldNotify &&
331
- !decision.selectedChannels.includes("vellum")
347
+ decision.shouldNotify
332
348
  ) {
333
- decision = {
334
- ...decision,
335
- selectedChannels: ["vellum", ...decision.selectedChannels],
336
- reasoningSummary: `${decision.reasoningSummary} (vellum forced: ${urgency} urgency)`,
337
- };
349
+ const selectedChannels: NotificationChannel[] = [
350
+ ...decision.selectedChannels,
351
+ ];
352
+ const forcedChannels: NotificationChannel[] = [];
353
+ if (!selectedChannels.includes("vellum")) {
354
+ selectedChannels.unshift("vellum");
355
+ forcedChannels.push("vellum");
356
+ }
357
+ if (
358
+ !selectedChannels.includes("platform") &&
359
+ (await isPlatformClientConfigured())
360
+ ) {
361
+ selectedChannels.push("platform");
362
+ forcedChannels.push("platform");
363
+ }
364
+ if (forcedChannels.length > 0) {
365
+ decision = {
366
+ ...decision,
367
+ selectedChannels,
368
+ reasoningSummary: `${decision.reasoningSummary} (${forcedChannels.join(", ")} forced: ${urgency} urgency)`,
369
+ };
370
+ }
338
371
  }
339
372
 
340
373
  // Step 2.5b: Enforce routing intent policy (fire-time guard)
341
- const preEnforcementDecision = decision;
342
374
  decision = enforceRoutingIntent(
343
375
  decision,
344
376
  signal.routingIntent,
@@ -368,9 +400,10 @@ export async function emitNotificationSignal<TEventName extends string>(
368
400
  };
369
401
  }
370
402
 
371
- // Re-persist the decision if routing intent enforcement changed it,
372
- // so the stored decision row matches what is actually dispatched.
373
- if (decision !== preEnforcementDecision && decision.persistedDecisionId) {
403
+ // Re-persist the decision if any policy step changed it (urgency channel
404
+ // forcing, routing intent enforcement, or the access-request vellum
405
+ // floor), so the stored decision row matches what is actually dispatched.
406
+ if (decision !== prePolicyDecision && decision.persistedDecisionId) {
374
407
  try {
375
408
  updateDecision(decision.persistedDecisionId, {
376
409
  selectedChannels: decision.selectedChannels,
@@ -384,7 +417,7 @@ export async function emitNotificationSignal<TEventName extends string>(
384
417
  } catch (err) {
385
418
  log.warn(
386
419
  { err, signalId },
387
- "Failed to re-persist decision after routing intent enforcement",
420
+ "Failed to re-persist decision after policy enforcement",
388
421
  );
389
422
  }
390
423
  }
@@ -91,8 +91,10 @@ export async function recordApprovalCardDelivery(
91
91
  * that row's id as `vellumDeliveryId` and it is reused (only its status applied)
92
92
  * — otherwise the vellum row is created here from the result.
93
93
  *
94
- * Every result records the internal `conversationId` the card is shown in, so a
95
- * conversation's pending cards can be found uniformly regardless of channel.
94
+ * Every addressable result records the internal `conversationId` the card is
95
+ * shown in, so a conversation's pending cards can be found uniformly
96
+ * regardless of channel. Platform push results are skipped entirely: a push
97
+ * has no channel-native card surface to address back to.
96
98
  * Channel results additionally carry the chat (`destination`) and channel-native
97
99
  * id (`messageId`) used to match inbound replies/reactions; a blank `destination`
98
100
  * is recorded as unknown rather than persisting the literal channel name as a
@@ -112,6 +114,10 @@ export async function recordGuardianRequestDeliveries(params: {
112
114
  let vellumDeliveryId = params.vellumDeliveryId;
113
115
 
114
116
  for (const result of deliveryResults) {
117
+ if (result.channel === "platform") {
118
+ // A push is not an independently addressable card surface -- no row.
119
+ continue;
120
+ }
115
121
  let deliveryId: string | undefined;
116
122
  if (result.channel === "vellum") {
117
123
  if (!vellumDeliveryId) {
@@ -20,7 +20,9 @@ import { appendFeedItem } from "../home/feed-writer.js";
20
20
  import { getConversation } from "../persistence/conversation-crud.js";
21
21
  import { isBackgroundConversationType } from "../persistence/conversation-types.js";
22
22
  import { getLogger } from "../util/logger.js";
23
+ import { normalizeTitle, stripMarkdown } from "../util/short-title.js";
23
24
  import { isConversationSeedSane } from "./conversation-seed-composer.js";
25
+ import { deriveTitle } from "./copy-composer.js";
24
26
  import { readPayloadString } from "./notification-utils.js";
25
27
  import type { NotificationSignal } from "./signal.js";
26
28
  import type { NotificationDecision, RenderedChannelCopy } from "./types.js";
@@ -66,11 +68,6 @@ export async function writeHomeFeedItemForSignal(
66
68
  readPayloadString(signal.contextPayload, "body") ??
67
69
  readPayloadString(signal.contextPayload, "requestedMessage");
68
70
 
69
- // Source the title from the payload only. The LLM's `renderedCopy.title`
70
- // often echoes the body when no explicit title was passed, which stutters
71
- // against `summary` in the row. Leave undefined when absent; renderers
72
- // fall back to `summary`.
73
- const resolvedTitle = payloadTitle?.trim() || undefined;
74
71
  // Prefer conversationSeedMessage over body for the home feed: the seed
75
72
  // message is richer and may contain structured markdown (lists, headers,
76
73
  // bold) that the detail panel renders. The popup-oriented `body` is
@@ -91,6 +88,15 @@ export async function writeHomeFeedItemForSignal(
91
88
  return null;
92
89
  }
93
90
 
91
+ // Title order: payload, then the rendered copy, then a headline derived
92
+ // from the summary. `normalizeTitle` returns "" for empty, prose-shaped, and
93
+ // meta-failure candidates; the derivation always yields something, so every
94
+ // feed item lands with a title.
95
+ const resolvedTitle =
96
+ normalizeTitle(payloadTitle ?? "") ||
97
+ normalizeTitle(renderedCopy?.title ?? "") ||
98
+ deriveFallbackTitle(resolvedSummary);
99
+
94
100
  const urgency = signal.attentionHints.urgency;
95
101
  const now = new Date().toISOString();
96
102
 
@@ -122,7 +128,7 @@ export async function writeHomeFeedItemForSignal(
122
128
  id: `notif:${signal.signalId}`,
123
129
  type: "notification",
124
130
  priority: 50,
125
- ...(resolvedTitle ? { title: resolvedTitle } : {}),
131
+ title: resolvedTitle,
126
132
  summary: resolvedSummary,
127
133
  timestamp: now,
128
134
  createdAt: now,
@@ -150,6 +156,37 @@ export async function writeHomeFeedItemForSignal(
150
156
  return item;
151
157
  }
152
158
 
159
+ /**
160
+ * Derive the terminal fallback headline from the summary.
161
+ *
162
+ * The summary is preferentially a conversation seed, which carries structured
163
+ * markdown and hard line breaks, so flatten it to plain single-line text
164
+ * before slicing a headline off the front. A non-empty summary always yields a
165
+ * non-empty title.
166
+ */
167
+ function deriveFallbackTitle(summary: string): string {
168
+ // Block markers are line-anchored, so they have to go before the whitespace
169
+ // collapse, and before `stripMarkdown` (whose inline-code rule would chew a
170
+ // fence into a stray backtick). Ordered markers matter most: the seed prompt
171
+ // asks the model for numbered lists, and a leading `1.` also reads as a
172
+ // sentence terminator, which would truncate the title to the first digit.
173
+ // The leading-space allowance and rule set mirror `flattenToPlainText` in
174
+ // workspace migration 138, which must stay self-contained and so cannot share
175
+ // this code. A backfilled title and a freshly written one have to match.
176
+ const deblocked = summary
177
+ .replace(/^\s{0,3}(?:```|~~~).*$/gm, "")
178
+ .replace(/^\s{0,3}#{1,6}\s+/gm, "")
179
+ .replace(/^\s{0,3}>\s?/gm, "")
180
+ .replace(/^\s{0,3}(?:[-*+]|\d+[.)])\s+/gm, "");
181
+ const plain = flattenWhitespace(stripMarkdown(deblocked));
182
+ return deriveTitle(plain || flattenWhitespace(summary));
183
+ }
184
+
185
+ /** Collapse whitespace runs, including hard line breaks, into single spaces. */
186
+ function flattenWhitespace(text: string): string {
187
+ return text.replace(/\s+/g, " ").trim();
188
+ }
189
+
153
190
  // ── Category & detail-panel derivation ────────────────────────────────
154
191
 
155
192
  const EVENT_CATEGORY_MAP: Record<string, FeedItemCategory> = {