@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
@@ -1,11 +1,13 @@
1
1
  /**
2
2
  * Shared leaf utilities for the notification pipeline.
3
3
  *
4
- * This module has no intra-pipeline imports it depends only on Node
5
- * stdlib so any notification module can import it without creating
4
+ * This module has no intra-pipeline imports (only Node stdlib and leaf
5
+ * utilities), so any notification module can import it without creating
6
6
  * circular dependencies.
7
7
  */
8
8
 
9
+ import { isPlainObject } from "../util/object.js";
10
+
9
11
  // ── String helpers ──────────────────────────────────────────────────────────
10
12
 
11
13
  /** Return `value` trimmed, or `undefined` when blank/nullish. */
@@ -26,13 +28,29 @@ export function readPayloadString(
26
28
  payload: unknown,
27
29
  key: string,
28
30
  ): string | undefined {
29
- if (!payload || typeof payload !== "object" || Array.isArray(payload)) {
31
+ if (!isPlainObject(payload)) {
30
32
  return undefined;
31
33
  }
32
- const value = (payload as Record<string, unknown>)[key];
34
+ const value = payload[key];
33
35
  return typeof value === "string" ? value : undefined;
34
36
  }
35
37
 
38
+ /**
39
+ * Safely read a plain-object property from an unknown-typed payload object.
40
+ * Returns `undefined` when the payload is not an object or the key does not
41
+ * hold a plain object (arrays and `null` are rejected).
42
+ */
43
+ export function readPayloadObject(
44
+ payload: unknown,
45
+ key: string,
46
+ ): Record<string, unknown> | undefined {
47
+ if (!isPlainObject(payload)) {
48
+ return undefined;
49
+ }
50
+ const value = payload[key];
51
+ return isPlainObject(value) ? value : undefined;
52
+ }
53
+
36
54
  /** Truncate `text` to `maxLength`, appending "…" when exceeded. */
37
55
  export function truncate(text: string, maxLength: number): string {
38
56
  if (text.length <= maxLength) {
@@ -68,3 +86,20 @@ export const MESSAGE_PREVIEW_MAX_LENGTH = 200;
68
86
  export function sanitizeMessagePreview(value: string): string {
69
87
  return sanitize(value, MESSAGE_PREVIEW_MAX_LENGTH);
70
88
  }
89
+
90
+ /** Character budget every notification title shares. */
91
+ export const NOTIFICATION_TITLE_MAX_LENGTH = 60;
92
+
93
+ /**
94
+ * Flatten an untrusted notification title onto a single line, stripping control
95
+ * characters and newlines and bounding it at `NOTIFICATION_TITLE_MAX_LENGTH`.
96
+ *
97
+ * The newline flattening is the load-bearing part: downstream consumers re-run
98
+ * the value through `normalizeTitle`, whose prose guard discards any string
99
+ * containing a newline outright, so a multi-line conversation title would be
100
+ * thrown away wholesale instead of salvaged. The length bound is a backstop;
101
+ * `normalizeTitle`'s tighter 40-character budget is what callers observe.
102
+ */
103
+ export function sanitizeNotificationTitle(value: string): string {
104
+ return sanitize(value, NOTIFICATION_TITLE_MAX_LENGTH);
105
+ }
@@ -122,6 +122,11 @@ export const NOTIFICATION_SOURCE_EVENT_NAMES = [
122
122
  description:
123
123
  "OAuth credential health issue detected (expired, revoked, missing scopes)",
124
124
  },
125
+ {
126
+ id: "chat.assistant_reply",
127
+ description:
128
+ "Assistant finished replying to a user-initiated turn the user is no longer watching",
129
+ },
125
130
  {
126
131
  id: "telegram.webhook_health_alert",
127
132
  description:
@@ -231,6 +236,11 @@ export interface NotificationSignal<TEventName extends string = string> {
231
236
  sourceEventName: TEventName; // free-form: 'reminder_fired', 'guardian_question', etc.
232
237
  contextPayload: NotificationContextPayload<TEventName>;
233
238
  attentionHints: AttentionHints;
239
+ /**
240
+ * Producer-supplied deduplication key. Deterministic decision paths reuse it
241
+ * as the decision's dedupeKey so the decision row matches the event row.
242
+ */
243
+ dedupeKey?: string;
234
244
  /** Routing intent from the source (e.g. reminder). Controls post-decision channel enforcement. */
235
245
  routingIntent?: RoutingIntent;
236
246
  /** Free-form hints from the source for the decision engine (e.g. preferred channels). */
@@ -58,6 +58,10 @@ export const DeliveryResultSchema = z.object({
58
58
  success: z.boolean(),
59
59
  error: z.string().optional(),
60
60
  messageId: z.string().optional(),
61
+ /** Set only by the platform push adapter: true when the platform accepted
62
+ * at least one device push for delivery (not proof of device receipt). */
63
+ remotePushAccepted: z.boolean().optional(),
64
+ remotePushPlatforms: z.array(z.enum(["ios", "android"])).optional(),
61
65
  });
62
66
  export type DeliveryResult = z.infer<typeof DeliveryResultSchema>;
63
67
 
@@ -95,6 +99,7 @@ export type RenderedChannelCopy = z.infer<typeof RenderedChannelCopySchema>;
95
99
 
96
100
  export const ChannelDeliveryPayloadSchema = z.object({
97
101
  deliveryId: z.string().optional(),
102
+ correlationId: z.string().optional(),
98
103
  sourceEventName: z.string(),
99
104
  copy: RenderedChannelCopySchema,
100
105
  deepLinkTarget: z.record(z.string(), z.unknown()).optional(),
@@ -106,6 +111,11 @@ export const ChannelDeliveryPayloadSchema = z.object({
106
111
  * broadcaster so channel adapters render it without re-parsing
107
112
  * `contextPayload`. */
108
113
  toolApprovalSource: ToolApprovalSourceViewSchema.optional(),
114
+ /** True when the platform (APNs) channel accepted a remote push for this
115
+ * delivery. Set only on the vellum channel's payload so clients can avoid
116
+ * double-bannering. */
117
+ remotePushDispatched: z.boolean().optional(),
118
+ remotePushPlatforms: z.array(z.enum(["ios", "android"])).optional(),
109
119
  });
110
120
  export type ChannelDeliveryPayload = z.infer<
111
121
  typeof ChannelDeliveryPayloadSchema
@@ -119,11 +129,16 @@ export interface ChannelUpdatePayload {
119
129
  // -- Channel adapter interface ------------------------------------------------
120
130
 
121
131
  /** Interface that each channel adapter must implement. */
132
+ export type ChannelDeliveryObserver = {
133
+ onRemotePushPlatforms(platforms: ("ios" | "android")[]): void;
134
+ };
135
+
122
136
  export interface ChannelAdapter {
123
137
  channel: NotificationChannel;
124
138
  send(
125
139
  payload: ChannelDeliveryPayload,
126
140
  destination: ChannelDestination,
141
+ observer?: ChannelDeliveryObserver,
127
142
  ): Promise<DeliveryResult>;
128
143
  update?(
129
144
  delivery: ChannelUpdateContext,
@@ -183,5 +198,7 @@ export interface NotificationDecision {
183
198
  dedupeKey: string;
184
199
  confidence: number;
185
200
  fallbackUsed: boolean;
201
+ /** Set by deterministic pass-through branches whose copy is producer-supplied verbatim; exempts the copy from the event-name-collision quality check. */
202
+ verbatimCopy?: boolean;
186
203
  persistedDecisionId?: string;
187
204
  }
@@ -1,4 +1,4 @@
1
- import { desc, eq } from "drizzle-orm";
1
+ import { and, desc, eq, type SQL } from "drizzle-orm";
2
2
  import { v4 as uuid } from "uuid";
3
3
 
4
4
  import type { DrizzleDb } from "./db-connection.js";
@@ -79,21 +79,30 @@ function rowToSummary(row: BookmarkJoinRow): BookmarkSummary {
79
79
  };
80
80
  }
81
81
 
82
- function selectBookmarkJoin(db: DrizzleDb) {
82
+ /**
83
+ * CROSS JOIN constrains SQLite's join order to start from message_bookmarks,
84
+ * which the planner would otherwise skip in favor of a full messages scan
85
+ * because an empty table never has statistics. The join predicates live in
86
+ * WHERE with identical semantics; callers pass extra conditions through
87
+ * `filter` because chaining `.where()` would replace the predicates.
88
+ */
89
+ function selectBookmarkJoin(db: DrizzleDb, filter?: SQL) {
90
+ const joinMatch = and(
91
+ eq(messages.id, messageBookmarks.messageId),
92
+ eq(conversations.id, messageBookmarks.conversationId),
93
+ );
83
94
  return db
84
95
  .select(BOOKMARK_JOIN_COLUMNS)
85
96
  .from(messageBookmarks)
86
- .innerJoin(messages, eq(messages.id, messageBookmarks.messageId))
87
- .innerJoin(
88
- conversations,
89
- eq(conversations.id, messageBookmarks.conversationId),
90
- );
97
+ .crossJoin(messages)
98
+ .crossJoin(conversations)
99
+ .where(filter ? and(joinMatch, filter) : joinMatch);
91
100
  }
92
101
 
93
102
  /**
94
103
  * List all bookmarks newest-first, joined against `messages` and
95
104
  * `conversations`. Bookmarks whose parent message or conversation has
96
- * been deleted are naturally excluded by the inner-join semantics; the
105
+ * been deleted are naturally excluded by the join predicates; the
97
106
  * `ON DELETE CASCADE` on the FKs means rows should never end up in this
98
107
  * orphan state, but the join provides a defense-in-depth guarantee.
99
108
  */
@@ -179,7 +188,7 @@ function readBookmarkSummaryOrThrow(
179
188
  db: DrizzleDb,
180
189
  id: string,
181
190
  ): BookmarkSummary {
182
- const row = selectBookmarkJoin(db).where(eq(messageBookmarks.id, id)).get();
191
+ const row = selectBookmarkJoin(db, eq(messageBookmarks.id, id)).get();
183
192
  if (!row) {
184
193
  // Unreachable: caller just observed (or inserted) this id.
185
194
  throw new Error(`Bookmark ${id} disappeared between insert and read`);
@@ -61,6 +61,32 @@ export interface AttentionState {
61
61
  updatedAt: number;
62
62
  }
63
63
 
64
+ /**
65
+ * True when the latest assistant cursor is ahead of the last-seen cursor.
66
+ *
67
+ * The single definition of "unseen" for the sidebar indicator
68
+ * (`buildAssistantAttention` in `runtime/services/conversation-serializer.ts`)
69
+ * and the assistant-reply push producer, so the badge and the notification
70
+ * cannot disagree. An absent state row reads as seen.
71
+ */
72
+ export function hasUnseenLatestAssistantMessage(
73
+ state:
74
+ | Pick<
75
+ AttentionState,
76
+ "latestAssistantMessageAt" | "lastSeenAssistantMessageAt"
77
+ >
78
+ | null
79
+ | undefined,
80
+ ): boolean {
81
+ if (!state || state.latestAssistantMessageAt == null) {
82
+ return false;
83
+ }
84
+ return (
85
+ state.lastSeenAssistantMessageAt == null ||
86
+ state.lastSeenAssistantMessageAt < state.latestAssistantMessageAt
87
+ );
88
+ }
89
+
64
90
  // ── Row mappers ──────────────────────────────────────────────────────
65
91
 
66
92
  function rowToEvent(
@@ -74,6 +74,7 @@ import { deleteConversationRowsInBatches } from "./conversation-row-batch-delete
74
74
  import {
75
75
  BACKGROUND_CONVERSATION_TYPES,
76
76
  type ConversationCreateType,
77
+ isHiddenMessageMetadata,
77
78
  } from "./conversation-types.js";
78
79
  import { runAsyncSqlite } from "./db-async-query.js";
79
80
  import {
@@ -297,14 +298,35 @@ export const messageMetadataSchema = z
297
298
  * treat message text as organic user input.
298
299
  */
299
300
  hidden: z.boolean().optional(),
301
+ /**
302
+ * Marks a role-`"user"` row that opened a live phone or in-app voice turn.
303
+ * Test with {@link isVoiceSessionUserMessage}, which documents why the
304
+ * channel/interface fields cannot stand in for it.
305
+ */
306
+ voiceSessionTurn: z.boolean().optional(),
300
307
  /**
301
308
  * Discriminates daemon-authored rows from ordinary turns.
302
309
  * `"system_card"` marks pre-composed status cards (the /compact, /clean,
303
310
  * and summarize-up-to results); see {@link SYSTEM_CARD_MESSAGE_KIND}.
304
- * Kept as a plain string so unknown future kinds never fail metadata
305
- * validation.
311
+ * `"provider_error"` marks the synthetic assistant row the agent loop
312
+ * persists when a turn dies on the provider-error path; see
313
+ * {@link PROVIDER_ERROR_MESSAGE_KIND}. Kept as a plain string so unknown
314
+ * future kinds never fail metadata validation.
306
315
  */
307
316
  messageKind: z.string().optional(),
317
+ /**
318
+ * Stable classified error code (`ClassifiedConversationError.code`, e.g.
319
+ * `"PROVIDER_BILLING"`) stamped alongside
320
+ * `messageKind: "provider_error"` on persisted provider-failure rows.
321
+ */
322
+ providerErrorCode: z.string().optional(),
323
+ /**
324
+ * Classified error category (`ClassifiedConversationError.errorCategory`,
325
+ * e.g. `"credits_exhausted"`) stamped alongside
326
+ * `messageKind: "provider_error"`. Clients switch on this to pick a
327
+ * themed rendering for the row.
328
+ */
329
+ providerErrorCategory: z.string().optional(),
308
330
  /**
309
331
  * Structured terminal record stamped onto a `<background_event
310
332
  * source="background-tool">` wake so the web can rebuild the inline
@@ -348,17 +370,17 @@ export const messageMetadataSchema = z
348
370
  export type MessageMetadata = z.infer<typeof messageMetadataSchema>;
349
371
 
350
372
  /**
351
- * Shared predicate for the transcript-suppression flag on user-message
352
- * metadata (see the `hidden` field on {@link messageMetadataSchema}). One
353
- * definition so the sites that must agree echo suppression, list-messages
354
- * filtering, queued-snapshot filtering, indexing exclusion, and downstream
355
- * consumers of message text — cannot drift.
373
+ * Pure predicates over the `metadata` record above. They live in the
374
+ * `conversation-types` leaf so a caller that only classifies a row does not
375
+ * pull in this module's DB graph, and are re-exported here alongside the
376
+ * schema they read.
356
377
  */
357
- export function isHiddenMessageMetadata(
358
- metadata: Record<string, unknown> | null | undefined,
359
- ): boolean {
360
- return metadata?.hidden === true;
361
- }
378
+ export {
379
+ isBackgroundEventMetadata,
380
+ isEchoSuppressedUserMessage,
381
+ isHiddenMessageMetadata,
382
+ isVoiceSessionUserMessage,
383
+ } from "./conversation-types.js";
362
384
 
363
385
  /**
364
386
  * `messageKind` value marking a daemon-authored system card — a pre-composed
@@ -369,6 +391,16 @@ export function isHiddenMessageMetadata(
369
391
  */
370
392
  export const SYSTEM_CARD_MESSAGE_KIND = "system_card";
371
393
 
394
+ /**
395
+ * `messageKind` value marking the synthetic assistant row the agent loop
396
+ * persists when a turn terminates on the provider-error path (see the
397
+ * `persistProviderErrorAsAssistantMessage` branch). The row's text stays in
398
+ * LLM history like any assistant message; the marker (plus the
399
+ * `providerErrorCode`/`providerErrorCategory` fields stamped next to it) lets
400
+ * clients render the row as a themed notice instead of persona speech.
401
+ */
402
+ export const PROVIDER_ERROR_MESSAGE_KIND = "provider_error";
403
+
372
404
  /**
373
405
  * Shared predicate for the system-card marker on assistant-message metadata
374
406
  * (see the `messageKind` field on {@link messageMetadataSchema}). One
@@ -382,11 +414,24 @@ export function isSystemCardMetadata(
382
414
  }
383
415
 
384
416
  /**
385
- * Row-level variant of {@link isSystemCardMetadata} for callers holding the
386
- * raw persisted `metadata` JSON string. The single place the parse lives so
387
- * display merging and turn grouping agree on what a card is.
417
+ * Shared predicate for the provider-error marker on assistant-message
418
+ * metadata (see the `messageKind` field on {@link messageMetadataSchema}).
419
+ * One definition so persistence stamping and wire projection cannot drift.
420
+ */
421
+ export function isProviderErrorMetadata(
422
+ metadata: Record<string, unknown> | null | undefined,
423
+ ): boolean {
424
+ return metadata?.messageKind === PROVIDER_ERROR_MESSAGE_KIND;
425
+ }
426
+
427
+ /**
428
+ * True when an assistant row is a standalone display turn: a system card or
429
+ * a provider-error notice. Standalone rows never merge with adjacent
430
+ * assistant rows, and turn grouping closes on them, so display merging and
431
+ * the turn resolver agree on boundaries. Takes the raw persisted `metadata`
432
+ * JSON string; malformed JSON and non-assistant roles are never standalone.
388
433
  */
389
- export function isSystemCardMessage(
434
+ export function isStandaloneAssistantMessage(
390
435
  role: string,
391
436
  metadata: string | null,
392
437
  ): boolean {
@@ -394,9 +439,8 @@ export function isSystemCardMessage(
394
439
  return false;
395
440
  }
396
441
  try {
397
- return isSystemCardMetadata(
398
- JSON.parse(metadata) as Record<string, unknown>,
399
- );
442
+ const parsed = JSON.parse(metadata) as Record<string, unknown>;
443
+ return isSystemCardMetadata(parsed) || isProviderErrorMetadata(parsed);
400
444
  } catch {
401
445
  return false;
402
446
  }
@@ -4209,8 +4253,10 @@ export function getTurnTimeBounds(
4209
4253
  /**
4210
4254
  * Resolve all assistant message IDs that belong to the same agent turn
4211
4255
  * as the given `messageId`. A "turn" is bounded by:
4212
- * - The start of the conversation, or
4213
- * - A user message whose content is NOT a tool_result array.
4256
+ * - The start of the conversation,
4257
+ * - A user message whose content is NOT a tool_result array, or
4258
+ * - A standalone assistant row (see {@link isStandaloneAssistantMessage}),
4259
+ * which always forms its own single-row turn.
4214
4260
  *
4215
4261
  * Within a multi-step agent loop, the pattern is:
4216
4262
  * user msg → assistant A1 → user (tool_result) → assistant A2 → ...
@@ -4229,9 +4275,10 @@ export function getAssistantMessageIdsInTurn(messageId: string): string[] {
4229
4275
  return [messageId];
4230
4276
  }
4231
4277
 
4232
- // A system card is its own single-row group its linked calls (e.g. the
4233
- // summarize-up-to compaction call) never mix into a neighbouring turn.
4234
- if (isSystemCardMessage(target.role, target.metadata)) {
4278
+ // A standalone row (system card or provider-error notice) is its own
4279
+ // single-row group: its linked calls (e.g. the summarize-up-to compaction
4280
+ // call) never mix into a neighbouring turn.
4281
+ if (isStandaloneAssistantMessage(target.role, target.metadata)) {
4235
4282
  return [target.id];
4236
4283
  }
4237
4284
 
@@ -4261,9 +4308,9 @@ export function getAssistantMessageIdsInTurn(messageId: string): string[] {
4261
4308
 
4262
4309
  for (const row of backwardRows) {
4263
4310
  if (row.role === "assistant") {
4264
- if (isSystemCardMessage(row.role, row.metadata)) {
4265
- // A system card closes the groups on either side of it rows
4266
- // before the card belong to an earlier display group.
4311
+ if (isStandaloneAssistantMessage(row.role, row.metadata)) {
4312
+ // A standalone row closes the groups on either side of it: rows
4313
+ // before it belong to an earlier display group.
4267
4314
  boundaryCreatedAt = row.createdAt;
4268
4315
  break;
4269
4316
  }
@@ -4303,10 +4350,10 @@ export function getAssistantMessageIdsInTurn(messageId: string): string[] {
4303
4350
 
4304
4351
  for (const row of forwardRows) {
4305
4352
  if (row.role === "assistant") {
4306
- if (isSystemCardMessage(row.role, row.metadata)) {
4307
- // A card that is the queried user message's only reply (e.g. the
4308
- // /compact result) IS the turn's response; otherwise the card
4309
- // closes the group.
4353
+ if (isStandaloneAssistantMessage(row.role, row.metadata)) {
4354
+ // A standalone row that is the queried user message's only reply
4355
+ // (e.g. the /compact result card, or a provider-error notice) IS
4356
+ // the turn's response; otherwise it closes the group.
4310
4357
  if (assistantIds.length === 0) {
4311
4358
  assistantIds.push(row.id);
4312
4359
  }
@@ -4350,7 +4397,7 @@ export function getAssistantMessageIdsInTurn(messageId: string): string[] {
4350
4397
  for (const row of gapRows) {
4351
4398
  if (
4352
4399
  row.role === "assistant" &&
4353
- !isSystemCardMessage(row.role, row.metadata) &&
4400
+ !isStandaloneAssistantMessage(row.role, row.metadata) &&
4354
4401
  !assistantIds.includes(row.id)
4355
4402
  ) {
4356
4403
  assistantIds.push(row.id);
@@ -902,16 +902,16 @@ function buildExcerptWithExternalContentMode(
902
902
 
903
903
  if (externalContentMode === "display") {
904
904
  text = unwrapExternalContentForDisplay(text);
905
- } else {
906
- const envelope = parseExternalContentEnvelope(text);
907
- if (envelope) {
908
- const innerExcerpt = buildExcerptFromText(envelope.content, query);
909
- return wrapRecallEvidenceExcerpt(
910
- innerExcerpt,
911
- envelope.source,
912
- envelope.origin,
913
- );
914
- }
905
+ return buildExcerptFromText(text, query, DISPLAY_EXCERPT_LEADING_CHARS);
906
+ }
907
+ const envelope = parseExternalContentEnvelope(text);
908
+ if (envelope) {
909
+ const innerExcerpt = buildExcerptFromText(envelope.content, query);
910
+ return wrapRecallEvidenceExcerpt(
911
+ innerExcerpt,
912
+ envelope.source,
913
+ envelope.origin,
914
+ );
915
915
  }
916
916
 
917
917
  return buildExcerptFromText(text, query);
@@ -985,19 +985,34 @@ function findEarliestMatch(
985
985
  return match ? { index: match.index, length: match[0].length } : null;
986
986
  }
987
987
 
988
- function buildExcerptFromText(text: string, query: string): string {
989
- const WINDOW = 100;
988
+ const EXCERPT_WINDOW = 100;
989
+
990
+ /**
991
+ * Leading context for palette display excerpts. The result row truncates
992
+ * from the right, so a wide leading window can push the match past the
993
+ * visible cutoff; recall evidence keeps the full window.
994
+ */
995
+ const DISPLAY_EXCERPT_LEADING_CHARS = 30;
996
+
997
+ function buildExcerptFromText(
998
+ text: string,
999
+ query: string,
1000
+ leadingChars: number = EXCERPT_WINDOW,
1001
+ ): string {
990
1002
  const match = findEarliestMatch(text, query);
991
1003
  if (!match) {
992
1004
  // Neither the query nor any of its tokens is present (e.g. the lexical
993
1005
  // index matched JSON structure instead); fall back to the text start.
994
1006
  return text
995
- .slice(0, WINDOW * 2)
1007
+ .slice(0, EXCERPT_WINDOW * 2)
996
1008
  .replace(/\s+/g, " ")
997
1009
  .trim();
998
1010
  }
999
- const start = Math.max(0, match.index - WINDOW);
1000
- const end = Math.min(text.length, match.index + match.length + WINDOW);
1011
+ const start = Math.max(0, match.index - leadingChars);
1012
+ const end = Math.min(
1013
+ text.length,
1014
+ match.index + match.length + EXCERPT_WINDOW,
1015
+ );
1001
1016
  const excerpt =
1002
1017
  (start > 0 ? "\u2026" : "") +
1003
1018
  text.slice(start, end).replace(/\s+/g, " ").trim() +