@vellumai/assistant 0.12.2 → 0.12.3-staging.1

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 (298) hide show
  1. package/.env.example +2 -0
  2. package/AGENTS.md +1 -1
  3. package/docs/architecture/integrations.md +7 -0
  4. package/docs/architecture/memory.md +17 -2
  5. package/docs/credential-execution-service.md +1 -1
  6. package/docs/desktop-browser-cli.md +7 -3
  7. package/node_modules/@vellumai/ces-client/node_modules/@vellumai/service-contracts/src/__tests__/secret-detection.test.ts +1 -0
  8. package/node_modules/@vellumai/ces-client/node_modules/@vellumai/service-contracts/src/secret-detection.ts +4 -1
  9. package/node_modules/@vellumai/environments/src/shell.test.ts +21 -0
  10. package/node_modules/@vellumai/environments/src/shell.ts +24 -0
  11. package/node_modules/@vellumai/gateway-client/node_modules/@vellumai/service-contracts/src/__tests__/secret-detection.test.ts +1 -0
  12. package/node_modules/@vellumai/gateway-client/node_modules/@vellumai/service-contracts/src/secret-detection.ts +4 -1
  13. package/node_modules/@vellumai/gateway-client/src/inbound-contract.ts +8 -2
  14. package/node_modules/@vellumai/service-contracts/src/__tests__/secret-detection.test.ts +1 -0
  15. package/node_modules/@vellumai/service-contracts/src/secret-detection.ts +4 -1
  16. package/openapi.yaml +54 -5
  17. package/package.json +1 -1
  18. package/scripts/smoke-desktop-browser-cli.ts +1 -0
  19. package/scripts/sync-llm-catalog.ts +3 -0
  20. package/scripts/voice-ttft-spike.ts +2 -2
  21. package/src/__tests__/agent-loop.test.ts +259 -0
  22. package/src/__tests__/agent-wake-delegation-prompt.test.ts +64 -2
  23. package/src/__tests__/anthropic-provider.test.ts +55 -0
  24. package/src/__tests__/approval-interception-trust-gates.test.ts +40 -0
  25. package/src/__tests__/attachments-store.test.ts +22 -3
  26. package/src/__tests__/channel-approval.test.ts +9 -14
  27. package/src/__tests__/channel-reply-delivery.test.ts +56 -0
  28. package/src/__tests__/chat-credential-redaction.test.ts +23 -0
  29. package/src/__tests__/computer-use-screenshot-attachments.test.ts +411 -0
  30. package/src/__tests__/computer-use-screenshot-selection.test.ts +50 -0
  31. package/src/__tests__/conversation-agent-loop-inference-profile.test.ts +2 -0
  32. package/src/__tests__/conversation-agent-loop-overflow.test.ts +2 -0
  33. package/src/__tests__/conversation-agent-loop.test.ts +257 -6
  34. package/src/__tests__/conversation-attachments.test.ts +106 -0
  35. package/src/__tests__/conversation-fork-crud.test.ts +191 -0
  36. package/src/__tests__/conversation-rendered-delegation-state.test.ts +276 -0
  37. package/src/__tests__/credential-execution-client.test.ts +70 -53
  38. package/src/__tests__/db-conversation-tool-surface.test.ts +221 -0
  39. package/src/__tests__/events-tail-route.test.ts +33 -0
  40. package/src/__tests__/history-repair.test.ts +53 -0
  41. package/src/__tests__/inference-profile-session-handler.test.ts +26 -0
  42. package/src/__tests__/list-messages-tool-merge.test.ts +85 -1
  43. package/src/__tests__/llm-catalog-parity.test.ts +27 -4
  44. package/src/__tests__/oauth-apps-routes.test.ts +1 -0
  45. package/src/__tests__/oauth-commands-routes.test.ts +114 -101
  46. package/src/__tests__/oauth-connect-orchestrator.test.ts +2 -0
  47. package/src/__tests__/oauth-provider-serializer.test.ts +1 -0
  48. package/src/__tests__/oauth-providers-routes.test.ts +2 -0
  49. package/src/__tests__/persist-media-references.test.ts +50 -0
  50. package/src/__tests__/plugin-import-boundary-guard.test.ts +0 -1
  51. package/src/__tests__/run-conversation-turn-persistence.test.ts +138 -1
  52. package/src/__tests__/schedule-routes.test.ts +20 -0
  53. package/src/__tests__/scheduler-result-notification.test.ts +23 -4
  54. package/src/__tests__/script-proxy-certs.test.ts +1 -1
  55. package/src/__tests__/secret-routes-platform-proxy.test.ts +86 -31
  56. package/src/__tests__/secret-routes-scrub.test.ts +22 -27
  57. package/src/__tests__/secret-scanner.test.ts +20 -0
  58. package/src/__tests__/secure-keys-managed-failover.test.ts +8 -0
  59. package/src/__tests__/secure-keys.test.ts +7 -3
  60. package/src/__tests__/server-tool-pairing.test.ts +107 -0
  61. package/src/__tests__/skills.test.ts +5 -4
  62. package/src/__tests__/subagent-tool-gate-mode.test.ts +226 -0
  63. package/src/__tests__/terminal-tools.test.ts +8 -0
  64. package/src/__tests__/tool-result-follow-up.test.ts +99 -0
  65. package/src/__tests__/tool-result-metadata-plumbing.test.ts +63 -0
  66. package/src/__tests__/unicode.test.ts +36 -0
  67. package/src/agent/loop.ts +37 -12
  68. package/src/agent/tool-result-follow-up.ts +70 -0
  69. package/src/api/attachment-provenance.test.ts +68 -0
  70. package/src/api/computer-use-tool.test.ts +55 -0
  71. package/src/api/computer-use-tool.ts +27 -0
  72. package/src/api/events/assistant-outbound-attachment.ts +3 -0
  73. package/src/api/events/desktop-activity-changed.ts +10 -0
  74. package/src/api/events/question-request.ts +1 -0
  75. package/src/api/index.ts +11 -0
  76. package/src/api/responses/conversation-message.ts +3 -0
  77. package/src/approvals/approval-primitive.ts +5 -2
  78. package/src/approvals/scoped-approval-grants.ts +6 -2
  79. package/src/browser/virtual-desktop-target.ts +1 -2
  80. package/src/cli/commands/__tests__/cli-test-harness.ts +21 -3
  81. package/src/cli/commands/__tests__/plugins.test.ts +20 -1
  82. package/src/cli/commands/__tests__/schedules.test.ts +14 -0
  83. package/src/cli/commands/bash.help.ts +4 -3
  84. package/src/cli/commands/browser.help.ts +5 -1
  85. package/src/cli/commands/credentials.help.ts +3 -3
  86. package/src/cli/commands/oauth/index.help.ts +10 -0
  87. package/src/cli/commands/oauth/providers.ts +12 -0
  88. package/src/cli/commands/oauth/request.test.ts +179 -2
  89. package/src/cli/commands/oauth/request.ts +41 -10
  90. package/src/cli/commands/plugins.ts +12 -5
  91. package/src/cli/commands/schedules.ts +2 -0
  92. package/src/cli/lib/__tests__/inspect-plugin.test.ts +54 -0
  93. package/src/cli/lib/__tests__/install-from-github.test.ts +41 -0
  94. package/src/cli/lib/__tests__/local-plugin-upgrade.test.ts +133 -51
  95. package/src/cli/lib/__tests__/plugin-catalog-cache.test.ts +8 -2
  96. package/src/cli/lib/bundled-marketplace.json +855 -0
  97. package/src/cli/lib/bundled-plugin-packages.json +783 -1
  98. package/src/cli/lib/inspect-plugin.ts +11 -4
  99. package/src/cli/lib/upgrade-plugin.ts +19 -11
  100. package/src/cli/output.ts +18 -4
  101. package/src/config/__tests__/default-provider.test.ts +4 -0
  102. package/src/config/bundled-skills/schedule/SKILL.md +6 -6
  103. package/src/config/loader.ts +9 -17
  104. package/src/config/platform-identity.ts +8 -8
  105. package/src/config/profile-text-generation.test.ts +51 -0
  106. package/src/config/profile-text-generation.ts +51 -0
  107. package/src/config/schemas/__tests__/memory-v3.test.ts +12 -0
  108. package/src/config/schemas/llm.ts +7 -2
  109. package/src/config/schemas/mcp.ts +5 -1
  110. package/src/config/schemas/memory-v3.ts +12 -0
  111. package/src/credential-execution/ces-runtime.ts +2 -2
  112. package/src/credential-execution/executable-discovery.ts +36 -134
  113. package/src/credential-execution/process-manager.test.ts +20 -24
  114. package/src/credential-execution/process-manager.ts +24 -19
  115. package/src/daemon/__tests__/conversation-tool-setup.test.ts +43 -0
  116. package/src/daemon/__tests__/turn-tail-deleted-conversation.test.ts +36 -0
  117. package/src/daemon/assistant-attachments.ts +20 -12
  118. package/src/daemon/chat-credential-redaction.ts +16 -1
  119. package/src/daemon/conversation-agent-loop-handlers.ts +74 -3
  120. package/src/daemon/conversation-agent-loop.ts +12 -0
  121. package/src/daemon/conversation-attachments.ts +93 -12
  122. package/src/daemon/conversation-tool-setup.ts +71 -3
  123. package/src/daemon/conversation-turn-finalize.ts +26 -13
  124. package/src/daemon/conversation.ts +72 -17
  125. package/src/daemon/daemon-control.ts +3 -7
  126. package/src/daemon/lifecycle.ts +4 -4
  127. package/src/daemon/mcp-reload-service.ts +1 -1
  128. package/src/daemon/message-types/shared.ts +1 -0
  129. package/src/daemon/orphan-reaper.ts +4 -3
  130. package/src/daemon/persist-media-references.ts +20 -3
  131. package/src/daemon/tool-setup-types.ts +6 -0
  132. package/src/daemon/wake-conversation-ops.ts +50 -15
  133. package/src/desktop/desktop-automation-lease.test.ts +230 -0
  134. package/src/desktop/desktop-automation-lease.ts +90 -6
  135. package/src/desktop/desktop-help.ts +15 -0
  136. package/src/desktop/desktop-stream-bridge.test.ts +65 -10
  137. package/src/desktop/desktop-stream-bridge.ts +3 -3
  138. package/src/desktop/desktop-wallpaper-renderer.ts +132 -0
  139. package/src/desktop/desktop-wallpaper-worker.ts +17 -0
  140. package/src/desktop/desktop-wallpaper.test.ts +48 -7
  141. package/src/desktop/desktop-wallpaper.ts +34 -102
  142. package/src/desktop/virtual-desktop-feature.ts +1 -1
  143. package/src/mcp/__tests__/manager-tool-caps.test.ts +111 -0
  144. package/src/mcp/__tests__/startup.test.ts +30 -7
  145. package/src/mcp/__tests__/tool-caps.test.ts +107 -0
  146. package/src/mcp/manager.ts +168 -101
  147. package/src/mcp/startup.ts +33 -13
  148. package/src/mcp/tool-caps.ts +176 -0
  149. package/src/messaging/provider-message-metadata.ts +3 -3
  150. package/src/notifications/__tests__/copy-composer.test.ts +70 -0
  151. package/src/notifications/copy-composer.ts +11 -3
  152. package/src/notifications/schedule-result-producer.ts +4 -6
  153. package/src/oauth/AGENTS.md +2 -0
  154. package/src/oauth/__tests__/identity-verifier.test.ts +36 -1
  155. package/src/oauth/identity-verifier.ts +24 -0
  156. package/src/oauth/oauth-store.ts +11 -1
  157. package/src/oauth/provider-serializer.ts +1 -0
  158. package/src/oauth/seed-providers.ts +12 -0
  159. package/src/permissions/confirmation-guardian-request.test.ts +16 -0
  160. package/src/permissions/confirmation-guardian-request.ts +2 -3
  161. package/src/permissions/question-prompter.test.ts +31 -0
  162. package/src/permissions/question-prompter.ts +2 -0
  163. package/src/persistence/attachments-store.ts +84 -44
  164. package/src/persistence/conversation-crud.ts +111 -20
  165. package/src/persistence/conversation-plugin-facade.ts +30 -0
  166. package/src/persistence/conversation-tool-surface.ts +135 -0
  167. package/src/persistence/conversation-types.test.ts +32 -0
  168. package/src/persistence/conversation-types.ts +26 -4
  169. package/src/persistence/migrations/378-create-conversation-tool-surfaces.test.ts +78 -0
  170. package/src/persistence/migrations/378-create-conversation-tool-surfaces.ts +29 -0
  171. package/src/persistence/migrations/379-oauth-providers-response-ok-field.test.ts +92 -0
  172. package/src/persistence/migrations/379-oauth-providers-response-ok-field.ts +32 -0
  173. package/src/persistence/migrations/380-conversation-tool-surfaces-delegate-independent-tasks.test.ts +90 -0
  174. package/src/persistence/migrations/380-conversation-tool-surfaces-delegate-independent-tasks.ts +34 -0
  175. package/src/persistence/schema/conversation-tool-surfaces.ts +32 -0
  176. package/src/persistence/schema/index.ts +1 -0
  177. package/src/persistence/schema/oauth.ts +1 -0
  178. package/src/persistence/steps.ts +24 -3
  179. package/src/plugin-api/conversation-turn.ts +31 -7
  180. package/src/plugin-api/index.ts +11 -1
  181. package/src/plugin-api/plugin-channel-turn-trust.test.ts +133 -0
  182. package/src/plugin-api/plugin-channel-turn-trust.ts +71 -0
  183. package/src/plugins/defaults/memory/AGENTS.md +39 -5
  184. package/src/plugins/defaults/memory/__tests__/buffer-file.test.ts +365 -0
  185. package/src/plugins/defaults/memory/__tests__/buffer-format.test.ts +43 -0
  186. package/src/plugins/defaults/memory/__tests__/conversation-memory-purge.test.ts +1 -0
  187. package/src/plugins/defaults/memory/__tests__/db-memory-attach.test.ts +2 -0
  188. package/src/plugins/defaults/memory/__tests__/fixtures/buffer-appender.ts +17 -0
  189. package/src/plugins/defaults/memory/__tests__/memory-retrospective-job.test.ts +74 -0
  190. package/src/plugins/defaults/memory/__tests__/memory-run-evidence.test.ts +161 -0
  191. package/src/plugins/defaults/memory/__tests__/relocated-memory-test-rows.ts +10 -0
  192. package/src/plugins/defaults/memory/buffer-file.ts +354 -0
  193. package/src/plugins/defaults/memory/buffer-format.ts +40 -0
  194. package/src/plugins/defaults/memory/context-search/agent-runner.ts +1 -2
  195. package/src/plugins/defaults/memory/context-search/format.ts +2 -1
  196. package/src/plugins/defaults/memory/context-search/sources/memory-v2.ts +2 -1
  197. package/src/plugins/defaults/memory/context-search/sources/workspace.ts +2 -1
  198. package/src/plugins/defaults/memory/conversation-memory-purge.ts +4 -0
  199. package/src/plugins/defaults/memory/graph/capability-seed.ts +1 -2
  200. package/src/plugins/defaults/memory/graph/tool-handlers.ts +1 -42
  201. package/src/plugins/defaults/memory/host-utils.ts +0 -10
  202. package/src/plugins/defaults/memory/injectors.ts +4 -3
  203. package/src/plugins/defaults/memory/memory-retrospective-job.ts +72 -183
  204. package/src/plugins/defaults/memory/memory-run-evidence.ts +213 -0
  205. package/src/plugins/defaults/memory/src/memory-item-routes.test.ts +1 -1
  206. package/src/plugins/defaults/memory/substrate/__tests__/consolidation-job.test.ts +407 -99
  207. package/src/plugins/defaults/memory/substrate/__tests__/consolidation-prompt-flag-gating-guard.test.ts +10 -0
  208. package/src/plugins/defaults/memory/substrate/__tests__/prompts-consolidation.test.ts +107 -7
  209. package/src/plugins/defaults/memory/substrate/consolidation-job.ts +307 -86
  210. package/src/plugins/defaults/memory/substrate/consolidation-tool-surface.ts +34 -0
  211. package/src/plugins/defaults/memory/substrate/page-index.ts +2 -1
  212. package/src/plugins/defaults/memory/substrate/prompts/consolidation.ts +89 -49
  213. package/src/plugins/defaults/memory/substrate/sweep-job.ts +1 -1
  214. package/src/plugins/defaults/memory/tools.ts +1 -1
  215. package/src/plugins/defaults/memory/v1/graph/consolidation.ts +2 -2
  216. package/src/plugins/defaults/memory/v1/graph/extraction.ts +2 -1
  217. package/src/plugins/defaults/memory/v1/graph/retriever.ts +1 -1
  218. package/src/plugins/defaults/memory/v2/__tests__/migration.test.ts +5 -0
  219. package/src/plugins/defaults/memory/v2/__tests__/reranker.test.ts +5 -2
  220. package/src/plugins/defaults/memory/v2/reranker.ts +2 -1
  221. package/src/plugins/defaults/memory/v3/__tests__/injection.test.ts +81 -1
  222. package/src/plugins/defaults/memory/v3/__tests__/orchestrate.test.ts +87 -0
  223. package/src/plugins/defaults/memory/v3/__tests__/plugin-schema.test.ts +12 -0
  224. package/src/plugins/defaults/memory/v3/__tests__/pool-log-store.test.ts +183 -4
  225. package/src/plugins/defaults/memory/v3/__tests__/shadow-plugin.test.ts +103 -2
  226. package/src/plugins/defaults/memory/v3/card.ts +2 -1
  227. package/src/plugins/defaults/memory/v3/injector.ts +212 -178
  228. package/src/plugins/defaults/memory/v3/orchestrate.ts +112 -25
  229. package/src/plugins/defaults/memory/v3/plugin-schema.ts +54 -4
  230. package/src/plugins/defaults/memory/v3/pool-log-store.ts +253 -1
  231. package/src/plugins/defaults/memory/v3/pool-select.test.ts +35 -2
  232. package/src/plugins/defaults/memory/v3/pool-select.ts +31 -19
  233. package/src/plugins/defaults/memory/v3/sections.ts +2 -1
  234. package/src/plugins/defaults/memory/v3/shadow-plugin.ts +41 -14
  235. package/src/plugins/defaults/tool-error/hooks/post-tool-use.ts +4 -1
  236. package/src/plugins/defaults/tool-result-truncate/terminal.ts +1 -46
  237. package/src/prompts/__tests__/parallel-tasks-section.test.ts +25 -0
  238. package/src/prompts/delegation-gate.ts +57 -0
  239. package/src/prompts/system-prompt.ts +14 -32
  240. package/src/providers/inference/adapter-factory.ts +6 -0
  241. package/src/providers/jev/client.test.ts +260 -0
  242. package/src/providers/jev/client.ts +518 -0
  243. package/src/providers/model-catalog.ts +58 -3
  244. package/src/providers/server-tool-pairing.ts +16 -7
  245. package/src/runtime/AGENTS.md +2 -2
  246. package/src/runtime/__tests__/agent-wake.test.ts +94 -1
  247. package/src/runtime/agent-wake.ts +31 -4
  248. package/src/runtime/guardian-action-service.ts +2 -17
  249. package/src/runtime/guardian-reply-router.ts +1 -8
  250. package/src/runtime/http-server.ts +2 -2
  251. package/src/runtime/migrations/__tests__/vbundle-import-policy.test.ts +89 -0
  252. package/src/runtime/migrations/vbundle-import-policy.ts +33 -11
  253. package/src/runtime/routes/__tests__/inference-profiles-routes.test.ts +24 -2
  254. package/src/runtime/routes/channel-route-shared.ts +1 -9
  255. package/src/runtime/routes/conversation-routes.ts +29 -4
  256. package/src/runtime/routes/desktop-setup-routes.test.ts +2 -2
  257. package/src/runtime/routes/desktop-setup-routes.ts +8 -5
  258. package/src/runtime/routes/guardian-approval-interception.ts +24 -0
  259. package/src/runtime/routes/inbound-message-handler.ts +2 -3
  260. package/src/runtime/routes/inbound-stages/background-dispatch.test.ts +1 -1
  261. package/src/runtime/routes/inbound-stages/background-dispatch.ts +7 -4
  262. package/src/runtime/routes/inference-profile-session-handler.ts +11 -1
  263. package/src/runtime/routes/inference-profiles-routes.ts +9 -0
  264. package/src/runtime/routes/oauth-commands-routes.ts +57 -54
  265. package/src/runtime/routes/oauth-providers.ts +4 -0
  266. package/src/runtime/routes/oauth-request-hints.test.ts +246 -0
  267. package/src/runtime/routes/oauth-request-hints.ts +149 -0
  268. package/src/runtime/routes/schedule-routes.ts +2 -0
  269. package/src/runtime/routes/secret-routes.ts +99 -119
  270. package/src/schedule/__tests__/worker-mcp-bootstrap.test.ts +107 -0
  271. package/src/schedule/__tests__/worker-mcp-readiness.test.ts +173 -0
  272. package/src/schedule/__tests__/worker-mcp-tools.test.ts +13 -47
  273. package/src/schedule/run-script.ts +2 -2
  274. package/src/schedule/scheduler.ts +47 -11
  275. package/src/schedule/tool-surface-readiness.ts +65 -0
  276. package/src/schedule/worker-mcp.ts +85 -0
  277. package/src/schedule/worker.ts +9 -31
  278. package/src/security/secure-keys.ts +60 -48
  279. package/src/tools/ask-question/ask-question-tool.test.ts +167 -5
  280. package/src/tools/ask-question/ask-question-tool.ts +119 -23
  281. package/src/tools/browser/browser-execution.ts +7 -1
  282. package/src/tools/host-terminal/host-shell.ts +12 -6
  283. package/src/tools/shared/filesystem/file-ops-service.ts +1 -31
  284. package/src/tools/shared/shell-output.test.ts +10 -0
  285. package/src/tools/shared/shell-output.ts +14 -2
  286. package/src/tools/skills/sandbox-runner.ts +13 -2
  287. package/src/tools/skills/scaffold-managed.ts +2 -1
  288. package/src/tools/terminal/__tests__/safe-env.test.ts +33 -4
  289. package/src/tools/terminal/__tests__/sanitized-bash.test.ts +0 -14
  290. package/src/tools/terminal/safe-env.ts +35 -24
  291. package/src/tools/terminal/sanitized-bash.ts +15 -2
  292. package/src/tools/terminal/shell-launch.test.ts +162 -0
  293. package/src/tools/terminal/shell.test.ts +29 -0
  294. package/src/tools/terminal/shell.ts +13 -7
  295. package/src/util/browser-human-verification.ts +14 -0
  296. package/src/util/host-process.test.ts +17 -1
  297. package/src/util/host-process.ts +24 -0
  298. package/src/util/unicode.ts +29 -0
@@ -0,0 +1,176 @@
1
+ /**
2
+ * Code-owned MCP tool-count caps and the selection used when they bind.
3
+ *
4
+ * Caps live here, not in workspace config: migration 153 strips
5
+ * `maxTools` / `globalMaxTools` from `config.json`. The numbers are a
6
+ * context-window budget, not a permission gate. Allowlists and risk
7
+ * still decide what a turn may call.
8
+ *
9
+ * The global cap is applied by a deterministic round-robin across
10
+ * servers sorted by id. Insertion order must not empty a later server
11
+ * while earlier ones keep every tool.
12
+ */
13
+
14
+ import {
15
+ MCP_GLOBAL_MAX_TOOLS,
16
+ MCP_MAX_TOOLS_PER_SERVER,
17
+ } from "../config/schemas/mcp.js";
18
+
19
+ export interface McpServerToolSet<T> {
20
+ serverId: string;
21
+ tools: readonly T[];
22
+ }
23
+
24
+ export interface McpToolCapDecision {
25
+ serverId: string;
26
+ discovered: number;
27
+ kept: number;
28
+ droppedByPerServerCap: number;
29
+ droppedByGlobalCap: number;
30
+ }
31
+
32
+ export interface McpToolCapResult<T> {
33
+ servers: Array<{ serverId: string; tools: T[] }>;
34
+ discoveredToolCount: number;
35
+ keptToolCount: number;
36
+ droppedToolCount: number;
37
+ globalCap: number;
38
+ perServerCap: number;
39
+ decisions: McpToolCapDecision[];
40
+ }
41
+
42
+ export function applyMcpToolCaps<T>(
43
+ servers: ReadonlyArray<McpServerToolSet<T>>,
44
+ options?: { globalMax?: number; perServerMax?: number },
45
+ ): McpToolCapResult<T> {
46
+ const globalCap = options?.globalMax ?? MCP_GLOBAL_MAX_TOOLS;
47
+ const perServerCap = options?.perServerMax ?? MCP_MAX_TOOLS_PER_SERVER;
48
+ const discoveredToolCount = servers.reduce(
49
+ (sum, server) => sum + server.tools.length,
50
+ 0,
51
+ );
52
+
53
+ const afterPerServer = servers.map((server) => {
54
+ const droppedByPerServerCap = Math.max(
55
+ 0,
56
+ server.tools.length - perServerCap,
57
+ );
58
+ return {
59
+ serverId: server.serverId,
60
+ tools: server.tools.slice(0, perServerCap),
61
+ discovered: server.tools.length,
62
+ droppedByPerServerCap,
63
+ };
64
+ });
65
+
66
+ const afterPerServerCount = afterPerServer.reduce(
67
+ (sum, server) => sum + server.tools.length,
68
+ 0,
69
+ );
70
+ if (afterPerServerCount <= globalCap) {
71
+ const decisions = afterPerServer.map((server) => ({
72
+ serverId: server.serverId,
73
+ discovered: server.discovered,
74
+ kept: server.tools.length,
75
+ droppedByPerServerCap: server.droppedByPerServerCap,
76
+ droppedByGlobalCap: 0,
77
+ }));
78
+ return {
79
+ servers: afterPerServer.map((server) => ({
80
+ serverId: server.serverId,
81
+ tools: [...server.tools],
82
+ })),
83
+ discoveredToolCount,
84
+ keptToolCount: afterPerServerCount,
85
+ droppedToolCount: discoveredToolCount - afterPerServerCount,
86
+ globalCap,
87
+ perServerCap,
88
+ decisions,
89
+ };
90
+ }
91
+
92
+ const order = [...afterPerServer].sort((a, b) =>
93
+ a.serverId.localeCompare(b.serverId),
94
+ );
95
+ const selected = new Map<string, T[]>(
96
+ order.map((server) => [server.serverId, []]),
97
+ );
98
+ const cursors = new Map<string, number>(
99
+ order.map((server) => [server.serverId, 0]),
100
+ );
101
+ let kept = 0;
102
+ let progressed = true;
103
+ while (kept < globalCap && progressed) {
104
+ progressed = false;
105
+ for (const server of order) {
106
+ if (kept >= globalCap) {
107
+ break;
108
+ }
109
+ const cursor = cursors.get(server.serverId) ?? 0;
110
+ const tool = server.tools[cursor];
111
+ if (tool !== undefined) {
112
+ selected.get(server.serverId)?.push(tool);
113
+ cursors.set(server.serverId, cursor + 1);
114
+ kept += 1;
115
+ progressed = true;
116
+ }
117
+ }
118
+ }
119
+
120
+ const decisions = afterPerServer.map((server) => {
121
+ const keptTools = selected.get(server.serverId) ?? [];
122
+ return {
123
+ serverId: server.serverId,
124
+ discovered: server.discovered,
125
+ kept: keptTools.length,
126
+ droppedByPerServerCap: server.droppedByPerServerCap,
127
+ droppedByGlobalCap: server.tools.length - keptTools.length,
128
+ };
129
+ });
130
+
131
+ return {
132
+ servers: afterPerServer.map((server) => ({
133
+ serverId: server.serverId,
134
+ tools: [...(selected.get(server.serverId) ?? [])],
135
+ })),
136
+ discoveredToolCount,
137
+ keptToolCount: kept,
138
+ droppedToolCount: discoveredToolCount - kept,
139
+ globalCap,
140
+ perServerCap,
141
+ decisions,
142
+ };
143
+ }
144
+
145
+ export interface McpStartupSnapshot {
146
+ configuredServerCount: number;
147
+ connectedServerCount: number;
148
+ discoveredToolCount: number;
149
+ registeredToolCount: number;
150
+ droppedToolCount: number;
151
+ truncatedServerIds: string[];
152
+ errorServerCount: number;
153
+ needsAuthServerCount: number;
154
+ }
155
+
156
+ export const EMPTY_MCP_STARTUP_SNAPSHOT: McpStartupSnapshot = {
157
+ configuredServerCount: 0,
158
+ connectedServerCount: 0,
159
+ discoveredToolCount: 0,
160
+ registeredToolCount: 0,
161
+ droppedToolCount: 0,
162
+ truncatedServerIds: [],
163
+ errorServerCount: 0,
164
+ needsAuthServerCount: 0,
165
+ };
166
+
167
+ export function truncatedServerIdsFromCaps(
168
+ decisions: readonly McpToolCapDecision[],
169
+ ): string[] {
170
+ return decisions
171
+ .filter(
172
+ (decision) =>
173
+ decision.droppedByPerServerCap > 0 || decision.droppedByGlobalCap > 0,
174
+ )
175
+ .map((decision) => decision.serverId);
176
+ }
@@ -30,9 +30,9 @@ import { CHANNEL_IDS } from "../channels/types.js";
30
30
  * The schema passes through what it does not name, so a provider carries its
31
31
  * own fields (Slack's file markers and timezone labels) on this same object
32
32
  * and validates them with its own schema. This is how `SourceMetadataSchema`
33
- * already carries `slackBotMentioned` and the email fields on the wire: one
34
- * object per row, no per-provider sub-envelope, and no second copy of
35
- * anything named here.
33
+ * carries `botMentioned` and the email fields on the wire: one object per
34
+ * row, no per-provider sub-envelope, and no second copy of anything named
35
+ * here.
36
36
  */
37
37
 
38
38
  const providerReactionMetadataSchema = z.object({
@@ -503,6 +503,76 @@ describe("composeFallbackCopy plugin schedule templates", () => {
503
503
  });
504
504
  });
505
505
 
506
+ // ── activity.complete rendering ───────────────────────────────────────
507
+
508
+ describe("activity.complete copy", () => {
509
+ function completeSignal(
510
+ contextPayload: Record<string, unknown>,
511
+ ): NotificationSignal {
512
+ return makeSignal({
513
+ sourceEventName: "activity.complete",
514
+ contextPayload,
515
+ });
516
+ }
517
+
518
+ test("a producer-authored title survives the fallback path", () => {
519
+ // The home feed reads the payload title first; the popup must say the
520
+ // same thing when the classifier was unreachable.
521
+ const copy = composeFallbackCopy(
522
+ completeSignal({
523
+ title: "Skill updated: Weekly Report Export",
524
+ summary: "Added the retry after an expired session.",
525
+ }),
526
+ CHANNELS,
527
+ );
528
+ expect(copy.vellum?.title).toBe("Skill updated: Weekly Report Export");
529
+ expect(copy.vellum?.body).toBe("Added the retry after an expired session.");
530
+ });
531
+
532
+ test("the pass-through's requestedTitle is honored when no title is set", () => {
533
+ const copy = composeFallbackCopy(
534
+ completeSignal({
535
+ requestedTitle: "Nightly export finished",
536
+ summary: "Wrote 12 files to the reports folder.",
537
+ }),
538
+ CHANNELS,
539
+ );
540
+ expect(copy.vellum?.title).toBe("Nightly export finished");
541
+ });
542
+
543
+ test("without an authored title the headline is the summary's first sentence", () => {
544
+ const copy = composeFallbackCopy(
545
+ completeSignal({
546
+ summary:
547
+ "Added the retry after an expired session. Dropped the login step.",
548
+ }),
549
+ CHANNELS,
550
+ );
551
+ expect(copy.vellum?.title).toBe(
552
+ "Added the retry after an expired session.",
553
+ );
554
+ });
555
+
556
+ test("an authored title the normalizer rejects falls back to the derived one", () => {
557
+ const copy = composeFallbackCopy(
558
+ completeSignal({
559
+ title: "Skill\nupdated",
560
+ summary: "Added the retry after an expired session.",
561
+ }),
562
+ CHANNELS,
563
+ );
564
+ expect(copy.vellum?.title).toBe(
565
+ "Added the retry after an expired session.",
566
+ );
567
+ });
568
+
569
+ test("neither a title nor a summary still says something happened", () => {
570
+ const copy = composeFallbackCopy(completeSignal({}), CHANNELS);
571
+ expect(copy.vellum?.title).toBe("Activity complete");
572
+ expect(copy.vellum?.body).toBe("An activity has completed");
573
+ });
574
+ });
575
+
506
576
  // ── deriveTitle ───────────────────────────────────────────────────────
507
577
 
508
578
  describe("deriveTitle", () => {
@@ -346,14 +346,22 @@ const TEMPLATES: Partial<Record<NotificationSourceEventName, CopyTemplate>> = {
346
346
 
347
347
  // Titled by what was done rather than by the kind of event: the summary's
348
348
  // first sentence is the outcome ("Finished the fuel-system diagnostic app"),
349
- // which is what a reader scanning the bell wants to see. A summary without
350
- // one still gets a title that says something happened.
349
+ // which is what a reader scanning the bell wants to see. A producer that
350
+ // names its subject in the payload keeps that name here, read in the same
351
+ // order the home feed reads it, so the headline is the same whether or not
352
+ // the classifier ran. A summary with neither still gets a title that says
353
+ // something happened.
351
354
  "activity.complete": (payload) => {
352
355
  const summary = nonEmpty(
353
356
  typeof payload.summary === "string" ? payload.summary : undefined,
354
357
  );
358
+ const authored = normalizeTitle(
359
+ readPayloadString(payload, "title") ??
360
+ readPayloadString(payload, "requestedTitle") ??
361
+ "",
362
+ );
355
363
  return {
356
- title: summary ? deriveTitle(summary) : "Activity complete",
364
+ title: authored || (summary ? deriveTitle(summary) : "Activity complete"),
357
365
  body: summary ?? "An activity has completed",
358
366
  };
359
367
  },
@@ -103,12 +103,10 @@ export interface ScheduleResultNotificationParams {
103
103
  * conversation can come back too and are harmless, because only this run's
104
104
  * own call ids are matched.
105
105
  *
106
- * A Slack Web API post through bash is deliberately not a route, because its
107
- * success proves nothing about the post. Slack refuses a call with HTTP 200
108
- * and `ok: false` in the body, and the authenticated-request command fails
109
- * only on a non-2xx status, so a post Slack refused still leaves a successful
110
- * tool result. A run that posts that way gets the fallback too: at worst a
111
- * duplicate, never a silence.
106
+ * A Slack Web API post through bash is deliberately not a route. It is off
107
+ * the guided path and unrecorded, and recognizing it would mean reading a
108
+ * shell command for a method name. A run that posts that way gets the
109
+ * fallback too: at worst a duplicate, never a silence.
112
110
  */
113
111
  function deliveredThroughMessagingTool(
114
112
  conversationId: string,
@@ -10,6 +10,8 @@ Add an entry to `PROVIDER_SEED_DATA`. Required fields: `provider`, `authorizeUrl
10
10
 
11
11
  `injectionTemplates` is also a security boundary, not only an injection rule. `assistant oauth request` and `assistant channels request` accept an absolute URL only when its host matches one of the entry's `hostPattern` values (`assertOAuthRequestUrlAllowed` in `../runtime/routes/oauth-commands-routes.ts`), so that list is the full set of hosts the credential may be sent to. A pattern matches exactly unless it starts with `*.`; reserve the wildcard for per-tenant hosts that cannot be enumerated (`*.salesforce.com`), and name a fixed host the provider documents (`files.slack.com`) by name. The guard sees only the URL the caller passes: the connection follows a redirect itself, and a cross-origin hop carries no `Authorization`, so a redirect target never receives the credential and never needs listing.
12
12
 
13
+ `responseOkField` declares the boolean a provider's API puts in every response body to report success (Slack: `ok`), and it is read in one place, `providerReportsFailure` in `identity-verifier.ts`. It is not `identityOkField`, which governs only the identity call and stays the documented `--identity-ok-field` contract. Both request doors and `oauth ping` treat an explicit `false` under a 2xx as a failed call, since such a provider hides refusals behind a successful status; a body without the field leaves the status as the verdict. Declare it on every row that calls such an API, the bot row included.
14
+
13
15
  If the provider will support managed mode, set `managedServiceConfigKey` to a slug matching the key you will add to `ServicesSchema` (e.g. `"acme-oauth"`).
14
16
 
15
17
  ### 2. _(managed only)_ Add a service schema — `../config/schemas/services.ts`
@@ -1,6 +1,9 @@
1
1
  import { afterEach, beforeEach, describe, expect, mock, test } from "bun:test";
2
2
 
3
- import { verifyIdentity } from "../identity-verifier.js";
3
+ import {
4
+ providerReportsFailure,
5
+ verifyIdentity,
6
+ } from "../identity-verifier.js";
4
7
  import type { OAuthProviderRow } from "../oauth-store.js";
5
8
 
6
9
  // ---------------------------------------------------------------------------
@@ -66,6 +69,7 @@ function makeProviderRow(
66
69
  identityResponsePaths: null,
67
70
  identityFormat: null,
68
71
  identityOkField: null,
72
+ responseOkField: null,
69
73
  featureFlag: null,
70
74
  createdAt: now,
71
75
  updatedAt: now,
@@ -469,3 +473,34 @@ describe("verifyIdentity", () => {
469
473
  });
470
474
  });
471
475
  });
476
+
477
+ describe("providerReportsFailure", () => {
478
+ test("reads an explicit false in the declared field as failure", () => {
479
+ expect(
480
+ providerReportsFailure(
481
+ { responseOkField: "ok" },
482
+ { ok: false, error: "invalid_auth" },
483
+ ),
484
+ ).toBe(true);
485
+ expect(
486
+ providerReportsFailure({ responseOkField: "ok" }, { ok: true }),
487
+ ).toBe(false);
488
+ });
489
+
490
+ test("a body without the field says nothing", () => {
491
+ // A download or an endpoint outside the envelope; the status decides.
492
+ expect(providerReportsFailure({ responseOkField: "ok" }, null)).toBe(false);
493
+ expect(
494
+ providerReportsFailure({ responseOkField: "ok" }, Buffer.from("png")),
495
+ ).toBe(false);
496
+ expect(
497
+ providerReportsFailure({ responseOkField: "ok" }, { data: {} }),
498
+ ).toBe(false);
499
+ });
500
+
501
+ test("a provider that declares no field never reports failure", () => {
502
+ expect(
503
+ providerReportsFailure({ responseOkField: null }, { ok: false }),
504
+ ).toBe(false);
505
+ });
506
+ });
@@ -27,6 +27,30 @@ function getNestedValue(obj: unknown, dotPath: string): unknown {
27
27
  return current;
28
28
  }
29
29
 
30
+ /**
31
+ * Whether a response body says the call failed, read through the ok field the
32
+ * provider declares (`responseOkField`).
33
+ *
34
+ * Some APIs report failure inside a successful HTTP exchange: Slack documents
35
+ * that every Web API response is a JSON object with a top-level boolean `ok`,
36
+ * false on failure, so the status code alone reads a refused call as a
37
+ * success. The provider row is where that envelope is declared, and this is
38
+ * the one reader of it for calls whose endpoint is not known in advance (ping
39
+ * and the authenticated-request doors). Only an explicit `false` counts: a
40
+ * body without the field, such as a file download or an endpoint outside the
41
+ * envelope, says nothing, and the status stays the verdict. The identity
42
+ * verifier reads its own field, `identityOkField`, for the one call it makes.
43
+ */
44
+ export function providerReportsFailure(
45
+ providerRow: Pick<OAuthProviderRow, "responseOkField">,
46
+ body: unknown,
47
+ ): boolean {
48
+ if (!providerRow.responseOkField) {
49
+ return false;
50
+ }
51
+ return getNestedValue(body, providerRow.responseOkField) === false;
52
+ }
53
+
30
54
  /** Safely parse a JSON string, returning a fallback on failure or null/undefined input. */
31
55
  function safeJsonParse<T>(value: string | null | undefined, fallback: T): T {
32
56
  if (value == null) {
@@ -59,7 +59,7 @@ export type OAuthConnectionRow = typeof oauthConnections.$inferSelect;
59
59
  * managedServiceConfigKey,
60
60
  * loopbackPort, injectionTemplates, appType, setupNotes,
61
61
  * identityUrl, identityMethod, identityHeaders, identityBody,
62
- * identityResponsePaths, identityFormat, identityOkField, featureFlag,
62
+ * identityResponsePaths, identityFormat, identityOkField, responseOkField, featureFlag,
63
63
  * scopeSeparator, defaultScopes, availableScopes)
64
64
  * and display metadata (displayLabel, description, dashboardUrl,
65
65
  * clientIdPlaceholder, logoUrl, requiresClientSecret) propagate to existing
@@ -111,6 +111,7 @@ export function seedProviders(
111
111
  identityResponsePaths?: string[];
112
112
  identityFormat?: string;
113
113
  identityOkField?: string;
114
+ responseOkField?: string;
114
115
  featureFlag?: string;
115
116
  }>,
116
117
  ): void {
@@ -175,6 +176,7 @@ export function seedProviders(
175
176
  : null;
176
177
  const identityFormat = p.identityFormat ?? null;
177
178
  const identityOkField = p.identityOkField ?? null;
179
+ const responseOkField = p.responseOkField ?? null;
178
180
  const featureFlag = p.featureFlag ?? null;
179
181
 
180
182
  db.insert(oauthProviders)
@@ -216,6 +218,7 @@ export function seedProviders(
216
218
  identityResponsePaths,
217
219
  identityFormat,
218
220
  identityOkField,
221
+ responseOkField,
219
222
  featureFlag,
220
223
  createdAt: now,
221
224
  updatedAt: now,
@@ -259,6 +262,7 @@ export function seedProviders(
259
262
  identityResponsePaths,
260
263
  identityFormat,
261
264
  identityOkField,
265
+ responseOkField,
262
266
  featureFlag,
263
267
  updatedAt: now,
264
268
  },
@@ -359,6 +363,7 @@ export function registerProvider(params: {
359
363
  identityResponsePaths?: string[];
360
364
  identityFormat?: string;
361
365
  identityOkField?: string;
366
+ responseOkField?: string;
362
367
  featureFlag?: string;
363
368
  }): OAuthProviderRow {
364
369
  const db = getDb();
@@ -425,6 +430,7 @@ export function registerProvider(params: {
425
430
  : null,
426
431
  identityFormat: params.identityFormat ?? null,
427
432
  identityOkField: params.identityOkField ?? null,
433
+ responseOkField: params.responseOkField ?? null,
428
434
  featureFlag: params.featureFlag ?? null,
429
435
  createdAt: now,
430
436
  updatedAt: now,
@@ -486,6 +492,7 @@ export function updateProvider(
486
492
  identityResponsePaths: string[];
487
493
  identityFormat: string;
488
494
  identityOkField: string;
495
+ responseOkField: string;
489
496
  featureFlag: string;
490
497
  managedServiceIsPaid: boolean;
491
498
  }>,
@@ -610,6 +617,9 @@ export function updateProvider(
610
617
  if (params.identityOkField !== undefined) {
611
618
  set.identityOkField = params.identityOkField;
612
619
  }
620
+ if (params.responseOkField !== undefined) {
621
+ set.responseOkField = params.responseOkField;
622
+ }
613
623
  if (params.featureFlag !== undefined) {
614
624
  set.featureFlag = params.featureFlag;
615
625
  }
@@ -141,6 +141,7 @@ function _serializeProvider(
141
141
  : null,
142
142
  identityFormat: row.identityFormat ?? null,
143
143
  identityOkField: row.identityOkField ?? null,
144
+ responseOkField: row.responseOkField ?? null,
144
145
  featureFlag: row.featureFlag ?? null,
145
146
  redirectUri:
146
147
  options?.redirectUri !== undefined ? options.redirectUri : null,
@@ -104,6 +104,14 @@ export const PROVIDER_SEED_DATA: Record<
104
104
  identityResponsePaths?: string[];
105
105
  identityFormat?: string;
106
106
  identityOkField?: string;
107
+ /**
108
+ * Dot path of the boolean the provider's API puts in every response body
109
+ * to report success (Slack: `ok`). Ping and the authenticated-request
110
+ * doors read an explicit `false` under a 2xx as a failed call, since such
111
+ * a provider hides refusals behind a successful status. Distinct from
112
+ * `identityOkField`, which governs only the identity call.
113
+ */
114
+ responseOkField?: string;
107
115
  featureFlag?: string;
108
116
  logoUrl?: string;
109
117
  }
@@ -256,6 +264,7 @@ export const PROVIDER_SEED_DATA: Record<
256
264
  appType: "Slack App",
257
265
  identityUrl: "https://slack.com/api/auth.test",
258
266
  identityOkField: "ok",
267
+ responseOkField: "ok",
259
268
  identityResponsePaths: ["user", "team"],
260
269
  identityFormat: "@${user} (${team})",
261
270
  },
@@ -1221,6 +1230,9 @@ export const PROVIDER_SEED_DATA: Record<
1221
1230
  authorizeUrl: "urn:manual-token",
1222
1231
  tokenExchangeUrl: "urn:manual-token",
1223
1232
  pingUrl: "https://slack.com/api/auth.test",
1233
+ // The bot answers through the same Web API envelope as the person's
1234
+ // integration, and auth.test refuses a revoked token inside an HTTP 200.
1235
+ responseOkField: "ok",
1224
1236
  baseUrl: "https://slack.com/api",
1225
1237
  displayLabel: "Slack Channel",
1226
1238
  description: "Channel bot token",
@@ -111,6 +111,22 @@ describe("createGuardianRequestForConfirmation", () => {
111
111
  expect(expiresAt).toBeLessThanOrEqual(after + APPROVAL_WINDOW_MS);
112
112
  });
113
113
 
114
+ test("describes the request by the status sentence, never a tool's own reason argument", async () => {
115
+ await createGuardianRequestForConfirmation(
116
+ { ...MSG, input: { reason: "Task complete" } },
117
+ "conv-1",
118
+ );
119
+ await createGuardianRequestForConfirmation(
120
+ { ...MSG, input: { activity: "Stopping the app" } },
121
+ "conv-1",
122
+ );
123
+
124
+ expect(createCalls.map((call) => call.activityText)).toEqual([
125
+ undefined,
126
+ "Stopping the app",
127
+ ]);
128
+ });
129
+
114
130
  test("expires the row and skips the bridge when the confirmation resolved mid-create", async () => {
115
131
  confirmationPending = false;
116
132
 
@@ -73,10 +73,9 @@ export async function createGuardianRequestForConfirmation(
73
73
  const sourceChannel = trustContext?.sourceChannel ?? "vellum";
74
74
  const inputRecord = msg.input as Record<string, unknown>;
75
75
  const activityRaw =
76
- (typeof inputRecord.activity === "string"
76
+ typeof inputRecord.activity === "string"
77
77
  ? inputRecord.activity
78
- : undefined) ??
79
- (typeof inputRecord.reason === "string" ? inputRecord.reason : undefined);
78
+ : undefined;
80
79
  // Tool approvals are decisionable: without a bound principal nobody could
81
80
  // ever decide them (mirrors the gateway create's integrity guard).
82
81
  const guardianPrincipalId = trustContext?.guardianPrincipalId;
@@ -455,3 +455,34 @@ describe("QuestionPrompter", () => {
455
455
  expect(sent).toHaveLength(0);
456
456
  });
457
457
  });
458
+
459
+ test("desktop help presentation survives broadcast and pending recovery until Done", async () => {
460
+ const { prompter, sent } = makePrompter();
461
+ const pending = prompter.prompt({
462
+ conversationId: "conv-123",
463
+ questions: [
464
+ {
465
+ question: "Please complete the CAPTCHA.",
466
+ presentation: "virtual_desktop",
467
+ options: [
468
+ { id: "done", label: "Done" },
469
+ { id: "skip", label: "Skip" },
470
+ ],
471
+ },
472
+ ],
473
+ });
474
+ const request = sent.find(
475
+ (event) => event.type === "question_request",
476
+ ) as QuestionRequestEvent;
477
+ expect(request.questions[0]?.presentation).toBe("virtual_desktop");
478
+ expect(_piStore.get(request.requestId)?.questionDetails?.entries).toEqual(
479
+ request.questions,
480
+ );
481
+ resolveBatch(request.requestId, [
482
+ { questionId: "q1", kind: "option", optionId: "done" },
483
+ ]);
484
+ expect((await pending).entries).toEqual([
485
+ { questionId: "q1", decision: "option", optionId: "done" },
486
+ ]);
487
+ expect(_piStore.has(request.requestId)).toBe(false);
488
+ });
@@ -77,6 +77,7 @@ export interface QuestionPromptParamsEntry {
77
77
  description?: string;
78
78
  options: QuestionOption[];
79
79
  freeTextPlaceholder?: string;
80
+ presentation?: QuestionEntry["presentation"];
80
81
  }
81
82
 
82
83
  export interface QuestionPromptParams {
@@ -201,6 +202,7 @@ export class QuestionPrompter {
201
202
  description: q.description,
202
203
  options: q.options,
203
204
  freeTextPlaceholder: q.freeTextPlaceholder,
205
+ ...(q.presentation ? { presentation: q.presentation } : {}),
204
206
  }));
205
207
  const orderedIds = entries.map((e) => e.id);
206
208
  const optionsById: Record<string, string[]> = {};