@vellumai/assistant 0.4.35 → 0.4.37

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (239) hide show
  1. package/AGENTS.md +1 -1
  2. package/ARCHITECTURE.md +44 -49
  3. package/README.md +32 -20
  4. package/docs/architecture/keychain-broker.md +186 -0
  5. package/docs/architecture/security.md +110 -116
  6. package/docs/runbook-trusted-contacts.md +2 -2
  7. package/docs/skills.md +25 -25
  8. package/package.json +5 -2
  9. package/src/__tests__/__snapshots__/ipc-snapshot.test.ts.snap +11 -2
  10. package/src/__tests__/actor-token-service.test.ts +1 -0
  11. package/src/__tests__/amazon-cdp-integration.test.ts +74 -0
  12. package/src/__tests__/assistant-feature-flags-integration.test.ts +38 -9
  13. package/src/__tests__/assistant-id-boundary-guard.test.ts +29 -0
  14. package/src/__tests__/browser-fill-credential.test.ts +1 -1
  15. package/src/__tests__/bundle-scanner.test.ts +1 -1
  16. package/src/__tests__/channel-guardian.test.ts +102 -102
  17. package/src/__tests__/channel-invite-transport.test.ts +155 -256
  18. package/src/__tests__/channel-readiness-routes.test.ts +336 -0
  19. package/src/__tests__/checker.test.ts +6 -6
  20. package/src/__tests__/chrome-cdp.test.ts +350 -0
  21. package/src/__tests__/computer-use-session-lifecycle.test.ts +3 -3
  22. package/src/__tests__/computer-use-session-working-dir.test.ts +86 -52
  23. package/src/__tests__/computer-use-skill-lifecycle-cleanup.test.ts +1 -1
  24. package/src/__tests__/config-loader-migration.test.ts +85 -0
  25. package/src/__tests__/conversation-pairing.test.ts +370 -5
  26. package/src/__tests__/credential-broker-browser-fill.test.ts +1 -10
  27. package/src/__tests__/credential-broker-server-use.test.ts +1 -10
  28. package/src/__tests__/credential-security-e2e.test.ts +7 -1
  29. package/src/__tests__/credential-security-invariants.test.ts +14 -20
  30. package/src/__tests__/credential-vault-unit.test.ts +1 -11
  31. package/src/__tests__/credential-vault.test.ts +5 -19
  32. package/src/__tests__/credentials-cli.test.ts +814 -0
  33. package/src/__tests__/dynamic-skill-workflow-prompt.test.ts +23 -4
  34. package/src/__tests__/email-invite-adapter.test.ts +78 -0
  35. package/src/__tests__/email-service-config-fallback.test.ts +102 -0
  36. package/src/__tests__/encrypted-store.test.ts +6 -6
  37. package/src/__tests__/ephemeral-permissions.test.ts +3 -3
  38. package/src/__tests__/gateway-only-enforcement.test.ts +5 -1
  39. package/src/__tests__/guardian-actions-endpoint.test.ts +70 -12
  40. package/src/__tests__/guardian-outbound-http.test.ts +53 -47
  41. package/src/__tests__/handle-user-message-secret-resume.test.ts +23 -0
  42. package/src/__tests__/handlers-add-trust-rule-metadata.test.ts +32 -23
  43. package/src/__tests__/handlers-telegram-config.test.ts +8 -2
  44. package/src/__tests__/handlers-twitter-config.test.ts +2 -2
  45. package/src/__tests__/handlers-user-message-approval-consumption.test.ts +108 -7
  46. package/src/__tests__/ingress-reconcile.test.ts +6 -0
  47. package/src/__tests__/intent-routing.test.ts +23 -4
  48. package/src/__tests__/invite-routes-http.test.ts +12 -0
  49. package/src/__tests__/ipc-snapshot.test.ts +8 -2
  50. package/src/__tests__/keychain-broker-client.test.ts +543 -0
  51. package/src/__tests__/llm-usage-store.test.ts +344 -0
  52. package/src/__tests__/mcp-client-auth.test.ts +2 -2
  53. package/src/__tests__/media-reuse-story.e2e.test.ts +1 -1
  54. package/src/__tests__/migration-transport.test.ts +49 -0
  55. package/src/__tests__/notification-broadcaster.test.ts +205 -5
  56. package/src/__tests__/notification-deep-link.test.ts +365 -1
  57. package/src/__tests__/oauth-connect-handler.test.ts +2 -2
  58. package/src/__tests__/onboarding-starter-tasks.test.ts +17 -4
  59. package/src/__tests__/proxy-approval-callback.test.ts +1 -1
  60. package/src/__tests__/recording-handler.test.ts +1 -1
  61. package/src/__tests__/recording-intent-handler.test.ts +6 -1
  62. package/src/__tests__/recording-state-machine.test.ts +1 -1
  63. package/src/__tests__/relay-server.test.ts +9 -1
  64. package/src/__tests__/ride-shotgun-handler.test.ts +499 -0
  65. package/src/__tests__/runtime-attachment-metadata.test.ts +160 -1
  66. package/src/__tests__/script-proxy-injection-runtime.test.ts +299 -2
  67. package/src/__tests__/script-proxy-profile-template-fallback.test.ts +1 -1
  68. package/src/__tests__/secret-onetime-send.test.ts +8 -2
  69. package/src/__tests__/secure-keys.test.ts +175 -216
  70. package/src/__tests__/session-confirmation-signals.test.ts +1 -1
  71. package/src/__tests__/session-messaging-secret-redirect.test.ts +1 -1
  72. package/src/__tests__/session-queue.test.ts +2 -1
  73. package/src/__tests__/session-tool-setup-app-refresh.test.ts +2 -2
  74. package/src/__tests__/skill-feature-flags-integration.test.ts +29 -4
  75. package/src/__tests__/skill-feature-flags.test.ts +12 -9
  76. package/src/__tests__/skill-load-feature-flag.test.ts +26 -5
  77. package/src/__tests__/skill-projection.benchmark.test.ts +0 -1
  78. package/src/__tests__/skills.test.ts +34 -4
  79. package/src/__tests__/slack-channel-config.test.ts +2 -2
  80. package/src/__tests__/system-prompt.test.ts +26 -4
  81. package/src/__tests__/telegram-bot-username-resolution.test.ts +212 -0
  82. package/src/__tests__/telegram-invite-adapter.test.ts +164 -0
  83. package/src/__tests__/tool-execution-pipeline.benchmark.test.ts +1 -1
  84. package/src/__tests__/tool-permission-simulate-handler.test.ts +8 -2
  85. package/src/__tests__/trusted-contact-approval-notifier.test.ts +9 -1
  86. package/src/__tests__/twitter-auth-handler.test.ts +2 -2
  87. package/src/__tests__/twitter-oauth-client.test.ts +1 -1
  88. package/src/__tests__/usage-routes.test.ts +339 -0
  89. package/src/__tests__/whatsapp-invite-adapter.test.ts +94 -0
  90. package/src/agent/loop.ts +3 -0
  91. package/src/amazon/checkout.ts +0 -1
  92. package/src/approvals/guardian-request-resolvers.ts +9 -1
  93. package/src/bundler/app-bundler.ts +28 -12
  94. package/src/bundler/bundle-scanner.ts +1 -1
  95. package/src/bundler/bundle-signer.ts +3 -3
  96. package/src/bundler/manifest.ts +1 -1
  97. package/src/bundler/signature-verifier.ts +3 -3
  98. package/src/channels/config.ts +1 -1
  99. package/src/cli/AGENTS.md +63 -0
  100. package/src/cli/__tests__/notifications.test.ts +470 -0
  101. package/src/cli/amazon.ts +344 -167
  102. package/src/cli/audit.ts +85 -0
  103. package/src/cli/autonomy.ts +369 -0
  104. package/src/cli/channels.ts +51 -0
  105. package/src/cli/completions.ts +208 -0
  106. package/src/cli/config.ts +220 -0
  107. package/src/cli/contacts.ts +471 -0
  108. package/src/cli/credentials.ts +564 -0
  109. package/src/cli/default-action.ts +14 -0
  110. package/src/cli/dev.ts +131 -0
  111. package/src/cli/doctor.ts +398 -0
  112. package/src/cli/email.ts +494 -0
  113. package/src/cli/influencer.ts +72 -0
  114. package/src/cli/integrations.ts +248 -57
  115. package/src/cli/keys.ts +114 -0
  116. package/src/cli/map.ts +46 -54
  117. package/src/cli/mcp.ts +111 -3
  118. package/src/cli/{config-commands.ts → memory.ts} +134 -245
  119. package/src/cli/notifications.ts +407 -0
  120. package/src/cli/program.ts +65 -0
  121. package/src/cli/reference.ts +48 -0
  122. package/src/cli/sequence.ts +154 -0
  123. package/src/cli/sessions.ts +262 -0
  124. package/src/cli/trust.ts +175 -0
  125. package/src/cli/twitter.ts +323 -106
  126. package/src/config/__tests__/build-cli-reference-section.test.ts +49 -0
  127. package/src/config/bundled-skills/amazon/SKILL.md +2 -2
  128. package/src/config/bundled-skills/app-builder/TOOLS.json +26 -0
  129. package/src/config/bundled-skills/app-builder/tools/app-generate-icon.ts +13 -0
  130. package/src/config/bundled-skills/contacts/SKILL.md +178 -10
  131. package/src/config/bundled-skills/doordash/doordash-cli.ts +23 -168
  132. package/src/config/bundled-skills/google-oauth-setup/SKILL.md +135 -34
  133. package/src/config/bundled-skills/messaging/tools/shared.ts +4 -1
  134. package/src/config/bundled-skills/twilio-setup/SKILL.md +70 -17
  135. package/src/config/bundled-tool-registry.ts +2 -0
  136. package/src/config/core-schema.ts +7 -0
  137. package/src/config/feature-flag-registry.json +16 -0
  138. package/src/config/loader.ts +26 -0
  139. package/src/config/schema.ts +4 -0
  140. package/src/config/skill-state.ts +0 -13
  141. package/src/config/system-prompt.ts +27 -0
  142. package/src/contacts/contact-store.ts +25 -0
  143. package/src/daemon/computer-use-session.ts +1 -1
  144. package/src/daemon/handlers/apps.ts +1 -0
  145. package/src/daemon/handlers/config-channels.ts +3 -3
  146. package/src/daemon/handlers/config-dispatch.ts +29 -0
  147. package/src/daemon/handlers/config-inbox.ts +4 -3
  148. package/src/daemon/handlers/config.ts +3 -43
  149. package/src/daemon/handlers/contacts.ts +34 -0
  150. package/src/daemon/handlers/index.ts +17 -3
  151. package/src/daemon/handlers/session-user-message.ts +7 -0
  152. package/src/daemon/handlers/sessions.ts +21 -2
  153. package/src/daemon/handlers/shared.ts +17 -0
  154. package/src/daemon/ipc-contract/apps.ts +2 -0
  155. package/src/daemon/ipc-contract/computer-use.ts +9 -0
  156. package/src/daemon/ipc-contract/contacts.ts +3 -3
  157. package/src/daemon/ipc-contract/inbox.ts +2 -0
  158. package/src/daemon/ipc-contract/messages.ts +4 -0
  159. package/src/daemon/ipc-contract/sessions.ts +8 -0
  160. package/src/daemon/ipc-contract-inventory.json +1 -0
  161. package/src/daemon/lifecycle.ts +0 -5
  162. package/src/daemon/ride-shotgun-handler.ts +139 -25
  163. package/src/daemon/session-agent-loop-handlers.ts +100 -0
  164. package/src/daemon/session-agent-loop.ts +72 -0
  165. package/src/daemon/session-tool-setup.ts +7 -0
  166. package/src/daemon/session.ts +23 -1
  167. package/src/daemon/tool-side-effects.ts +39 -1
  168. package/src/email/service.ts +59 -2
  169. package/src/index.ts +2 -60
  170. package/src/mcp/mcp-oauth-provider.ts +90 -8
  171. package/src/media/app-icon-generator.ts +86 -0
  172. package/src/memory/db-init.ts +11 -0
  173. package/src/memory/llm-usage-store.ts +186 -0
  174. package/src/memory/migrations/137-usage-dashboard-indexes.ts +26 -0
  175. package/src/memory/migrations/139-drop-usage-composite-indexes.ts +30 -0
  176. package/src/memory/migrations/index.ts +2 -0
  177. package/src/memory/schema-migration.ts +1 -0
  178. package/src/memory/shared-app-links-store.ts +1 -1
  179. package/src/messaging/registry.ts +27 -0
  180. package/src/notifications/README.md +79 -70
  181. package/src/notifications/broadcaster.ts +2 -1
  182. package/src/notifications/conversation-pairing.ts +147 -13
  183. package/src/notifications/copy-composer.ts +7 -3
  184. package/src/notifications/destination-resolver.ts +14 -1
  185. package/src/notifications/emit-signal.ts +3 -2
  186. package/src/notifications/signal.ts +105 -1
  187. package/src/notifications/types.ts +16 -0
  188. package/src/permissions/checker.ts +29 -3
  189. package/src/permissions/prompter.ts +11 -3
  190. package/src/runtime/access-request-helper.ts +2 -1
  191. package/src/runtime/auth/route-policy.ts +7 -1
  192. package/src/runtime/channel-invite-transport.ts +40 -63
  193. package/src/runtime/channel-invite-transports/email.ts +13 -39
  194. package/src/runtime/channel-invite-transports/slack.ts +5 -34
  195. package/src/runtime/channel-invite-transports/sms.ts +8 -29
  196. package/src/runtime/channel-invite-transports/telegram.ts +69 -28
  197. package/src/runtime/channel-invite-transports/voice.ts +0 -7
  198. package/src/runtime/channel-invite-transports/whatsapp.ts +43 -0
  199. package/src/runtime/channel-readiness-service.ts +202 -45
  200. package/src/runtime/confirmation-request-guardian-bridge.ts +2 -1
  201. package/src/runtime/guardian-outbound-actions.ts +8 -5
  202. package/src/runtime/http-server.ts +2 -0
  203. package/src/runtime/invite-instruction-generator.ts +178 -0
  204. package/src/runtime/invite-service.ts +22 -25
  205. package/src/runtime/migrations/migration-transport.ts +13 -0
  206. package/src/runtime/routes/app-routes.ts +1 -1
  207. package/src/runtime/routes/approval-strategies/guardian-callback-strategy.ts +8 -7
  208. package/src/runtime/routes/channel-readiness-routes.ts +30 -11
  209. package/src/runtime/routes/contact-routes.ts +54 -26
  210. package/src/runtime/routes/inbound-stages/bootstrap-intercept.ts +1 -1
  211. package/src/runtime/routes/inbound-stages/escalation-intercept.ts +2 -1
  212. package/src/runtime/routes/inbound-stages/verification-intercept.ts +2 -1
  213. package/src/runtime/routes/integration-routes.ts +1 -1
  214. package/src/runtime/routes/invite-routes.ts +1 -1
  215. package/src/runtime/routes/secret-routes.ts +31 -7
  216. package/src/runtime/routes/twilio-routes.ts +32 -1
  217. package/src/runtime/routes/usage-routes.ts +114 -0
  218. package/src/runtime/tool-grant-request-helper.ts +2 -1
  219. package/src/security/encrypted-store.ts +9 -5
  220. package/src/security/keychain-broker-client.ts +393 -0
  221. package/src/security/secure-keys.ts +106 -321
  222. package/src/tools/apps/executors.ts +73 -0
  223. package/src/tools/browser/auto-navigate.ts +15 -6
  224. package/src/tools/browser/chrome-cdp.ts +211 -0
  225. package/src/tools/browser/network-recorder.test.ts +83 -0
  226. package/src/tools/browser/network-recorder.ts +8 -7
  227. package/src/tools/browser/x-auto-navigate.ts +12 -6
  228. package/src/tools/credentials/policy-types.ts +24 -0
  229. package/src/tools/credentials/vault.ts +22 -27
  230. package/src/tools/network/script-proxy/session-manager.ts +47 -3
  231. package/src/tools/permission-checker.ts +1 -0
  232. package/src/tools/types.ts +2 -0
  233. package/src/tools/ui-surface/definitions.ts +1 -2
  234. package/src/tools/watch/watch-state.ts +2 -0
  235. package/src/__tests__/key-migration.test.ts +0 -240
  236. package/src/__tests__/keychain.test.ts +0 -286
  237. package/src/cli/core-commands.ts +0 -899
  238. package/src/security/keychain-to-encrypted-migration.ts +0 -66
  239. package/src/security/keychain.ts +0 -490
@@ -101,7 +101,17 @@ export class McpOAuthProvider implements OAuthClientProvider {
101
101
  }
102
102
 
103
103
  async saveTokens(tokens: OAuthTokens): Promise<void> {
104
- await setSecureKeyAsync(tokensKey(this.serverId), JSON.stringify(tokens));
104
+ const ok = await setSecureKeyAsync(
105
+ tokensKey(this.serverId),
106
+ JSON.stringify(tokens),
107
+ );
108
+ if (!ok) {
109
+ log.warn(
110
+ { serverId: this.serverId },
111
+ "Failed to persist OAuth tokens to secure storage",
112
+ );
113
+ return;
114
+ }
105
115
  log.info({ serverId: this.serverId }, "OAuth tokens saved");
106
116
  }
107
117
 
@@ -124,7 +134,17 @@ export class McpOAuthProvider implements OAuthClientProvider {
124
134
  async saveClientInformation(
125
135
  info: OAuthClientInformationMixed,
126
136
  ): Promise<void> {
127
- await setSecureKeyAsync(clientInfoKey(this.serverId), JSON.stringify(info));
137
+ const ok = await setSecureKeyAsync(
138
+ clientInfoKey(this.serverId),
139
+ JSON.stringify(info),
140
+ );
141
+ if (!ok) {
142
+ log.warn(
143
+ { serverId: this.serverId },
144
+ "Failed to persist OAuth client information to secure storage",
145
+ );
146
+ return;
147
+ }
128
148
  log.info({ serverId: this.serverId }, "OAuth client information saved");
129
149
  }
130
150
 
@@ -154,7 +174,16 @@ export class McpOAuthProvider implements OAuthClientProvider {
154
174
  }
155
175
 
156
176
  async saveDiscoveryState(state: OAuthDiscoveryState): Promise<void> {
157
- await setSecureKeyAsync(discoveryKey(this.serverId), JSON.stringify(state));
177
+ const ok = await setSecureKeyAsync(
178
+ discoveryKey(this.serverId),
179
+ JSON.stringify(state),
180
+ );
181
+ if (!ok) {
182
+ log.warn(
183
+ { serverId: this.serverId },
184
+ "Failed to persist OAuth discovery state to secure storage",
185
+ );
186
+ }
158
187
  }
159
188
 
160
189
  // --- Redirect to Authorization ---
@@ -214,16 +243,49 @@ export class McpOAuthProvider implements OAuthClientProvider {
214
243
  );
215
244
 
216
245
  if (scope === "all" || scope === "tokens") {
217
- await deleteSecureKeyAsync(tokensKey(this.serverId));
246
+ const result = await deleteSecureKeyAsync(tokensKey(this.serverId));
247
+ if (result === "error") {
248
+ log.warn(
249
+ { serverId: this.serverId },
250
+ "Failed to delete OAuth tokens from secure storage",
251
+ );
252
+ } else if (result === "not-found") {
253
+ log.debug(
254
+ { serverId: this.serverId },
255
+ "OAuth tokens key not found in secure storage (already removed)",
256
+ );
257
+ }
218
258
  }
219
259
  if (scope === "all" || scope === "client") {
220
- await deleteSecureKeyAsync(clientInfoKey(this.serverId));
260
+ const result = await deleteSecureKeyAsync(clientInfoKey(this.serverId));
261
+ if (result === "error") {
262
+ log.warn(
263
+ { serverId: this.serverId },
264
+ "Failed to delete OAuth client information from secure storage",
265
+ );
266
+ } else if (result === "not-found") {
267
+ log.debug(
268
+ { serverId: this.serverId },
269
+ "OAuth client information key not found in secure storage (already removed)",
270
+ );
271
+ }
221
272
  }
222
273
  if (scope === "all" || scope === "verifier") {
223
274
  this._codeVerifier = undefined;
224
275
  }
225
276
  if (scope === "all" || scope === "discovery") {
226
- await deleteSecureKeyAsync(discoveryKey(this.serverId));
277
+ const result = await deleteSecureKeyAsync(discoveryKey(this.serverId));
278
+ if (result === "error") {
279
+ log.warn(
280
+ { serverId: this.serverId },
281
+ "Failed to delete OAuth discovery state from secure storage",
282
+ );
283
+ } else if (result === "not-found") {
284
+ log.debug(
285
+ { serverId: this.serverId },
286
+ "OAuth discovery state key not found in secure storage (already removed)",
287
+ );
288
+ }
227
289
  }
228
290
  }
229
291
 
@@ -373,12 +435,32 @@ export class McpOAuthProvider implements OAuthClientProvider {
373
435
  export async function deleteMcpOAuthCredentials(
374
436
  serverId: string,
375
437
  ): Promise<void> {
376
- await Promise.all([
438
+ const [tokensResult, clientResult, discoveryResult] = await Promise.all([
377
439
  deleteSecureKeyAsync(tokensKey(serverId)),
378
440
  deleteSecureKeyAsync(clientInfoKey(serverId)),
379
441
  deleteSecureKeyAsync(discoveryKey(serverId)),
380
442
  ]);
381
- log.info({ serverId }, "OAuth credentials deleted");
443
+ const results = [
444
+ { key: "tokens", result: tokensResult },
445
+ { key: "client_info", result: clientResult },
446
+ { key: "discovery", result: discoveryResult },
447
+ ];
448
+ const errors = results
449
+ .filter((r) => r.result === "error")
450
+ .map((r) => r.key);
451
+ if (errors.length > 0) {
452
+ log.warn(
453
+ { serverId, failedKeys: errors },
454
+ "Some OAuth credentials could not be deleted from secure storage",
455
+ );
456
+ }
457
+ const hasErrors = errors.length > 0;
458
+ log.info(
459
+ { serverId },
460
+ hasErrors
461
+ ? "OAuth credential deletion completed with errors"
462
+ : "OAuth credentials deleted",
463
+ );
382
464
  }
383
465
 
384
466
  // --- HTML rendering ---
@@ -0,0 +1,86 @@
1
+ /**
2
+ * Generates app icons using the Gemini image generation service.
3
+ *
4
+ * Called as an async side-effect after app creation — never blocks
5
+ * the main app_create flow. Icons are saved to the app's directory
6
+ * as `icon.png` and included in .vellum bundles.
7
+ */
8
+
9
+ import { existsSync, mkdirSync, writeFileSync } from "node:fs";
10
+ import { join } from "node:path";
11
+
12
+ import { getConfig } from "../config/loader.js";
13
+ import { getAppsDir } from "../memory/app-store.js";
14
+ import { getLogger } from "../util/logger.js";
15
+ import { generateImage, mapGeminiError } from "./gemini-image-service.js";
16
+
17
+ const log = getLogger("app-icon-generator");
18
+
19
+ /**
20
+ * Generate an app icon and save it to `~/.vellum/apps/{appId}/icon.png`.
21
+ *
22
+ * Uses Gemini image generation when an API key is available.
23
+ * Silently no-ops if no key is configured or generation fails.
24
+ */
25
+ export async function generateAppIcon(
26
+ appId: string,
27
+ appName: string,
28
+ appDescription?: string,
29
+ ): Promise<void> {
30
+ const config = getConfig();
31
+ const apiKey = config.apiKeys.gemini ?? process.env.GEMINI_API_KEY;
32
+ if (!apiKey) {
33
+ log.debug("No Gemini API key — skipping app icon generation");
34
+ return;
35
+ }
36
+
37
+ const appDir = join(getAppsDir(), appId);
38
+ const iconPath = join(appDir, "icon.png");
39
+
40
+ // Don't regenerate if icon already exists
41
+ if (existsSync(iconPath)) {
42
+ return;
43
+ }
44
+
45
+ const descPart = appDescription ? ` Description: ${appDescription}.` : "";
46
+
47
+ const prompt =
48
+ `Design a beautiful, minimal app icon for "${appName}".${descPart}\n\n` +
49
+ "Style requirements:\n" +
50
+ "- Square app icon with rounded corners (like macOS/iOS app icons)\n" +
51
+ "- Clean, flat design with a single bold symbol or glyph in the center\n" +
52
+ "- Rich gradient background using 2-3 harmonious colors\n" +
53
+ "- The symbol should be white or very light colored for contrast\n" +
54
+ "- No text, no letters, no words — only a symbolic glyph\n" +
55
+ "- Professional quality, recognizable at small sizes (32px)\n" +
56
+ "- Modern aesthetic similar to Apple's design language";
57
+
58
+ try {
59
+ log.info({ appId, appName }, "Generating app icon via Gemini");
60
+
61
+ const result = await generateImage(apiKey, {
62
+ prompt,
63
+ mode: "generate",
64
+ model: config.imageGenModel,
65
+ });
66
+
67
+ if (result.images.length === 0) {
68
+ log.warn({ appId }, "Gemini returned no image for app icon");
69
+ return;
70
+ }
71
+
72
+ const image = result.images[0];
73
+ const pngBuffer = Buffer.from(image.dataBase64, "base64");
74
+
75
+ mkdirSync(appDir, { recursive: true });
76
+ writeFileSync(iconPath, pngBuffer);
77
+
78
+ log.info({ appId, iconPath }, "App icon saved");
79
+ } catch (error) {
80
+ const message = mapGeminiError(error);
81
+ log.warn(
82
+ { appId, error: message },
83
+ "App icon generation failed — skipping",
84
+ );
85
+ }
86
+ }
@@ -47,6 +47,7 @@ import {
47
47
  migrateConversationsThreadTypeIndex,
48
48
  migrateDropAssistantIdColumns,
49
49
  migrateDropLegacyMemberGuardianTables,
50
+ migrateDropUsageCompositeIndexes,
50
51
  migrateFkCascadeRebuilds,
51
52
  migrateGuardianActionFollowup,
52
53
  migrateGuardianActionSupersession,
@@ -63,6 +64,7 @@ import {
63
64
  migrateNotificationDeliveryThreadDecision,
64
65
  migrateReminderRoutingIntent,
65
66
  migrateSchemaIndexesAndColumns,
67
+ migrateUsageDashboardIndexes,
66
68
  migrateVoiceInviteColumns,
67
69
  migrateVoiceInviteDisplayMetadata,
68
70
  recoverCrashedMigrations,
@@ -293,6 +295,15 @@ export function initializeDb(): void {
293
295
  // 40. Drop assistant_id columns from all 16 daemon tables
294
296
  migrateDropAssistantIdColumns(database);
295
297
 
298
+ // 41. Indexes on llm_usage_events for usage dashboard time-range and breakdown queries
299
+ migrateUsageDashboardIndexes(database);
300
+
301
+ // 42. (skipped) migrateReorderUsageDashboardIndexes — superseded by 43 which drops
302
+ // all composite indexes that 42 would create, so running it is wasted work.
303
+
304
+ // 43. Drop all composite usage indexes — they don't eliminate temp B-trees for GROUP BY
305
+ migrateDropUsageCompositeIndexes(database);
306
+
296
307
  validateMigrationState(database);
297
308
 
298
309
  if (process.env.BUN_TEST === "1") {
@@ -7,8 +7,13 @@ import type {
7
7
  UsageEventInput,
8
8
  } from "../usage/types.js";
9
9
  import { getDb } from "./db.js";
10
+ import { rawAll } from "./raw-query.js";
10
11
  import { llmUsageEvents } from "./schema.js";
11
12
 
13
+ // ---------------------------------------------------------------------------
14
+ // Write
15
+ // ---------------------------------------------------------------------------
16
+
12
17
  export function recordUsageEvent(
13
18
  input: UsageEventInput,
14
19
  pricing: PricingResult,
@@ -43,6 +48,10 @@ export function recordUsageEvent(
43
48
  return event;
44
49
  }
45
50
 
51
+ // ---------------------------------------------------------------------------
52
+ // Read — single-event listing
53
+ // ---------------------------------------------------------------------------
54
+
46
55
  export function listUsageEvents(options?: { limit?: number }): UsageEvent[] {
47
56
  const db = getDb();
48
57
  const rows = db
@@ -68,3 +77,180 @@ export function listUsageEvents(options?: { limit?: number }): UsageEvent[] {
68
77
  pricingStatus: row.pricingStatus as "priced" | "unpriced",
69
78
  }));
70
79
  }
80
+
81
+ // ---------------------------------------------------------------------------
82
+ // Aggregation — time-range queries for the usage dashboard
83
+ // ---------------------------------------------------------------------------
84
+
85
+ /** Epoch-millis time range (inclusive on both ends). */
86
+ export interface UsageTimeRange {
87
+ from: number;
88
+ to: number;
89
+ }
90
+
91
+ /** Aggregate totals across a time range. */
92
+ export interface UsageTotals {
93
+ totalInputTokens: number;
94
+ totalOutputTokens: number;
95
+ totalCacheCreationTokens: number;
96
+ totalCacheReadTokens: number;
97
+ totalEstimatedCostUsd: number;
98
+ eventCount: number;
99
+ pricedEventCount: number;
100
+ unpricedEventCount: number;
101
+ }
102
+
103
+ /** A single day bucket with its aggregate totals. */
104
+ export interface UsageDayBucket {
105
+ /** ISO date string (YYYY-MM-DD) in UTC. */
106
+ date: string;
107
+ totalInputTokens: number;
108
+ totalOutputTokens: number;
109
+ totalEstimatedCostUsd: number;
110
+ eventCount: number;
111
+ }
112
+
113
+ /** A grouped breakdown row (by actor, provider, or model). */
114
+ export interface UsageGroupBreakdown {
115
+ group: string;
116
+ totalInputTokens: number;
117
+ totalOutputTokens: number;
118
+ totalEstimatedCostUsd: number;
119
+ eventCount: number;
120
+ }
121
+
122
+ // -- raw row shapes returned by SQLite aggregation queries --
123
+
124
+ interface TotalsRow {
125
+ total_input_tokens: number;
126
+ total_output_tokens: number;
127
+ total_cache_creation_tokens: number;
128
+ total_cache_read_tokens: number;
129
+ total_estimated_cost_usd: number | null;
130
+ event_count: number;
131
+ priced_event_count: number;
132
+ unpriced_event_count: number;
133
+ }
134
+
135
+ interface DayBucketRow {
136
+ date: string;
137
+ total_input_tokens: number;
138
+ total_output_tokens: number;
139
+ total_estimated_cost_usd: number | null;
140
+ event_count: number;
141
+ }
142
+
143
+ interface GroupRow {
144
+ group_key: string;
145
+ total_input_tokens: number;
146
+ total_output_tokens: number;
147
+ total_estimated_cost_usd: number | null;
148
+ event_count: number;
149
+ }
150
+
151
+ /**
152
+ * Return aggregate totals for all usage events within the given time range.
153
+ */
154
+ export function getUsageTotals(range: UsageTimeRange): UsageTotals {
155
+ const rows = rawAll<TotalsRow>(
156
+ /*sql*/ `
157
+ SELECT
158
+ COALESCE(SUM(input_tokens), 0) AS total_input_tokens,
159
+ COALESCE(SUM(output_tokens), 0) AS total_output_tokens,
160
+ COALESCE(SUM(cache_creation_input_tokens), 0) AS total_cache_creation_tokens,
161
+ COALESCE(SUM(cache_read_input_tokens), 0) AS total_cache_read_tokens,
162
+ COALESCE(SUM(estimated_cost_usd), 0) AS total_estimated_cost_usd,
163
+ COUNT(*) AS event_count,
164
+ COUNT(CASE WHEN pricing_status = 'priced' THEN 1 END) AS priced_event_count,
165
+ COUNT(CASE WHEN pricing_status = 'unpriced' THEN 1 END) AS unpriced_event_count
166
+ FROM llm_usage_events
167
+ WHERE created_at >= ?1 AND created_at <= ?2
168
+ `,
169
+ range.from,
170
+ range.to,
171
+ );
172
+ const row = rows[0];
173
+ return {
174
+ totalInputTokens: row.total_input_tokens,
175
+ totalOutputTokens: row.total_output_tokens,
176
+ totalCacheCreationTokens: row.total_cache_creation_tokens,
177
+ totalCacheReadTokens: row.total_cache_read_tokens,
178
+ totalEstimatedCostUsd: row.total_estimated_cost_usd ?? 0,
179
+ eventCount: row.event_count,
180
+ pricedEventCount: row.priced_event_count,
181
+ unpricedEventCount: row.unpriced_event_count,
182
+ };
183
+ }
184
+
185
+ /**
186
+ * Return per-day aggregates (UTC) within the given time range, ordered by date ascending.
187
+ *
188
+ * Each bucket key is a YYYY-MM-DD string derived by dividing the epoch-millis
189
+ * timestamp by 86400000 and formatting as a date.
190
+ */
191
+ export function getUsageDayBuckets(range: UsageTimeRange): UsageDayBucket[] {
192
+ const rows = rawAll<DayBucketRow>(
193
+ /*sql*/ `
194
+ SELECT
195
+ strftime('%Y-%m-%d', created_at / 1000, 'unixepoch') AS date,
196
+ COALESCE(SUM(input_tokens), 0) AS total_input_tokens,
197
+ COALESCE(SUM(output_tokens), 0) AS total_output_tokens,
198
+ COALESCE(SUM(estimated_cost_usd), 0) AS total_estimated_cost_usd,
199
+ COUNT(*) AS event_count
200
+ FROM llm_usage_events
201
+ WHERE created_at >= ?1 AND created_at <= ?2
202
+ GROUP BY date
203
+ ORDER BY date ASC
204
+ `,
205
+ range.from,
206
+ range.to,
207
+ );
208
+ return rows.map((r) => ({
209
+ date: r.date,
210
+ totalInputTokens: r.total_input_tokens,
211
+ totalOutputTokens: r.total_output_tokens,
212
+ totalEstimatedCostUsd: r.total_estimated_cost_usd ?? 0,
213
+ eventCount: r.event_count,
214
+ }));
215
+ }
216
+
217
+ type GroupByDimension = "actor" | "provider" | "model";
218
+
219
+ /**
220
+ * Return grouped breakdowns across the given time range, ordered by total
221
+ * estimated cost descending (most expensive group first).
222
+ */
223
+ export function getUsageGroupBreakdown(
224
+ range: UsageTimeRange,
225
+ groupBy: GroupByDimension,
226
+ ): UsageGroupBreakdown[] {
227
+ // Runtime allowlist — defense-in-depth against SQL injection via type assertions.
228
+ const ALLOWED_COLUMNS = new Set<string>(["actor", "provider", "model"]);
229
+ if (!ALLOWED_COLUMNS.has(groupBy)) {
230
+ throw new Error(`Invalid groupBy column: ${groupBy}`);
231
+ }
232
+ const column = groupBy;
233
+ const rows = rawAll<GroupRow>(
234
+ /*sql*/ `
235
+ SELECT
236
+ ${column} AS group_key,
237
+ COALESCE(SUM(input_tokens), 0) AS total_input_tokens,
238
+ COALESCE(SUM(output_tokens), 0) AS total_output_tokens,
239
+ COALESCE(SUM(estimated_cost_usd), 0) AS total_estimated_cost_usd,
240
+ COUNT(*) AS event_count
241
+ FROM llm_usage_events
242
+ WHERE created_at >= ?1 AND created_at <= ?2
243
+ GROUP BY ${column}
244
+ ORDER BY total_estimated_cost_usd DESC
245
+ `,
246
+ range.from,
247
+ range.to,
248
+ );
249
+ return rows.map((r) => ({
250
+ group: r.group_key,
251
+ totalInputTokens: r.total_input_tokens,
252
+ totalOutputTokens: r.total_output_tokens,
253
+ totalEstimatedCostUsd: r.total_estimated_cost_usd ?? 0,
254
+ eventCount: r.event_count,
255
+ }));
256
+ }
@@ -0,0 +1,26 @@
1
+ import type { DrizzleDb } from "../db-connection.js";
2
+
3
+ /**
4
+ * Idempotent migration to add indexes on llm_usage_events for the
5
+ * time-range and breakdown queries the usage dashboard needs.
6
+ *
7
+ * - Covering index on (created_at) for efficient time-range scans.
8
+ * - Composite index on (actor, created_at) for per-actor breakdowns.
9
+ * - Composite index on (provider, model, created_at) for provider/model grouping.
10
+ *
11
+ * SUPERSEDED: The two composite indexes are dropped by migration 139.
12
+ * They don't accelerate grouped queries — SQLite still uses temp B-trees
13
+ * for GROUP BY regardless of index column order. Only the plain
14
+ * created_at index (kept) provides value for range scans.
15
+ */
16
+ export function migrateUsageDashboardIndexes(database: DrizzleDb): void {
17
+ database.run(
18
+ /*sql*/ `CREATE INDEX IF NOT EXISTS idx_llm_usage_events_created_at ON llm_usage_events(created_at)`,
19
+ );
20
+ database.run(
21
+ /*sql*/ `CREATE INDEX IF NOT EXISTS idx_llm_usage_events_actor_created_at ON llm_usage_events(actor, created_at)`,
22
+ );
23
+ database.run(
24
+ /*sql*/ `CREATE INDEX IF NOT EXISTS idx_llm_usage_events_provider_model_created_at ON llm_usage_events(provider, model, created_at)`,
25
+ );
26
+ }
@@ -0,0 +1,30 @@
1
+ import type { DrizzleDb } from "../db-connection.js";
2
+
3
+ /**
4
+ * Drop all composite indexes on llm_usage_events added by migrations 137
5
+ * and 138. EXPLAIN QUERY PLAN shows they provide no benefit: SQLite uses
6
+ * the created_at prefix for the range scan but still needs a temp B-tree
7
+ * for GROUP BY because the grouping column isn't contiguous after a range
8
+ * filter. For a local SQLite DB with typical usage volumes, the plain
9
+ * created_at index is sufficient and the temp B-tree overhead is negligible.
10
+ *
11
+ * The plain idx_llm_usage_events_created_at index (from migration 137)
12
+ * is intentionally kept — it genuinely helps range scans.
13
+ */
14
+ export function migrateDropUsageCompositeIndexes(database: DrizzleDb): void {
15
+ // Migration 137 composites (may already be dropped by 138, hence IF EXISTS)
16
+ database.run(
17
+ /*sql*/ `DROP INDEX IF EXISTS idx_llm_usage_events_actor_created_at`,
18
+ );
19
+ database.run(
20
+ /*sql*/ `DROP INDEX IF EXISTS idx_llm_usage_events_provider_model_created_at`,
21
+ );
22
+
23
+ // Migration 138 composites
24
+ database.run(
25
+ /*sql*/ `DROP INDEX IF EXISTS idx_llm_usage_events_created_at_actor`,
26
+ );
27
+ database.run(
28
+ /*sql*/ `DROP INDEX IF EXISTS idx_llm_usage_events_created_at_provider_model`,
29
+ );
30
+ }
@@ -79,6 +79,8 @@ export { migrateAssistantContactMetadata } from "./133-assistant-contact-metadat
79
79
  export { migrateContactsNotesColumn } from "./134-contacts-notes-column.js";
80
80
  export { migrateBackfillContactInteractionStats } from "./135-backfill-contact-interaction-stats.js";
81
81
  export { migrateDropAssistantIdColumns } from "./136-drop-assistant-id-columns.js";
82
+ export { migrateUsageDashboardIndexes } from "./137-usage-dashboard-indexes.js";
83
+ export { migrateDropUsageCompositeIndexes } from "./139-drop-usage-composite-indexes.js";
82
84
  export {
83
85
  MIGRATION_REGISTRY,
84
86
  type MigrationRegistryEntry,
@@ -28,6 +28,7 @@ export {
28
28
  migrateRemoveAssistantIdColumns,
29
29
  migrateSchemaIndexesAndColumns,
30
30
  migrateToolInvocationsFk,
31
+ migrateUsageDashboardIndexes,
31
32
  MIGRATION_REGISTRY,
32
33
  type MigrationRegistryEntry,
33
34
  type MigrationValidationResult,
@@ -1,7 +1,7 @@
1
1
  /**
2
2
  * Store for cloud-shared app link records.
3
3
  *
4
- * Each record holds a .vellumapp zip bundle keyed by a short, shareable token.
4
+ * Each record holds a .vellum zip bundle keyed by a short, shareable token.
5
5
  */
6
6
 
7
7
  import { randomBytes, randomUUID } from "node:crypto";
@@ -2,9 +2,21 @@
2
2
  * Messaging provider registry — register/lookup providers by platform ID.
3
3
  */
4
4
 
5
+ import { isAssistantFeatureFlagEnabled } from "../config/assistant-feature-flags.js";
6
+ import { getConfig } from "../config/loader.js";
5
7
  import { getSecureKey } from "../security/secure-keys.js";
6
8
  import type { MessagingProvider } from "./provider.js";
7
9
 
10
+ /**
11
+ * Per-platform feature flag keys. Platforms not listed here are allowed
12
+ * by default (undeclared keys resolve to `true`).
13
+ */
14
+ const PLATFORM_FLAG_KEYS: Record<string, string> = {
15
+ gmail: "feature_flags.messaging.gmail.enabled",
16
+ telegram: "feature_flags.messaging.telegram.enabled",
17
+ sms: "feature_flags.sms.enabled",
18
+ };
19
+
8
20
  const providers = new Map<string, MessagingProvider>();
9
21
 
10
22
  export function registerMessagingProvider(provider: MessagingProvider): void {
@@ -19,9 +31,24 @@ export function getMessagingProvider(id: string): MessagingProvider {
19
31
  `Messaging provider "${id}" not found. Available: ${available}`,
20
32
  );
21
33
  }
34
+ assertPlatformEnabled(id);
22
35
  return provider;
23
36
  }
24
37
 
38
+ export function isPlatformEnabled(platformId: string): boolean {
39
+ const flagKey = PLATFORM_FLAG_KEYS[platformId];
40
+ if (!flagKey) return true;
41
+ return isAssistantFeatureFlagEnabled(flagKey, getConfig());
42
+ }
43
+
44
+ function assertPlatformEnabled(platformId: string): void {
45
+ if (!isPlatformEnabled(platformId)) {
46
+ throw new Error(
47
+ `The ${platformId} platform is not enabled. Enable it in Settings > Features.`,
48
+ );
49
+ }
50
+ }
51
+
25
52
  /** Return all registered providers that have stored credentials. */
26
53
  export function getConnectedProviders(): MessagingProvider[] {
27
54
  return Array.from(providers.values()).filter((p) => {