@vellumai/assistant 0.5.11 → 0.5.13

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 (209) hide show
  1. package/Dockerfile +42 -9
  2. package/docs/architecture/integrations.md +34 -32
  3. package/node_modules/@vellumai/ces-contracts/src/__tests__/grants.test.ts +7 -7
  4. package/node_modules/@vellumai/ces-contracts/src/handles.ts +5 -4
  5. package/node_modules/@vellumai/ces-contracts/src/index.ts +7 -0
  6. package/node_modules/@vellumai/ces-contracts/src/rpc.ts +5 -0
  7. package/node_modules/@vellumai/credential-storage/src/index.ts +1 -1
  8. package/openapi.yaml +87 -9
  9. package/package.json +1 -1
  10. package/src/__tests__/catalog-cache.test.ts +164 -0
  11. package/src/__tests__/catalog-search.test.ts +61 -0
  12. package/src/__tests__/cli-command-risk-guard.test.ts +181 -6
  13. package/src/__tests__/conversation-delete-schedule-cleanup.test.ts +396 -0
  14. package/src/__tests__/conversation-error.test.ts +3 -2
  15. package/src/__tests__/credential-security-invariants.test.ts +9 -15
  16. package/src/__tests__/credential-vault-unit.test.ts +32 -34
  17. package/src/__tests__/credential-vault.test.ts +25 -33
  18. package/src/__tests__/credentials-cli.test.ts +3 -3
  19. package/src/__tests__/daemon-credential-client.test.ts +2 -2
  20. package/src/__tests__/first-greeting.test.ts +7 -0
  21. package/src/__tests__/host-bash-proxy.test.ts +79 -0
  22. package/src/__tests__/host-cu-proxy.test.ts +90 -0
  23. package/src/__tests__/host-file-proxy.test.ts +89 -0
  24. package/src/__tests__/integration-status.test.ts +5 -5
  25. package/src/__tests__/list-messages-attachments.test.ts +171 -0
  26. package/src/__tests__/mcp-abort-signal.test.ts +205 -0
  27. package/src/__tests__/messaging-send-tool.test.ts +5 -5
  28. package/src/__tests__/navigate-settings-tab.test.ts +6 -2
  29. package/src/__tests__/notification-telegram-adapter.test.ts +125 -0
  30. package/src/__tests__/oauth-cli.test.ts +126 -119
  31. package/src/__tests__/oauth-provider-profiles.test.ts +55 -20
  32. package/src/__tests__/oauth-scope-policy.test.ts +4 -6
  33. package/src/__tests__/onboarding-template-contract.test.ts +2 -2
  34. package/src/__tests__/platform.test.ts +3 -168
  35. package/src/__tests__/secret-routes-managed-proxy.test.ts +78 -0
  36. package/src/__tests__/secure-keys-managed-failover.test.ts +73 -0
  37. package/src/__tests__/skill-feature-flags.test.ts +8 -0
  38. package/src/__tests__/skill-secret-handling-guard.test.ts +212 -0
  39. package/src/__tests__/skills-uninstall.test.ts +2 -2
  40. package/src/__tests__/slack-messaging-token-resolution.test.ts +22 -24
  41. package/src/__tests__/slack-share-routes.test.ts +5 -5
  42. package/src/__tests__/system-prompt.test.ts +39 -0
  43. package/src/__tests__/token-estimator-accuracy.benchmark.test.ts +1 -1
  44. package/src/__tests__/workspace-migration-backfill-installation-id.test.ts +5 -4
  45. package/src/cli/AGENTS.md +47 -7
  46. package/src/cli/commands/browser-relay.ts +2 -17
  47. package/src/cli/commands/contacts.ts +6 -4
  48. package/src/cli/commands/conversations.ts +13 -1
  49. package/src/cli/commands/credential-execution.ts +16 -1
  50. package/src/cli/commands/credentials.ts +2 -8
  51. package/src/cli/commands/oauth/__tests__/connect.test.ts +29 -108
  52. package/src/cli/commands/oauth/__tests__/disconnect.test.ts +13 -87
  53. package/src/cli/commands/oauth/__tests__/mode.test.ts +22 -69
  54. package/src/cli/commands/oauth/__tests__/ping.test.ts +20 -79
  55. package/src/cli/commands/oauth/__tests__/providers-delete.test.ts +574 -0
  56. package/src/cli/commands/oauth/__tests__/providers-update.test.ts +416 -0
  57. package/src/cli/commands/oauth/__tests__/status.test.ts +12 -40
  58. package/src/cli/commands/oauth/__tests__/token.test.ts +3 -50
  59. package/src/cli/commands/oauth/apps.ts +63 -44
  60. package/src/cli/commands/oauth/connect.ts +187 -155
  61. package/src/cli/commands/oauth/disconnect.ts +27 -75
  62. package/src/cli/commands/oauth/index.ts +36 -46
  63. package/src/cli/commands/oauth/mode.ts +22 -34
  64. package/src/cli/commands/oauth/ping.ts +19 -45
  65. package/src/cli/commands/oauth/providers.ts +569 -62
  66. package/src/cli/commands/oauth/request.ts +36 -48
  67. package/src/cli/commands/oauth/shared.ts +1 -19
  68. package/src/cli/commands/oauth/status.ts +14 -25
  69. package/src/cli/commands/oauth/token.ts +25 -34
  70. package/src/cli/commands/platform/__tests__/connect.test.ts +224 -0
  71. package/src/cli/commands/platform/__tests__/disconnect.test.ts +237 -0
  72. package/src/cli/commands/platform/__tests__/status.test.ts +246 -0
  73. package/src/cli/commands/platform/connect.ts +104 -0
  74. package/src/cli/commands/platform/disconnect.ts +118 -0
  75. package/src/cli/commands/{platform.ts → platform/index.ts} +108 -38
  76. package/src/cli/commands/sequence.ts +5 -4
  77. package/src/cli/commands/shotgun.ts +16 -0
  78. package/src/cli/commands/skills.ts +173 -41
  79. package/src/cli/commands/usage.ts +5 -11
  80. package/src/cli/lib/daemon-credential-client.ts +22 -38
  81. package/src/cli/program.ts +1 -1
  82. package/src/config/assistant-feature-flags.ts +3 -7
  83. package/src/config/bundled-skills/contacts/tools/google-contacts.ts +1 -1
  84. package/src/config/bundled-skills/conversations/SKILL.md +20 -0
  85. package/src/config/bundled-skills/conversations/TOOLS.json +23 -0
  86. package/src/config/bundled-skills/conversations/tools/rename-conversation.ts +66 -0
  87. package/src/config/bundled-skills/gmail/SKILL.md +13 -13
  88. package/src/config/bundled-skills/gmail/tools/gmail-archive.ts +3 -3
  89. package/src/config/bundled-skills/gmail/tools/gmail-attachments.ts +2 -2
  90. package/src/config/bundled-skills/gmail/tools/gmail-draft.ts +1 -1
  91. package/src/config/bundled-skills/gmail/tools/gmail-filters.ts +1 -1
  92. package/src/config/bundled-skills/gmail/tools/gmail-follow-up.ts +1 -1
  93. package/src/config/bundled-skills/gmail/tools/gmail-forward.ts +1 -1
  94. package/src/config/bundled-skills/gmail/tools/gmail-label.ts +2 -2
  95. package/src/config/bundled-skills/gmail/tools/gmail-outreach-scan.ts +1 -1
  96. package/src/config/bundled-skills/gmail/tools/gmail-send-draft.ts +1 -1
  97. package/src/config/bundled-skills/gmail/tools/gmail-sender-digest.ts +1 -1
  98. package/src/config/bundled-skills/gmail/tools/gmail-trash.ts +1 -1
  99. package/src/config/bundled-skills/gmail/tools/gmail-unsubscribe.ts +1 -1
  100. package/src/config/bundled-skills/gmail/tools/gmail-vacation.ts +1 -1
  101. package/src/config/bundled-skills/google-calendar/SKILL.md +10 -4
  102. package/src/config/bundled-skills/google-calendar/tools/shared.ts +1 -1
  103. package/src/config/bundled-skills/messaging/SKILL.md +7 -7
  104. package/src/config/bundled-skills/messaging/tools/messaging-send.ts +5 -2
  105. package/src/config/bundled-skills/messaging/tools/shared.ts +5 -6
  106. package/src/config/bundled-skills/settings/TOOLS.json +5 -3
  107. package/src/config/bundled-skills/settings/tools/navigate-settings-tab.ts +4 -2
  108. package/src/config/bundled-tool-registry.ts +5 -0
  109. package/src/config/feature-flag-registry.json +2 -2
  110. package/src/credential-execution/client.ts +15 -3
  111. package/src/daemon/conversation-agent-loop.ts +2 -0
  112. package/src/daemon/conversation-error.ts +36 -6
  113. package/src/daemon/conversation-messaging.ts +9 -0
  114. package/src/daemon/conversation-runtime-assembly.ts +33 -0
  115. package/src/daemon/conversation-surfaces.ts +120 -14
  116. package/src/daemon/conversation.ts +5 -0
  117. package/src/daemon/first-greeting.ts +6 -1
  118. package/src/daemon/handlers/skills.ts +148 -3
  119. package/src/daemon/host-bash-proxy.ts +16 -0
  120. package/src/daemon/host-cu-proxy.ts +16 -0
  121. package/src/daemon/host-file-proxy.ts +16 -0
  122. package/src/daemon/lifecycle.ts +56 -5
  123. package/src/daemon/message-types/conversations.ts +1 -0
  124. package/src/daemon/message-types/guardian-actions.ts +2 -0
  125. package/src/daemon/message-types/host-bash.ts +6 -1
  126. package/src/daemon/message-types/host-cu.ts +6 -1
  127. package/src/daemon/message-types/host-file.ts +6 -1
  128. package/src/daemon/message-types/integrations.ts +0 -1
  129. package/src/daemon/server.ts +29 -2
  130. package/src/hooks/cli.ts +74 -0
  131. package/src/inbound/platform-callback-registration.ts +7 -12
  132. package/src/index.ts +0 -12
  133. package/src/mcp/client.ts +6 -1
  134. package/src/mcp/manager.ts +2 -1
  135. package/src/memory/conversation-crud.ts +92 -3
  136. package/src/memory/conversation-key-store.ts +26 -0
  137. package/src/memory/conversation-queries.ts +6 -6
  138. package/src/memory/db-init.ts +16 -0
  139. package/src/memory/journal-memory.ts +8 -2
  140. package/src/memory/migrations/196-messages-conversation-created-at-index.ts +9 -0
  141. package/src/memory/migrations/196-strip-integration-prefix-from-provider-keys.ts +186 -0
  142. package/src/memory/migrations/197-oauth-providers-behavior-columns.ts +29 -0
  143. package/src/memory/migrations/198-drop-setup-skill-id-column.ts +11 -0
  144. package/src/memory/migrations/index.ts +4 -0
  145. package/src/memory/migrations/registry.ts +8 -0
  146. package/src/memory/schema/oauth.ts +11 -0
  147. package/src/messaging/provider.ts +13 -12
  148. package/src/messaging/providers/gmail/adapter.ts +44 -35
  149. package/src/messaging/providers/slack/adapter.ts +63 -33
  150. package/src/messaging/providers/telegram-bot/adapter.ts +6 -8
  151. package/src/messaging/providers/whatsapp/adapter.ts +6 -8
  152. package/src/notifications/adapters/telegram.ts +78 -2
  153. package/src/oauth/__tests__/identity-verifier.test.ts +464 -0
  154. package/src/oauth/byo-connection.test.ts +22 -24
  155. package/src/oauth/connect-orchestrator.ts +37 -76
  156. package/src/oauth/connect-types.ts +7 -65
  157. package/src/oauth/connection-resolver.test.ts +13 -13
  158. package/src/oauth/connection-resolver.ts +3 -4
  159. package/src/oauth/identity-verifier.ts +177 -0
  160. package/src/oauth/oauth-store.ts +228 -3
  161. package/src/oauth/platform-connection.test.ts +56 -6
  162. package/src/oauth/platform-connection.ts +8 -1
  163. package/src/oauth/seed-providers.ts +247 -34
  164. package/src/permissions/checker.ts +127 -1
  165. package/src/prompts/journal-context.ts +4 -1
  166. package/src/prompts/system-prompt.ts +54 -9
  167. package/src/prompts/templates/BOOTSTRAP.md +16 -5
  168. package/src/providers/anthropic/client.ts +2 -33
  169. package/src/runtime/guardian-action-service.ts +7 -2
  170. package/src/runtime/http-server.ts +12 -18
  171. package/src/runtime/http-types.ts +8 -1
  172. package/src/runtime/migrations/rebind-secrets-screen.ts +2 -2
  173. package/src/runtime/routes/conversation-management-routes.ts +31 -0
  174. package/src/runtime/routes/conversation-routes.ts +79 -4
  175. package/src/runtime/routes/guardian-action-routes.ts +15 -2
  176. package/src/runtime/routes/inbound-stages/acl-enforcement.ts +21 -8
  177. package/src/runtime/routes/integrations/slack/share.ts +1 -1
  178. package/src/runtime/routes/oauth-apps.ts +2 -1
  179. package/src/runtime/routes/secret-routes.ts +45 -15
  180. package/src/runtime/routes/settings-routes.ts +12 -19
  181. package/src/runtime/routes/skills-routes.ts +45 -4
  182. package/src/schedule/integration-status.ts +2 -2
  183. package/src/security/ces-rpc-credential-backend.ts +19 -16
  184. package/src/security/oauth-completion-page.ts +153 -0
  185. package/src/security/oauth2.ts +3 -17
  186. package/src/security/secure-keys.ts +207 -7
  187. package/src/security/token-manager.ts +3 -6
  188. package/src/signals/bash.ts +6 -1
  189. package/src/skills/catalog-cache.ts +44 -0
  190. package/src/skills/catalog-search.ts +18 -0
  191. package/src/tools/browser/browser-manager.ts +2 -2
  192. package/src/tools/credentials/post-connect-hooks.ts +1 -1
  193. package/src/tools/credentials/vault.ts +34 -45
  194. package/src/tools/host-terminal/host-shell.ts +16 -3
  195. package/src/tools/mcp/mcp-tool-factory.ts +2 -1
  196. package/src/tools/skills/sandbox-runner.ts +16 -3
  197. package/src/tools/terminal/shell.ts +16 -3
  198. package/src/util/logger.ts +11 -1
  199. package/src/util/platform.ts +1 -91
  200. package/src/util/sentry-log-stream.ts +51 -0
  201. package/src/watcher/providers/github.ts +2 -2
  202. package/src/watcher/providers/gmail.ts +1 -1
  203. package/src/watcher/providers/google-calendar.ts +1 -1
  204. package/src/watcher/providers/linear.ts +2 -2
  205. package/src/workspace/migrations/011-backfill-installation-id.ts +5 -3
  206. package/src/workspace/migrations/020-rename-oauth-skill-dirs.ts +119 -0
  207. package/src/workspace/migrations/registry.ts +2 -0
  208. package/src/cli/commands/oauth/connections.ts +0 -255
  209. package/src/oauth/provider-behaviors.ts +0 -634
@@ -1000,6 +1000,23 @@ export function stripInterfaceTurnContext(messages: Message[]): Message[] {
1000
1000
  ]);
1001
1001
  }
1002
1002
 
1003
+ // ---------------------------------------------------------------------------
1004
+ // Transport hints injection (e.g. Slack thread context from the gateway)
1005
+ // ---------------------------------------------------------------------------
1006
+
1007
+ function injectTransportHints(message: Message, hints: string[]): Message {
1008
+ const block = `<transport_hints>\n${hints.join("\n")}\n</transport_hints>`;
1009
+ return {
1010
+ ...message,
1011
+ content: [{ type: "text", text: block }, ...message.content],
1012
+ };
1013
+ }
1014
+
1015
+ /** Strip `<transport_hints>` blocks injected by `injectTransportHints`. */
1016
+ export function stripTransportHints(messages: Message[]): Message[] {
1017
+ return stripUserTextBlocksByPrefix(messages, ["<transport_hints>"]);
1018
+ }
1019
+
1003
1020
  /** Prefixes stripped by the pipeline (order doesn't matter — single pass). */
1004
1021
  const RUNTIME_INJECTION_PREFIXES = [
1005
1022
  "<channel_capabilities>",
@@ -1018,6 +1035,7 @@ const RUNTIME_INJECTION_PREFIXES = [
1018
1035
  "<active_dynamic_page>",
1019
1036
  "<non_interactive_context>",
1020
1037
  "<now_scratchpad>",
1038
+ "<transport_hints>",
1021
1039
  ];
1022
1040
 
1023
1041
  /**
@@ -1064,6 +1082,7 @@ export function applyRuntimeInjections(
1064
1082
  voiceCallControlPrompt?: string | null;
1065
1083
  nowScratchpad?: string | null;
1066
1084
  isNonInteractive?: boolean;
1085
+ transportHints?: string[] | null;
1067
1086
  mode?: InjectionMode;
1068
1087
  },
1069
1088
  ): Message[] {
@@ -1165,6 +1184,20 @@ export function applyRuntimeInjections(
1165
1184
  }
1166
1185
  }
1167
1186
 
1187
+ if (
1188
+ mode === "full" &&
1189
+ options.transportHints &&
1190
+ options.transportHints.length > 0
1191
+ ) {
1192
+ const userTail = result[result.length - 1];
1193
+ if (userTail && userTail.role === "user") {
1194
+ result = [
1195
+ ...result.slice(0, -1),
1196
+ injectTransportHints(userTail, options.transportHints),
1197
+ ];
1198
+ }
1199
+ }
1200
+
1168
1201
  // Temporal context is injected before workspace top-level so it
1169
1202
  // appears after workspace context in the final message content
1170
1203
  // (both are prepended, so later injections appear first).
@@ -26,6 +26,7 @@ import type {
26
26
  UiSurfaceShow,
27
27
  } from "./message-protocol.js";
28
28
  import { INTERACTIVE_SURFACE_TYPES } from "./message-protocol.js";
29
+ import type { UserMessageAttachment } from "./message-types/shared.js";
29
30
 
30
31
  const log = getLogger("conversation-surfaces");
31
32
 
@@ -217,7 +218,7 @@ export interface SurfaceConversationContext {
217
218
  isProcessing(): boolean;
218
219
  enqueueMessage(
219
220
  content: string,
220
- attachments: never[],
221
+ attachments: UserMessageAttachment[],
221
222
  onEvent: (msg: ServerMessage) => void,
222
223
  requestId: string,
223
224
  activeSurfaceId?: string,
@@ -229,7 +230,7 @@ export interface SurfaceConversationContext {
229
230
  getQueueDepth(): number;
230
231
  processMessage(
231
232
  content: string,
232
- attachments: never[],
233
+ attachments: UserMessageAttachment[],
233
234
  onEvent: (msg: ServerMessage) => void,
234
235
  requestId?: string,
235
236
  activeSurfaceId?: string,
@@ -625,6 +626,36 @@ export function handleSurfaceAction(
625
626
  const accState = ctx.accumulatedSurfaceState.get(surfaceId);
626
627
  const hasAccState = accState && Object.keys(accState).length > 0;
627
628
 
629
+ // Extract file attachments from action data so they are sent as proper
630
+ // image/file content blocks instead of dumping base64 into the text.
631
+ let attachments: UserMessageAttachment[] = [];
632
+ let actionDataForText = data;
633
+ if (data && Array.isArray(data.files)) {
634
+ const files = data.files as Array<Record<string, unknown>>;
635
+ attachments = files
636
+ .filter(
637
+ (f) =>
638
+ typeof f.filename === "string" &&
639
+ typeof f.mimeType === "string" &&
640
+ typeof f.data === "string",
641
+ )
642
+ .map((f) => ({
643
+ filename: f.filename as string,
644
+ mimeType: f.mimeType as string,
645
+ data: f.data as string,
646
+ ...(typeof f.extractedText === "string"
647
+ ? { extractedText: f.extractedText }
648
+ : {}),
649
+ }));
650
+ // Only remove files from the text payload when we successfully parsed
651
+ // attachments — otherwise preserve the original data so the model still
652
+ // sees the files field (e.g. IDs/paths from dynamic app actions).
653
+ if (attachments.length > 0) {
654
+ const { files: _files, ...rest } = data;
655
+ actionDataForText = Object.keys(rest).length > 0 ? rest : undefined;
656
+ }
657
+ }
658
+
628
659
  let content: string;
629
660
  let displayContent: string | undefined;
630
661
  if (prompt) {
@@ -639,8 +670,12 @@ export function handleSurfaceAction(
639
670
  .replace(/_/g, " ")
640
671
  .replace(/\b\w/g, (c) => c.toUpperCase());
641
672
  content = `[User action on app: ${summary}]`;
642
- if (data && Object.keys(data).length > 0) {
643
- content += `\n\nAction data: ${JSON.stringify(data)}`;
673
+ if (attachments.length > 0) {
674
+ const names = attachments.map((a) => a.filename).join(", ");
675
+ content += `\n\nUploaded files: ${names}`;
676
+ }
677
+ if (actionDataForText && Object.keys(actionDataForText).length > 0) {
678
+ content += `\n\nAction data: ${JSON.stringify(actionDataForText)}`;
644
679
  }
645
680
  if (hasAccState) {
646
681
  content += `\n\nAccumulated surface state: ${JSON.stringify(accState)}`;
@@ -648,6 +683,23 @@ export function handleSurfaceAction(
648
683
  displayContent = summary;
649
684
  }
650
685
 
686
+ log.info(
687
+ {
688
+ surfaceId,
689
+ actionId,
690
+ contentLength: content.length,
691
+ contentPreview: content.slice(0, 200),
692
+ attachmentCount: attachments.length,
693
+ attachments: attachments.map((a) => ({
694
+ filename: a.filename,
695
+ mimeType: a.mimeType,
696
+ dataLength: a.data?.length ?? 0,
697
+ hasExtractedText: !!a.extractedText,
698
+ })),
699
+ },
700
+ "Surface action: preparing to send message to model",
701
+ );
702
+
651
703
  const requestId = uuid();
652
704
  ctx.surfaceActionRequestIds.add(requestId);
653
705
  // Use broadcastToAllClients (publishes to the SSE event hub) instead of
@@ -665,7 +717,7 @@ export function handleSurfaceAction(
665
717
 
666
718
  const result = ctx.enqueueMessage(
667
719
  content,
668
- [],
720
+ attachments,
669
721
  onEvent,
670
722
  requestId,
671
723
  surfaceId,
@@ -706,13 +758,13 @@ export function handleSurfaceAction(
706
758
 
707
759
  // Conversation is idle — process the message immediately.
708
760
  log.info(
709
- { surfaceId, actionId, requestId },
710
- "Processing surface action immediately (history-restored)",
761
+ { surfaceId, actionId, requestId, attachmentCount: attachments.length },
762
+ "Processing surface action immediately (history-restored) with attachments",
711
763
  );
712
764
  ctx
713
765
  .processMessage(
714
766
  content,
715
- [],
767
+ attachments,
716
768
  onEvent,
717
769
  requestId,
718
770
  surfaceId,
@@ -807,11 +859,45 @@ export function handleSurfaceAction(
807
859
  });
808
860
  }
809
861
 
862
+ // Extract file attachments from action data so they are sent as proper
863
+ // image/file content blocks instead of dumping base64 into the text.
864
+ let pendingAttachments: UserMessageAttachment[] = [];
865
+ let mergedDataForText = mergedData;
866
+ if (mergedData && Array.isArray(mergedData.files)) {
867
+ const files = mergedData.files as Array<Record<string, unknown>>;
868
+ pendingAttachments = files
869
+ .filter(
870
+ (f) =>
871
+ typeof f.filename === "string" &&
872
+ typeof f.mimeType === "string" &&
873
+ typeof f.data === "string",
874
+ )
875
+ .map((f) => ({
876
+ filename: f.filename as string,
877
+ mimeType: f.mimeType as string,
878
+ data: f.data as string,
879
+ ...(typeof f.extractedText === "string"
880
+ ? { extractedText: f.extractedText }
881
+ : {}),
882
+ }));
883
+ // Only remove files from the text payload when we successfully parsed
884
+ // attachments — otherwise preserve the original data so the model still
885
+ // sees the files field.
886
+ if (pendingAttachments.length > 0) {
887
+ const { files: _files, ...rest } = mergedData;
888
+ mergedDataForText = Object.keys(rest).length > 0 ? rest : undefined;
889
+ }
890
+ }
891
+
810
892
  let fallbackContent = `[User action on ${pending.surfaceType} surface: ${summary}]`;
893
+ if (pendingAttachments.length > 0) {
894
+ const names = pendingAttachments.map((a) => a.filename).join(", ");
895
+ fallbackContent += `\n\nUploaded files: ${names}`;
896
+ }
811
897
  // Append structured data so the LLM has access to IDs/values it needs
812
898
  // to act on (e.g. selectedIds for archiving).
813
- if (mergedData && Object.keys(mergedData).length > 0) {
814
- fallbackContent += `\n\nAction data: ${JSON.stringify(mergedData)}`;
899
+ if (mergedDataForText && Object.keys(mergedDataForText).length > 0) {
900
+ fallbackContent += `\n\nAction data: ${JSON.stringify(mergedDataForText)}`;
815
901
  }
816
902
  // Append deselection context for table/list surfaces so the LLM knows what the user chose to keep.
817
903
  const selectedIds = mergedData?.selectedIds as string[] | undefined;
@@ -867,9 +953,24 @@ export function handleSurfaceAction(
867
953
  attributes: { source: "surface_action", surfaceId, actionId },
868
954
  });
869
955
 
956
+ log.info(
957
+ {
958
+ surfaceId,
959
+ actionId,
960
+ attachmentCount: pendingAttachments.length,
961
+ attachments: pendingAttachments.map((a) => ({
962
+ filename: a.filename,
963
+ mimeType: a.mimeType,
964
+ dataLength: a.data?.length ?? 0,
965
+ })),
966
+ contentPreview: content.slice(0, 200),
967
+ },
968
+ "Surface action follow-up: preparing to send message to model",
969
+ );
970
+
870
971
  const result = ctx.enqueueMessage(
871
972
  content,
872
- [],
973
+ pendingAttachments,
873
974
  onEvent,
874
975
  requestId,
875
976
  surfaceId,
@@ -929,13 +1030,18 @@ export function handleSurfaceAction(
929
1030
  ctx.pendingSurfaceActions.delete(surfaceId);
930
1031
  }
931
1032
  log.info(
932
- { surfaceId, actionId, requestId },
933
- "Processing surface action as follow-up",
1033
+ {
1034
+ surfaceId,
1035
+ actionId,
1036
+ requestId,
1037
+ attachmentCount: pendingAttachments.length,
1038
+ },
1039
+ "Processing surface action as follow-up with attachments",
934
1040
  );
935
1041
  ctx
936
1042
  .processMessage(
937
1043
  content,
938
- [],
1044
+ pendingAttachments,
939
1045
  onEvent,
940
1046
  requestId,
941
1047
  surfaceId,
@@ -187,6 +187,7 @@ export class Conversation {
187
187
  /** @internal */ authContext?: AuthContext;
188
188
  /** @internal */ loadedHistoryTrustClass?: TrustClass;
189
189
  /** @internal */ voiceCallControlPrompt?: string;
190
+ /** @internal */ transportHints?: string[];
190
191
  /** @internal */ assistantId?: string;
191
192
  /** @internal */ commandIntent?: {
192
193
  type: string;
@@ -892,6 +893,10 @@ export class Conversation {
892
893
  this.voiceCallControlPrompt = prompt ?? undefined;
893
894
  }
894
895
 
896
+ setTransportHints(hints: string[] | undefined): void {
897
+ this.transportHints = hints;
898
+ }
899
+
895
900
  setAssistantId(assistantId: string | null): void {
896
901
  this.assistantId = assistantId ?? undefined;
897
902
  }
@@ -22,7 +22,12 @@ export function isWakeUpGreeting(
22
22
  ): boolean {
23
23
  if (conversationMessageCount !== 0) return false;
24
24
  if (!existsSync(getWorkspacePromptPath("BOOTSTRAP.md"))) return false;
25
- return content.trim().toLowerCase() === "wake up, my friend.";
25
+ return (
26
+ content
27
+ .trim()
28
+ .toLowerCase()
29
+ .replace(/[.!?]+$/, "") === "wake up, my friend"
30
+ );
26
31
  }
27
32
 
28
33
  /**
@@ -25,6 +25,9 @@ import {
25
25
  userMessage,
26
26
  } from "../../providers/provider-send-message.js";
27
27
  import { isTextMimeType as isTextMime } from "../../runtime/routes/workspace-utils.js";
28
+ import { getCatalog } from "../../skills/catalog-cache.js";
29
+ import { installSkillLocally } from "../../skills/catalog-install.js";
30
+ import { filterByQuery } from "../../skills/catalog-search.js";
28
31
  import {
29
32
  clawhubCheckUpdates,
30
33
  clawhubInspect,
@@ -232,8 +235,9 @@ export interface SkillListItem {
232
235
  description: string;
233
236
  emoji?: string;
234
237
  homepage?: string;
235
- source: "bundled" | "managed" | "workspace" | "clawhub" | "extra";
238
+ source: "bundled" | "managed" | "workspace" | "clawhub" | "extra" | "catalog";
236
239
  state: "enabled" | "disabled";
240
+ installStatus: "bundled" | "installed" | "available";
237
241
  updateAvailable: boolean;
238
242
  provenance: SkillProvenance;
239
243
  }
@@ -259,6 +263,9 @@ export function listSkills(_ctx: SkillOperationContext): SkillListItem[] {
259
263
  homepage: r.summary.homepage,
260
264
  source: r.summary.source,
261
265
  state: r.state,
266
+ installStatus: (r.summary.source === "bundled"
267
+ ? "bundled"
268
+ : "installed") as SkillListItem["installStatus"],
262
269
  updateAvailable: false,
263
270
  provenance: resolveProvenance(r.summary),
264
271
  }));
@@ -275,6 +282,54 @@ export function listSkills(_ctx: SkillOperationContext): SkillListItem[] {
275
282
  return items;
276
283
  }
277
284
 
285
+ /**
286
+ * List installed skills merged with available catalog skills.
287
+ * Installed skills take precedence when deduplicating by ID.
288
+ */
289
+ export async function listSkillsWithCatalog(
290
+ ctx: SkillOperationContext,
291
+ ): Promise<SkillListItem[]> {
292
+ const installed = listSkills(ctx);
293
+ const installedIds = new Set(installed.map((s) => s.id));
294
+
295
+ let catalogSkills: import("../../skills/catalog-install.js").CatalogSkill[];
296
+ try {
297
+ catalogSkills = await getCatalog();
298
+ } catch {
299
+ // If catalog fetch fails, return installed-only
300
+ return installed;
301
+ }
302
+
303
+ // All entries from the Vellum platform API are first-party.
304
+ // Create SkillListItems for catalog skills not already installed.
305
+ const available: SkillListItem[] = catalogSkills
306
+ .filter((cs) => !installedIds.has(cs.id))
307
+ .map((cs) => ({
308
+ id: cs.id,
309
+ name: cs.metadata?.vellum?.["display-name"] ?? cs.name,
310
+ description: cs.description,
311
+ emoji: cs.emoji,
312
+ homepage: undefined,
313
+ source: "catalog" as const,
314
+ state: "disabled" as const,
315
+ installStatus: "available" as const,
316
+ updateAvailable: false,
317
+ provenance: { kind: "first-party" as const, provider: "Vellum" },
318
+ }));
319
+
320
+ const merged = [...installed, ...available];
321
+
322
+ // Sort using the same provenance sort + alphabetical
323
+ merged.sort((a, b) => {
324
+ const rankDiff =
325
+ provenanceSortRank(a.provenance) - provenanceSortRank(b.provenance);
326
+ if (rankDiff !== 0) return rankDiff;
327
+ return a.name.localeCompare(b.name);
328
+ });
329
+
330
+ return merged;
331
+ }
332
+
278
333
  /** Look up a single skill by ID from the resolved catalog, returning its SkillListItem. */
279
334
  function findSkillById(
280
335
  skillId: string,
@@ -294,6 +349,7 @@ function findSkillById(
294
349
  homepage: r.summary.homepage,
295
350
  source: r.summary.source,
296
351
  state: r.state,
352
+ installStatus: r.summary.source === "bundled" ? "bundled" : "installed",
297
353
  updateAvailable: false,
298
354
  provenance: resolveProvenance(r.summary),
299
355
  };
@@ -519,6 +575,43 @@ export async function installSkill(
519
575
  return { success: true };
520
576
  }
521
577
 
578
+ // Check the Vellum catalog (first-party skills hosted on the platform)
579
+ try {
580
+ const vellumCatalog = await getCatalog();
581
+ const catalogEntry = vellumCatalog.find((s) => s.id === spec.slug);
582
+ if (catalogEntry) {
583
+ await installSkillLocally(spec.slug, catalogEntry, true);
584
+
585
+ // Reload skill catalog so the newly installed skill is picked up
586
+ loadSkillCatalog();
587
+
588
+ // Auto-enable the newly installed catalog skill
589
+ try {
590
+ const raw = loadRawConfig();
591
+ ensureSkillEntry(raw, spec.slug).enabled = true;
592
+ saveConfigWithSuppression(raw, ctx);
593
+ ctx.broadcast({
594
+ type: "skills_state_changed",
595
+ name: spec.slug,
596
+ state: "enabled",
597
+ });
598
+ } catch (err) {
599
+ log.warn(
600
+ { err, skillId: spec.slug },
601
+ "Failed to auto-enable installed catalog skill",
602
+ );
603
+ }
604
+
605
+ return { success: true };
606
+ }
607
+ } catch (err) {
608
+ // If catalog lookup/install fails, fall through to clawhub
609
+ log.warn(
610
+ { err, skillId: spec.slug },
611
+ "Vellum catalog install failed, falling back to community registry",
612
+ );
613
+ }
614
+
522
615
  // Install from clawhub (community)
523
616
  const result = await clawhubInstall(spec.slug, { version: spec.version });
524
617
  if (!result.success) {
@@ -660,8 +753,60 @@ export async function searchSkills(
660
753
  { success: true; data: unknown } | { success: false; error: string }
661
754
  > {
662
755
  try {
663
- const result = await clawhubSearch(query);
664
- return { success: true, data: result };
756
+ // Search the loaded skill catalog (bundled + installed) for matches
757
+ const catalog = loadSkillCatalog();
758
+ const catalogMatches = filterByQuery(catalog, query, [
759
+ (s) => s.id,
760
+ (s) => s.displayName,
761
+ (s) => s.description,
762
+ ]);
763
+
764
+ // Shape that matches ClawhubSearchResultItem so the client
765
+ // (Swift ClawhubSkillItem) can decode results uniformly.
766
+ interface SearchItem {
767
+ name: string;
768
+ slug: string;
769
+ description: string;
770
+ author: string;
771
+ stars: number;
772
+ installs: number;
773
+ version: string;
774
+ createdAt: number;
775
+ source: "vellum" | "clawhub";
776
+ }
777
+
778
+ const catalogItems: SearchItem[] = catalogMatches.map((s) => ({
779
+ name: s.displayName,
780
+ slug: s.id,
781
+ description: s.description,
782
+ author: "Vellum",
783
+ stars: 0,
784
+ installs: 0,
785
+ version: "",
786
+ createdAt: 0,
787
+ source: "vellum" as const,
788
+ }));
789
+
790
+ // Search the community registry (non-fatal on failure)
791
+ let communitySkills: SearchItem[] = [];
792
+ try {
793
+ const communityResult = await clawhubSearch(query);
794
+ communitySkills = communityResult.skills;
795
+ } catch (err) {
796
+ log.warn(
797
+ { err },
798
+ "clawhub search failed, returning catalog-only results",
799
+ );
800
+ }
801
+
802
+ // Deduplicate: catalog takes precedence when slugs collide
803
+ const catalogSlugs = new Set(catalogItems.map((s) => s.slug));
804
+ const deduped = communitySkills.filter((s) => !catalogSlugs.has(s.slug));
805
+
806
+ return {
807
+ success: true,
808
+ data: { skills: [...catalogItems, ...deduped] },
809
+ };
665
810
  } catch (err) {
666
811
  const message = err instanceof Error ? err.message : String(err);
667
812
  log.error({ err }, "Failed to search skills");
@@ -86,6 +86,14 @@ export class HostBashProxy {
86
86
  clearTimeout(timer);
87
87
  this.pending.delete(requestId);
88
88
  this.onInternalResolve?.(requestId);
89
+ try {
90
+ this.sendToClient({
91
+ type: "host_bash_cancel",
92
+ requestId,
93
+ } as ServerMessage);
94
+ } catch {
95
+ // Best-effort cancel notification — connection may already be closed.
96
+ }
89
97
  resolve(formatShellOutput("", "Aborted", null, false, 0));
90
98
  }
91
99
  };
@@ -144,6 +152,14 @@ export class HostBashProxy {
144
152
  for (const [requestId, entry] of this.pending) {
145
153
  clearTimeout(entry.timer);
146
154
  this.onInternalResolve?.(requestId);
155
+ try {
156
+ this.sendToClient({
157
+ type: "host_bash_cancel",
158
+ requestId,
159
+ } as ServerMessage);
160
+ } catch {
161
+ // Best-effort cancel notification — connection may already be closed.
162
+ }
147
163
  entry.reject(
148
164
  new AssistantError(
149
165
  "Host bash proxy disposed",
@@ -170,6 +170,14 @@ export class HostCuProxy {
170
170
  clearTimeout(timer);
171
171
  this.pending.delete(requestId);
172
172
  this.onInternalResolve?.(requestId);
173
+ try {
174
+ this.sendToClient({
175
+ type: "host_cu_cancel",
176
+ requestId,
177
+ } as ServerMessage);
178
+ } catch {
179
+ // Best-effort cancel notification — connection may already be closed.
180
+ }
173
181
  resolve({ content: "Aborted", isError: true });
174
182
  }
175
183
  };
@@ -381,6 +389,14 @@ export class HostCuProxy {
381
389
  for (const [requestId, entry] of this.pending) {
382
390
  clearTimeout(entry.timer);
383
391
  this.onInternalResolve?.(requestId);
392
+ try {
393
+ this.sendToClient({
394
+ type: "host_cu_cancel",
395
+ requestId,
396
+ } as ServerMessage);
397
+ } catch {
398
+ // Best-effort cancel notification — connection may already be closed.
399
+ }
384
400
  entry.reject(
385
401
  new AssistantError("Host CU proxy disposed", ErrorCode.INTERNAL_ERROR),
386
402
  );
@@ -82,6 +82,14 @@ export class HostFileProxy {
82
82
  clearTimeout(timer);
83
83
  this.pending.delete(requestId);
84
84
  this.onInternalResolve?.(requestId);
85
+ try {
86
+ this.sendToClient({
87
+ type: "host_file_cancel",
88
+ requestId,
89
+ } as ServerMessage);
90
+ } catch {
91
+ // Best-effort cancel notification — connection may already be closed.
92
+ }
85
93
  resolve({ content: "Aborted", isError: true });
86
94
  }
87
95
  };
@@ -123,6 +131,14 @@ export class HostFileProxy {
123
131
  for (const [requestId, entry] of this.pending) {
124
132
  clearTimeout(entry.timer);
125
133
  this.onInternalResolve?.(requestId);
134
+ try {
135
+ this.sendToClient({
136
+ type: "host_file_cancel",
137
+ requestId,
138
+ } as ServerMessage);
139
+ } catch {
140
+ // Best-effort cancel notification — connection may already be closed.
141
+ }
126
142
  entry.reject(
127
143
  new AssistantError(
128
144
  "Host file proxy disposed",