@vellumai/assistant 0.4.14 → 0.4.16

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 (140) hide show
  1. package/ARCHITECTURE.md +77 -38
  2. package/README.md +10 -12
  3. package/package.json +1 -1
  4. package/src/__tests__/actor-token-service.test.ts +108 -522
  5. package/src/__tests__/approval-routes-http.test.ts +383 -254
  6. package/src/__tests__/channel-approval-routes.test.ts +94 -241
  7. package/src/__tests__/channel-approval.test.ts +100 -0
  8. package/src/__tests__/conversation-routes-guardian-reply.test.ts +13 -6
  9. package/src/__tests__/conversation-routes.test.ts +11 -4
  10. package/src/__tests__/guardian-actions-endpoint.test.ts +26 -19
  11. package/src/__tests__/mcp-health-check.test.ts +65 -0
  12. package/src/__tests__/pairing-concurrent.test.ts +78 -0
  13. package/src/__tests__/permission-types.test.ts +33 -0
  14. package/src/__tests__/scan-result-store.test.ts +121 -0
  15. package/src/__tests__/session-agent-loop.test.ts +120 -0
  16. package/src/__tests__/session-approval-overrides.test.ts +205 -0
  17. package/src/__tests__/session-surfaces-task-progress.test.ts +38 -0
  18. package/src/amazon/client.ts +8 -5
  19. package/src/approvals/guardian-decision-primitive.ts +14 -9
  20. package/src/approvals/guardian-request-resolvers.ts +2 -2
  21. package/src/calls/call-controller.ts +2 -2
  22. package/src/calls/guardian-action-sweep.ts +6 -6
  23. package/src/calls/twilio-routes.ts +3 -5
  24. package/src/cli/mcp.ts +3 -3
  25. package/src/cli.ts +24 -0
  26. package/src/config/bundled-skills/chatgpt-import/tools/chatgpt-import.ts +19 -130
  27. package/src/config/bundled-skills/doordash/__tests__/doordash-client.test.ts +8 -6
  28. package/src/config/bundled-skills/google-calendar/SKILL.md +1 -1
  29. package/src/config/bundled-skills/messaging/SKILL.md +49 -14
  30. package/src/config/bundled-skills/messaging/TOOLS.json +52 -9
  31. package/src/config/bundled-skills/messaging/tools/gmail-batch-archive.ts +35 -11
  32. package/src/config/bundled-skills/messaging/tools/gmail-draft.ts +3 -1
  33. package/src/config/bundled-skills/messaging/tools/gmail-forward.ts +5 -6
  34. package/src/config/bundled-skills/messaging/tools/gmail-outreach-scan.ts +10 -2
  35. package/src/config/bundled-skills/messaging/tools/gmail-send-draft.ts +20 -0
  36. package/src/config/bundled-skills/messaging/tools/gmail-send-with-attachments.ts +3 -4
  37. package/src/config/bundled-skills/messaging/tools/gmail-sender-digest.ts +16 -8
  38. package/src/config/bundled-skills/messaging/tools/messaging-reply.ts +137 -0
  39. package/src/config/bundled-skills/messaging/tools/messaging-send.ts +10 -0
  40. package/src/config/bundled-skills/messaging/tools/messaging-sender-digest.ts +11 -3
  41. package/src/config/bundled-skills/messaging/tools/scan-result-store.ts +86 -0
  42. package/src/config/bundled-skills/phone-calls/SKILL.md +2 -2
  43. package/src/config/bundled-skills/skills-catalog/SKILL.md +31 -8
  44. package/src/config/bundled-skills/slack/tools/slack-channel-details.ts +1 -1
  45. package/src/config/bundled-skills/slack/tools/slack-scan-digest.ts +68 -24
  46. package/src/config/bundled-skills/sms-setup/SKILL.md +1 -1
  47. package/src/config/bundled-skills/telegram-setup/SKILL.md +1 -1
  48. package/src/config/bundled-skills/twilio-setup/SKILL.md +1 -1
  49. package/src/daemon/approval-generators.ts +6 -3
  50. package/src/daemon/handlers/config-inbox.ts +5 -5
  51. package/src/daemon/handlers/config-ingress.ts +2 -6
  52. package/src/daemon/handlers/guardian-actions.ts +1 -1
  53. package/src/daemon/handlers/sessions.ts +4 -1
  54. package/src/daemon/handlers/shared.ts +3 -0
  55. package/src/daemon/handlers/skills.ts +32 -0
  56. package/src/daemon/ipc-contract/messages.ts +3 -1
  57. package/src/daemon/ipc-handler.ts +24 -0
  58. package/src/daemon/ipc-validate.ts +1 -1
  59. package/src/daemon/lifecycle.ts +6 -8
  60. package/src/daemon/pairing-store.ts +15 -2
  61. package/src/daemon/server.ts +8 -3
  62. package/src/daemon/session-agent-loop.ts +19 -1
  63. package/src/daemon/session-attachments.ts +2 -1
  64. package/src/daemon/session-history.ts +2 -2
  65. package/src/daemon/session-process.ts +5 -9
  66. package/src/daemon/session-slash.ts +4 -4
  67. package/src/daemon/session-surfaces.ts +17 -1
  68. package/src/daemon/session-tool-setup.ts +216 -69
  69. package/src/daemon/session.ts +24 -1
  70. package/src/events/domain-events.ts +1 -1
  71. package/src/events/tool-domain-event-publisher.ts +5 -10
  72. package/src/influencer/client.ts +8 -7
  73. package/src/messaging/providers/gmail/client.ts +33 -1
  74. package/src/messaging/providers/gmail/mime-builder.ts +5 -1
  75. package/src/messaging/providers/sms/adapter.ts +3 -7
  76. package/src/messaging/providers/telegram-bot/adapter.ts +3 -7
  77. package/src/messaging/providers/whatsapp/adapter.ts +3 -7
  78. package/src/notifications/adapters/sms.ts +2 -2
  79. package/src/notifications/adapters/telegram.ts +2 -2
  80. package/src/permissions/prompter.ts +2 -0
  81. package/src/permissions/types.ts +11 -1
  82. package/src/runtime/approval-conversation-turn.ts +4 -0
  83. package/src/runtime/auth/__tests__/context.test.ts +130 -0
  84. package/src/runtime/auth/__tests__/credential-service.test.ts +277 -0
  85. package/src/runtime/auth/__tests__/guard-tests.test.ts +289 -0
  86. package/src/runtime/auth/__tests__/ipc-auth-context.test.ts +71 -0
  87. package/src/runtime/auth/__tests__/middleware.test.ts +239 -0
  88. package/src/runtime/auth/__tests__/policy.test.ts +29 -0
  89. package/src/runtime/auth/__tests__/route-policy.test.ts +166 -0
  90. package/src/runtime/auth/__tests__/scopes.test.ts +109 -0
  91. package/src/runtime/auth/__tests__/subject.test.ts +149 -0
  92. package/src/runtime/auth/__tests__/token-service.test.ts +263 -0
  93. package/src/runtime/auth/context.ts +62 -0
  94. package/src/runtime/{actor-refresh-token-service.ts → auth/credential-service.ts} +112 -79
  95. package/src/runtime/auth/external-assistant-id.ts +69 -0
  96. package/src/runtime/auth/index.ts +37 -0
  97. package/src/runtime/auth/middleware.ts +127 -0
  98. package/src/runtime/auth/policy.ts +17 -0
  99. package/src/runtime/auth/route-policy.ts +261 -0
  100. package/src/runtime/auth/scopes.ts +64 -0
  101. package/src/runtime/auth/subject.ts +68 -0
  102. package/src/runtime/auth/token-service.ts +298 -0
  103. package/src/runtime/auth/types.ts +79 -0
  104. package/src/runtime/channel-approval-parser.ts +11 -5
  105. package/src/runtime/channel-approval-types.ts +1 -1
  106. package/src/runtime/channel-approvals.ts +22 -1
  107. package/src/runtime/channel-retry-sweep.ts +2 -2
  108. package/src/runtime/guardian-action-followup-executor.ts +2 -2
  109. package/src/runtime/guardian-context-resolver.ts +15 -0
  110. package/src/runtime/guardian-decision-types.ts +23 -6
  111. package/src/runtime/guardian-outbound-actions.ts +4 -22
  112. package/src/runtime/guardian-reply-router.ts +5 -3
  113. package/src/runtime/http-server.ts +215 -186
  114. package/src/runtime/http-types.ts +12 -2
  115. package/src/runtime/local-actor-identity.ts +25 -0
  116. package/src/runtime/pending-interactions.ts +1 -0
  117. package/src/runtime/routes/approval-routes.ts +42 -59
  118. package/src/runtime/routes/channel-route-shared.ts +9 -41
  119. package/src/runtime/routes/channel-routes.ts +0 -2
  120. package/src/runtime/routes/conversation-routes.ts +40 -50
  121. package/src/runtime/routes/events-routes.ts +15 -22
  122. package/src/runtime/routes/guardian-action-routes.ts +46 -51
  123. package/src/runtime/routes/guardian-approval-interception.ts +6 -5
  124. package/src/runtime/routes/guardian-bootstrap-routes.ts +15 -10
  125. package/src/runtime/routes/guardian-expiry-sweep.ts +5 -5
  126. package/src/runtime/routes/guardian-refresh-routes.ts +2 -2
  127. package/src/runtime/routes/inbound-message-handler.ts +39 -45
  128. package/src/runtime/routes/pairing-routes.ts +13 -10
  129. package/src/runtime/routes/secret-routes.ts +90 -45
  130. package/src/runtime/routes/surface-action-routes.ts +12 -2
  131. package/src/runtime/routes/trust-rules-routes.ts +13 -0
  132. package/src/runtime/routes/twilio-routes.ts +3 -3
  133. package/src/runtime/session-approval-overrides.ts +86 -0
  134. package/src/security/keychain-to-encrypted-migration.ts +8 -1
  135. package/src/sequence/reply-matcher.ts +8 -2
  136. package/src/skills/frontmatter.ts +44 -1
  137. package/src/tools/permission-checker.ts +226 -74
  138. package/src/tools/ui-surface/definitions.ts +2 -1
  139. package/src/runtime/actor-token-service.ts +0 -234
  140. package/src/runtime/middleware/actor-token.ts +0 -265
@@ -0,0 +1,20 @@
1
+ import { sendDraft } from '../../../../messaging/providers/gmail/client.js';
2
+ import { getMessagingProvider } from '../../../../messaging/registry.js';
3
+ import { withValidToken } from '../../../../security/token-manager.js';
4
+ import type { ToolContext, ToolExecutionResult } from '../../../../tools/types.js';
5
+ import { err, ok } from './shared.js';
6
+
7
+ export async function run(input: Record<string, unknown>, _context: ToolContext): Promise<ToolExecutionResult> {
8
+ const draftId = input.draft_id as string;
9
+ if (!draftId) return err('draft_id is required.');
10
+
11
+ try {
12
+ const provider = getMessagingProvider('gmail');
13
+ return withValidToken(provider.credentialService, async (token) => {
14
+ const msg = await sendDraft(token, draftId);
15
+ return ok(`Draft sent (Message ID: ${msg.id}).`);
16
+ });
17
+ } catch (e) {
18
+ return err(e instanceof Error ? e.message : String(e));
19
+ }
20
+ }
@@ -1,7 +1,7 @@
1
1
  import { readFile } from 'node:fs/promises';
2
2
  import { basename, extname } from 'node:path';
3
3
 
4
- import { sendMessageRaw } from '../../../../messaging/providers/gmail/client.js';
4
+ import { createDraftRaw } from '../../../../messaging/providers/gmail/client.js';
5
5
  import { buildMultipartMime } from '../../../../messaging/providers/gmail/mime-builder.js';
6
6
  import { getMessagingProvider } from '../../../../messaging/registry.js';
7
7
  import { withValidToken } from '../../../../security/token-manager.js';
@@ -67,11 +67,10 @@ export async function run(input: Record<string, unknown>, _context: ToolContext)
67
67
  );
68
68
 
69
69
  const raw = buildMultipartMime({ to, subject, body, inReplyTo, attachments });
70
- const result = await sendMessageRaw(token, raw, threadId);
70
+ const draft = await createDraftRaw(token, raw, threadId);
71
71
 
72
72
  const filenames = attachments.map((a) => a.filename).join(', ');
73
- const threadSuffix = result.threadId ? `, "thread_id": "${result.threadId}"` : '';
74
- return ok(`Message sent with ${attachments.length} attachment(s): ${filenames} (ID: ${result.id}${threadSuffix}).`);
73
+ return ok(`Gmail draft created with ${attachments.length} attachment(s): ${filenames} (Draft ID: ${draft.id}). Review in Gmail Drafts, then tell me to send it or send it yourself.`);
75
74
  });
76
75
  } catch (e) {
77
76
  return err(e instanceof Error ? e.message : String(e));
@@ -2,10 +2,11 @@ import { batchGetMessages,listMessages } from '../../../../messaging/providers/g
2
2
  import { getMessagingProvider } from '../../../../messaging/registry.js';
3
3
  import { withValidToken } from '../../../../security/token-manager.js';
4
4
  import type { ToolContext, ToolExecutionResult } from '../../../../tools/types.js';
5
+ import { storeScanResult } from './scan-result-store.js';
5
6
  import { err,ok } from './shared.js';
6
7
 
7
- const MAX_MESSAGES_CAP = 500;
8
- const MAX_IDS_PER_SENDER = 500;
8
+ const MAX_MESSAGES_CAP = 5000;
9
+ const MAX_IDS_PER_SENDER = 5000;
9
10
  const MAX_SAMPLE_SUBJECTS = 3;
10
11
 
11
12
  interface SenderAggregation {
@@ -36,7 +37,7 @@ function parseFrom(from: string): { displayName: string; email: string } {
36
37
 
37
38
  export async function run(input: Record<string, unknown>, _context: ToolContext): Promise<ToolExecutionResult> {
38
39
  const query = (input.query as string) ?? 'category:promotions newer_than:90d';
39
- const maxMessages = Math.min((input.max_messages as number) ?? 500, MAX_MESSAGES_CAP);
40
+ const maxMessages = Math.min((input.max_messages as number) ?? 2000, MAX_MESSAGES_CAP);
40
41
  const maxSenders = (input.max_senders as number) ?? 30;
41
42
  const inputPageToken = input.page_token as string | undefined;
42
43
 
@@ -149,7 +150,7 @@ export async function run(input: Record<string, unknown>, _context: ToolContext)
149
150
  .sort((a, b) => b.messageCount - a.messageCount)
150
151
  .slice(0, maxSenders);
151
152
 
152
- const result = sorted.map((s) => ({
153
+ const resultSenders = sorted.map((s) => ({
153
154
  id: Buffer.from(s.email).toString('base64url'),
154
155
  display_name: s.displayName || s.email.split('@')[0],
155
156
  email: s.email,
@@ -161,19 +162,26 @@ export async function run(input: Record<string, unknown>, _context: ToolContext)
161
162
  : s.newestMessageId,
162
163
  oldest_date: s.oldestDate,
163
164
  newest_date: s.newestDate,
164
- message_ids: s.messageIds,
165
- has_more: s.hasMore,
166
165
  // Preserve original query filters so follow-up searches stay scoped
167
166
  search_query: `from:${s.email} ${query}`,
168
167
  sample_subjects: s.sampleSubjects,
169
168
  }));
170
169
 
170
+ // Store message IDs server-side to keep them out of LLM context
171
+ const scanId = storeScanResult(sorted.map((s) => ({
172
+ id: Buffer.from(s.email).toString('base64url'),
173
+ messageIds: s.messageIds,
174
+ newestMessageId: s.newestMessageId,
175
+ newestUnsubscribableMessageId: s.newestUnsubscribableMessageId,
176
+ })));
177
+
171
178
  return ok(JSON.stringify({
172
- senders: result,
179
+ scan_id: scanId,
180
+ senders: resultSenders,
173
181
  total_scanned: allMessageIds.length,
174
182
  query_used: query,
175
183
  ...(truncated ? { truncated: true, next_page_token: pageToken } : {}),
176
- note: `message_count reflects emails found per sender within the ${allMessageIds.length} messages scanned. Use the message_ids array with gmail_batch_archive to archive exactly these messages.`,
184
+ note: `message_count reflects emails found per sender within the ${allMessageIds.length} messages scanned. Use scan_id with gmail_batch_archive to archive messages (pass scan_id + sender_ids instead of message_ids).`,
177
185
  }));
178
186
  });
179
187
  } catch (e) {
@@ -1,6 +1,72 @@
1
+ import { batchGetMessages, createDraft, getProfile, listMessages } from '../../../../messaging/providers/gmail/client.js';
1
2
  import type { ToolContext, ToolExecutionResult } from '../../../../tools/types.js';
2
3
  import { err,ok, resolveProvider, withProviderToken } from './shared.js';
3
4
 
5
+ function extractHeader(headers: Array<{ name: string; value: string }> | undefined, name: string): string {
6
+ return headers?.find((h) => h.name.toLowerCase() === name.toLowerCase())?.value ?? '';
7
+ }
8
+
9
+ /**
10
+ * RFC 5322-aware address list parser. Splits a header value like
11
+ * `"Doe, Jane" <jane@example.com>, bob@example.com` into individual
12
+ * addresses without breaking on commas inside quoted display names.
13
+ */
14
+ function parseAddressList(header: string): string[] {
15
+ const addresses: string[] = [];
16
+ let current = '';
17
+ let inQuotes = false;
18
+ let inAngle = false;
19
+
20
+ for (let i = 0; i < header.length; i++) {
21
+ const ch = header[i];
22
+
23
+ if (ch === '"' && !inAngle) {
24
+ inQuotes = !inQuotes;
25
+ current += ch;
26
+ } else if (ch === '<' && !inQuotes) {
27
+ inAngle = true;
28
+ current += ch;
29
+ } else if (ch === '>' && !inQuotes) {
30
+ inAngle = false;
31
+ current += ch;
32
+ } else if (ch === ',' && !inQuotes && !inAngle) {
33
+ const trimmed = current.trim();
34
+ if (trimmed) addresses.push(trimmed);
35
+ current = '';
36
+ } else {
37
+ current += ch;
38
+ }
39
+ }
40
+
41
+ const trimmed = current.trim();
42
+ if (trimmed) addresses.push(trimmed);
43
+
44
+ return addresses;
45
+ }
46
+
47
+ /**
48
+ * Extracts the bare email from an address that may be in any of these forms:
49
+ * - `user@example.com`
50
+ * - `<user@example.com>`
51
+ * - `"Display Name" <user@example.com>`
52
+ * - `Display Name <user@example.com>`
53
+ * - `"Team <Ops>" <user@example.com>`
54
+ * - `user@example.com (team <ops>)`
55
+ *
56
+ * Extracts all angle-bracketed segments and picks the last one containing `@`,
57
+ * falling back to the last segment, then to the raw string. This handles both
58
+ * display names with angle brackets and trailing RFC 5322 comments that may
59
+ * contain angle brackets.
60
+ */
61
+ function extractEmail(address: string): string {
62
+ const segments = [...address.matchAll(/<([^>]+)>/g)].map((m) => m[1]);
63
+ if (segments.length > 0) {
64
+ const emailSegment = segments.findLast((s) => s.includes('@'));
65
+ return (emailSegment ?? segments[segments.length - 1]).trim().toLowerCase();
66
+ }
67
+ return address.trim().toLowerCase();
68
+ }
69
+
4
70
  export async function run(input: Record<string, unknown>, context: ToolContext): Promise<ToolExecutionResult> {
5
71
  const platform = input.platform as string | undefined;
6
72
  const conversationId = input.conversation_id as string;
@@ -19,6 +85,77 @@ export async function run(input: Record<string, unknown>, context: ToolContext):
19
85
 
20
86
  try {
21
87
  const provider = resolveProvider(platform);
88
+
89
+ // Gmail: create a threaded draft with reply-all recipients
90
+ if (provider.id === 'gmail') {
91
+ return withProviderToken(provider, async (token) => {
92
+ // Fetch thread messages to extract recipients and threading headers
93
+ const list = await listMessages(token, `thread:${threadId}`, 10);
94
+ if (!list.messages?.length) {
95
+ return err('No messages found in this thread.');
96
+ }
97
+
98
+ const messages = await batchGetMessages(token, list.messages.map((m) => m.id), 'metadata', [
99
+ 'From', 'To', 'Cc', 'Message-ID', 'Subject',
100
+ ]);
101
+
102
+ // Use the latest message for threading and recipient extraction
103
+ const latest = messages[messages.length - 1];
104
+ const latestHeaders = latest.payload?.headers ?? [];
105
+
106
+ const messageIdHeader = extractHeader(latestHeaders, 'Message-ID');
107
+ let subject = extractHeader(latestHeaders, 'Subject');
108
+ if (subject && !subject.startsWith('Re:')) {
109
+ subject = `Re: ${subject}`;
110
+ }
111
+
112
+ // Build reply-all recipient list, excluding the user's own email
113
+ const profile = await getProfile(token);
114
+ const userEmail = profile.emailAddress.toLowerCase();
115
+
116
+ const allRecipients = new Set<string>();
117
+ const allCc = new Set<string>();
118
+
119
+ // From the latest message: From goes to To, original To/Cc go to Cc
120
+ const fromAddr = extractHeader(latestHeaders, 'From');
121
+ const toAddrs = extractHeader(latestHeaders, 'To');
122
+ const ccAddrs = extractHeader(latestHeaders, 'Cc');
123
+
124
+ if (fromAddr) allRecipients.add(fromAddr);
125
+ for (const addr of parseAddressList(toAddrs)) {
126
+ allRecipients.add(addr);
127
+ }
128
+ for (const addr of parseAddressList(ccAddrs)) {
129
+ allCc.add(addr);
130
+ }
131
+
132
+ // Remove user's own email from recipients using exact email comparison
133
+ const filterSelf = (addr: string) => extractEmail(addr) !== userEmail;
134
+ const toList = [...allRecipients].filter(filterSelf);
135
+ const ccList = [...allCc].filter(filterSelf);
136
+
137
+ if (toList.length === 0) {
138
+ return err('Could not determine reply recipients from thread.');
139
+ }
140
+
141
+ const draft = await createDraft(
142
+ token,
143
+ toList.join(', '),
144
+ subject,
145
+ text,
146
+ messageIdHeader || undefined,
147
+ ccList.length > 0 ? ccList.join(', ') : undefined,
148
+ undefined,
149
+ threadId,
150
+ );
151
+
152
+ const recipientSummary = ccList.length > 0
153
+ ? `To: ${toList.join(', ')}; Cc: ${ccList.join(', ')}`
154
+ : `To: ${toList.join(', ')}`;
155
+ return ok(`Gmail draft created (ID: ${draft.id}). ${recipientSummary}. Review in Gmail Drafts, then tell me to send it or send it yourself.`);
156
+ });
157
+ }
158
+
22
159
  return withProviderToken(provider, async (token) => {
23
160
  const result = await provider.sendMessage(token, conversationId, text, {
24
161
  threadId,
@@ -1,3 +1,4 @@
1
+ import { createDraft } from '../../../../messaging/providers/gmail/client.js';
1
2
  import type { ToolContext, ToolExecutionResult } from '../../../../tools/types.js';
2
3
  import { err,ok, resolveProvider, withProviderToken } from './shared.js';
3
4
 
@@ -17,6 +18,15 @@ export async function run(input: Record<string, unknown>, context: ToolContext):
17
18
 
18
19
  try {
19
20
  const provider = resolveProvider(platform);
21
+
22
+ // Gmail: create a draft instead of sending directly
23
+ if (provider.id === 'gmail') {
24
+ return withProviderToken(provider, async (token) => {
25
+ const draft = await createDraft(token, conversationId, subject ?? '', text, inReplyTo);
26
+ return ok(`Gmail draft created (ID: ${draft.id}). Review it in your Gmail Drafts, then tell me to send it or send it yourself from Gmail.`);
27
+ });
28
+ }
29
+
20
30
  return withProviderToken(provider, async (token) => {
21
31
  const result = await provider.sendMessage(token, conversationId, text, {
22
32
  subject,
@@ -1,4 +1,5 @@
1
1
  import type { ToolContext, ToolExecutionResult } from '../../../../tools/types.js';
2
+ import { storeScanResult } from './scan-result-store.js';
2
3
  import { err, ok, resolveProvider, withProviderToken } from './shared.js';
3
4
 
4
5
  export async function run(input: Record<string, unknown>, _context: ToolContext): Promise<ToolExecutionResult> {
@@ -37,16 +38,23 @@ export async function run(input: Record<string, unknown>, _context: ToolContext)
37
38
  has_unsubscribe: s.hasUnsubscribe,
38
39
  newest_message_id: s.newestMessageId,
39
40
  search_query: s.searchQuery,
40
- message_ids: s.messageIds,
41
- has_more: s.hasMore,
42
41
  }));
43
42
 
43
+ // Store message IDs server-side to keep them out of LLM context
44
+ const scanId = storeScanResult(result.senders.map((s) => ({
45
+ id: s.id,
46
+ messageIds: s.messageIds,
47
+ newestMessageId: s.newestMessageId,
48
+ newestUnsubscribableMessageId: null,
49
+ })));
50
+
44
51
  return ok(JSON.stringify({
52
+ scan_id: scanId,
45
53
  senders,
46
54
  total_scanned: result.totalScanned,
47
55
  query_used: result.queryUsed,
48
56
  ...(result.truncated ? { truncated: true, next_page_token: result.nextPageToken } : {}),
49
- note: `message_count reflects emails found per sender within the ${result.totalScanned} messages scanned. Use the message_ids array with the archive tool to archive exactly these messages.`,
57
+ note: `message_count reflects emails found per sender within the ${result.totalScanned} messages scanned. Use scan_id with the archive tool to archive messages (pass scan_id + sender_ids instead of message_ids).`,
50
58
  }));
51
59
  });
52
60
  } catch (e) {
@@ -0,0 +1,86 @@
1
+ import { randomBytes } from 'crypto';
2
+
3
+ interface SenderData {
4
+ messageIds: string[];
5
+ newestMessageId: string;
6
+ newestUnsubscribableMessageId: string | null;
7
+ }
8
+
9
+ interface ScanEntry {
10
+ senders: Map<string, SenderData>;
11
+ createdAt: number;
12
+ }
13
+
14
+ const MAX_ENTRIES = 16;
15
+ const TTL_MS = 30 * 60_000; // 30 minutes
16
+
17
+ const _store = new Map<string, ScanEntry>();
18
+
19
+ /** Store scan results and return a unique scan ID. */
20
+ export function storeScanResult(
21
+ senders: Array<{ id: string; messageIds: string[]; newestMessageId: string; newestUnsubscribableMessageId: string | null }>,
22
+ ): string {
23
+ const scanId = randomBytes(8).toString('hex');
24
+
25
+ // LRU eviction: remove oldest if at capacity
26
+ if (_store.size >= MAX_ENTRIES) {
27
+ const oldest = _store.keys().next().value;
28
+ if (oldest !== undefined) _store.delete(oldest);
29
+ }
30
+
31
+ const senderMap = new Map<string, SenderData>();
32
+ for (const s of senders) {
33
+ senderMap.set(s.id, {
34
+ messageIds: s.messageIds,
35
+ newestMessageId: s.newestMessageId,
36
+ newestUnsubscribableMessageId: s.newestUnsubscribableMessageId,
37
+ });
38
+ }
39
+
40
+ _store.set(scanId, { senders: senderMap, createdAt: Date.now() });
41
+ return scanId;
42
+ }
43
+
44
+ /** Retrieve message IDs for the given senders from a scan result. */
45
+ export function getSenderMessageIds(scanId: string, senderIds: string[]): string[] | null {
46
+ const entry = _store.get(scanId);
47
+ if (!entry) return null;
48
+ if (Date.now() - entry.createdAt > TTL_MS) {
49
+ _store.delete(scanId);
50
+ return null;
51
+ }
52
+ // LRU: move to end
53
+ _store.delete(scanId);
54
+ _store.set(scanId, entry);
55
+
56
+ const ids: string[] = [];
57
+ for (const sid of senderIds) {
58
+ const data = entry.senders.get(sid);
59
+ if (data) ids.push(...data.messageIds);
60
+ }
61
+ return ids;
62
+ }
63
+
64
+ /** Retrieve metadata for a single sender from a scan result. */
65
+ export function getSenderMetadata(
66
+ scanId: string,
67
+ senderId: string,
68
+ ): { newestMessageId: string; newestUnsubscribableMessageId: string | null } | null {
69
+ const entry = _store.get(scanId);
70
+ if (!entry) return null;
71
+ if (Date.now() - entry.createdAt > TTL_MS) {
72
+ _store.delete(scanId);
73
+ return null;
74
+ }
75
+ const data = entry.senders.get(senderId);
76
+ if (!data) return null;
77
+ return { newestMessageId: data.newestMessageId, newestUnsubscribableMessageId: data.newestUnsubscribableMessageId };
78
+ }
79
+
80
+ /** Clear the store (for tests). */
81
+ export function clearScanStore(): void {
82
+ _store.clear();
83
+ }
84
+
85
+ /** Visible for testing: override TTL check by returning internal store reference. */
86
+ export const _internals = { store: _store, TTL_MS };
@@ -62,7 +62,7 @@ If `hasCredentials` is `true`, `phoneNumber` is set, and `calls.enabled` is `tru
62
62
 
63
63
  If Twilio is not yet configured, load the **twilio-setup** skill — it handles credential storage, phone number provisioning, and public ingress setup:
64
64
 
65
- - Call `skill_load` with `skill_id: "twilio-setup"` to load the dependency skill.
65
+ - Call `skill_load` with `skill: "twilio-setup"` to load the dependency skill.
66
66
 
67
67
  Once twilio-setup completes, return here to enable calls.
68
68
 
@@ -293,7 +293,7 @@ No additional configuration is needed beyond Twilio setup and `calls.enabled` be
293
293
 
294
294
  ### Guardian voice verification for inbound calls
295
295
 
296
- For guardian verification setup, load the skill by calling `skill_load` with `skill_id: "guardian-verify-setup"`. This skill handles the full outbound verification flow; `phone-calls` does not orchestrate it inline. Do not use `call_start` to place guardian verification calls — the guardian outbound verification endpoints already place those calls.
296
+ For guardian verification setup, load the skill by calling `skill_load` with `skill: "guardian-verify-setup"`. This skill handles the full outbound verification flow; `phone-calls` does not orchestrate it inline. Do not use `call_start` to place guardian verification calls — the guardian outbound verification endpoints already place those calls.
297
297
 
298
298
  Once a guardian binding exists for the voice channel, inbound callers may be prompted for verification before calls proceed. The relay server detects pending challenges and prompts callers: "Please enter your six-digit verification code using your keypad, or speak the digits now." If verification fails after 3 attempts, the call ends with "Verification failed. Goodbye."
299
299
 
@@ -19,19 +19,39 @@ The skill catalog shown in the system prompt lists all bundled skills with their
19
19
 
20
20
  ## Community skills (Clawhub)
21
21
 
22
- Community skills are published on [Clawhub](https://clawhub.com) and can be searched and installed on demand.
22
+ Community skills are published on Clawhub and can be searched, inspected, and installed on demand using the `clawhub` CLI via bash.
23
23
 
24
24
  ### Searching for community skills
25
25
 
26
- Use the `skill_load` tool to search the catalog, or check the system prompt's available skills list. The IPC `skills_search` message searches community skills on Clawhub. Bundled skills are already listed in the system prompt.
26
+ ```bash
27
+ npx clawhub search "<query>" --limit 10
28
+ ```
27
29
 
28
- ### Installing a community skill
30
+ Returns matching skills with their slug, version, and name. Use this when the user asks for a capability not covered by bundled skills.
31
+
32
+ To browse trending/popular skills without a specific query:
29
33
 
30
- Community skills are installed via the IPC `skills_install` message with a `slug` parameter. Once installed, they appear in `~/.vellum/workspace/skills/<slug>/` and can be loaded with `skill_load` like any other skill.
34
+ ```bash
35
+ npx clawhub explore --json --limit 10
36
+ ```
31
37
 
32
38
  ### Inspecting a community skill
33
39
 
34
- Before installing, you can inspect a community skill via the IPC `skills_inspect` message with a `slug` parameter. This returns metadata (author, stats, version) and optionally the skill's SKILL.md content so the user can review it.
40
+ Before installing, inspect a skill to review its metadata, author, stats, and SKILL.md content:
41
+
42
+ ```bash
43
+ npx clawhub inspect <slug> --json --files --file SKILL.md
44
+ ```
45
+
46
+ Present the results to the user so they can decide whether to install.
47
+
48
+ ### Installing a community skill
49
+
50
+ ```bash
51
+ npx clawhub install <slug> --force --workdir ~/.vellum/workspace
52
+ ```
53
+
54
+ Once installed, the skill appears in `~/.vellum/workspace/skills/<slug>/` and can be loaded with `skill_load` like any other skill.
35
55
 
36
56
  ## Typical flow
37
57
 
@@ -41,9 +61,11 @@ Before installing, you can inspect a community skill via the IPC `skills_inspect
41
61
  - Load any that match with `skill_load`
42
62
 
43
63
  2. **User wants a capability not covered by bundled skills** — "Can you do X?"
44
- - Search for community skills that provide the capability
64
+ - Search with `npx clawhub search "<query>"`
65
+ - Optionally inspect promising results with `npx clawhub inspect <slug> --json`
45
66
  - Present matching results with descriptions and install counts
46
- - Install the chosen skill, then load it with `skill_load`
67
+ - Install the chosen skill with `npx clawhub install <slug> --force --workdir ~/.vellum/workspace`
68
+ - Load it with `skill_load`
47
69
 
48
70
  3. **Skill has dependencies** — if `includes` lists other skill IDs, load those first with `skill_load`
49
71
 
@@ -51,5 +73,6 @@ Before installing, you can inspect a community skill via the IPC `skills_inspect
51
73
 
52
74
  - Bundled skills are always available and do not need installation
53
75
  - Community skills are installed to `~/.vellum/workspace/skills/<slug>/`
54
- - After installing a community skill, it may need to be enabled in settings
76
+ - After installing a community skill, it is auto-enabled and immediately loadable
55
77
  - Skills can be enabled or disabled via feature flags without uninstalling them
78
+ - Run `npx clawhub --help` to discover additional CLI options
@@ -10,7 +10,7 @@ export async function run(input: Record<string, unknown>, _context: ToolContext)
10
10
  }
11
11
 
12
12
  try {
13
- return withSlackToken(async (token) => {
13
+ return await withSlackToken(async (token) => {
14
14
  const resp = await slack.conversationInfo(token, channelId);
15
15
  const conv = resp.channel;
16
16
 
@@ -1,8 +1,11 @@
1
- import * as slack from '../../../../messaging/providers/slack/client.js';
2
- import type { SlackConversation } from '../../../../messaging/providers/slack/types.js';
3
- import { getConfig } from '../../../../config/loader.js';
4
- import type { ToolContext, ToolExecutionResult } from '../../../../tools/types.js';
5
- import { err, ok, withSlackToken } from './shared.js';
1
+ import { getConfig } from "../../../../config/loader.js";
2
+ import * as slack from "../../../../messaging/providers/slack/client.js";
3
+ import type { SlackConversation } from "../../../../messaging/providers/slack/types.js";
4
+ import type {
5
+ ToolContext,
6
+ ToolExecutionResult,
7
+ } from "../../../../tools/types.js";
8
+ import { err, ok, withSlackToken } from "./shared.js";
6
9
 
7
10
  interface ThreadSummary {
8
11
  threadTs: string;
@@ -24,16 +27,17 @@ interface ChannelDigest {
24
27
  const userNameCache = new Map<string, string>();
25
28
 
26
29
  async function resolveUserName(token: string, userId: string): Promise<string> {
27
- if (!userId) return 'unknown';
30
+ if (!userId) return "unknown";
28
31
  const cached = userNameCache.get(userId);
29
32
  if (cached) return cached;
30
33
 
31
34
  try {
32
35
  const resp = await slack.userInfo(token, userId);
33
- const name = resp.user.profile?.display_name
34
- || resp.user.profile?.real_name
35
- || resp.user.real_name
36
- || resp.user.name;
36
+ const name =
37
+ resp.user.profile?.display_name ||
38
+ resp.user.profile?.real_name ||
39
+ resp.user.real_name ||
40
+ resp.user.name;
37
41
  userNameCache.set(userId, name);
38
42
  return name;
39
43
  } catch {
@@ -52,7 +56,13 @@ async function scanChannel(
52
56
  const isPrivate = conv.is_private ?? conv.is_group ?? false;
53
57
 
54
58
  try {
55
- const history = await slack.conversationHistory(token, channelId, 100, undefined, oldestTs);
59
+ const history = await slack.conversationHistory(
60
+ token,
61
+ channelId,
62
+ 100,
63
+ undefined,
64
+ oldestTs,
65
+ );
56
66
  const messages = history.messages;
57
67
 
58
68
  const participantIds = new Set<string>();
@@ -76,7 +86,12 @@ async function scanChannel(
76
86
 
77
87
  if (includeThreads) {
78
88
  try {
79
- const replies = await slack.conversationReplies(token, channelId, msg.ts, 10);
89
+ const replies = await slack.conversationReplies(
90
+ token,
91
+ channelId,
92
+ msg.ts,
93
+ 10,
94
+ );
80
95
  const threadParticipantIds = new Set<string>();
81
96
  for (const reply of replies.messages) {
82
97
  if (reply.user) threadParticipantIds.add(reply.user);
@@ -85,7 +100,7 @@ async function scanChannel(
85
100
  participants.push(await resolveUserName(token, uid));
86
101
  }
87
102
  } catch {
88
- participants = [await resolveUserName(token, msg.user ?? '')];
103
+ participants = [await resolveUserName(token, msg.user ?? "")];
89
104
  }
90
105
  }
91
106
 
@@ -120,10 +135,13 @@ async function scanChannel(
120
135
 
121
136
  function truncate(text: string, maxLen: number): string {
122
137
  if (text.length <= maxLen) return text;
123
- return text.slice(0, maxLen - 3) + '...';
138
+ return text.slice(0, maxLen - 3) + "...";
124
139
  }
125
140
 
126
- export async function run(input: Record<string, unknown>, _context: ToolContext): Promise<ToolExecutionResult> {
141
+ export async function run(
142
+ input: Record<string, unknown>,
143
+ _context: ToolContext,
144
+ ): Promise<ToolExecutionResult> {
127
145
  const channelIds = input.channel_ids as string[] | undefined;
128
146
  const hoursBack = (input.hours_back as number) ?? 24;
129
147
  const includeThreads = (input.include_threads as boolean) ?? true;
@@ -141,26 +159,45 @@ export async function run(input: Record<string, unknown>, _context: ToolContext)
141
159
  channelIds.map((id) => slack.conversationInfo(token, id)),
142
160
  );
143
161
  channelsToScan = results
144
- .filter((r): r is PromiseFulfilledResult<Awaited<ReturnType<typeof slack.conversationInfo>>> => r.status === 'fulfilled')
162
+ .filter(
163
+ (
164
+ r,
165
+ ): r is PromiseFulfilledResult<
166
+ Awaited<ReturnType<typeof slack.conversationInfo>>
167
+ > => r.status === "fulfilled",
168
+ )
145
169
  .map((r) => r.value.channel);
146
- failedLookups = results.filter((r) => r.status === 'rejected').length;
170
+ failedLookups = results.filter((r) => r.status === "rejected").length;
147
171
  } else {
148
172
  const config = getConfig();
149
- const preferredIds = config.skills?.entries?.slack?.config?.preferredChannels as string[] | undefined;
173
+ const preferredIds = config.skills?.entries?.slack?.config
174
+ ?.preferredChannels as string[] | undefined;
150
175
 
151
176
  if (preferredIds?.length) {
152
177
  const results = await Promise.allSettled(
153
178
  preferredIds.map((id) => slack.conversationInfo(token, id)),
154
179
  );
155
180
  channelsToScan = results
156
- .filter((r): r is PromiseFulfilledResult<Awaited<ReturnType<typeof slack.conversationInfo>>> => r.status === 'fulfilled')
181
+ .filter(
182
+ (
183
+ r,
184
+ ): r is PromiseFulfilledResult<
185
+ Awaited<ReturnType<typeof slack.conversationInfo>>
186
+ > => r.status === "fulfilled",
187
+ )
157
188
  .map((r) => r.value.channel);
158
- failedLookups = results.filter((r) => r.status === 'rejected').length;
189
+ failedLookups = results.filter((r) => r.status === "rejected").length;
159
190
  } else {
160
191
  const allChannels: SlackConversation[] = [];
161
192
  let cursor: string | undefined;
162
193
  do {
163
- const resp = await slack.listConversations(token, 'public_channel,private_channel', true, 200, cursor);
194
+ const resp = await slack.listConversations(
195
+ token,
196
+ "public_channel,private_channel",
197
+ true,
198
+ 200,
199
+ cursor,
200
+ );
164
201
  allChannels.push(...resp.channels);
165
202
  cursor = resp.response_metadata?.next_cursor || undefined;
166
203
  } while (cursor);
@@ -177,15 +214,22 @@ export async function run(input: Record<string, unknown>, _context: ToolContext)
177
214
  }
178
215
 
179
216
  const scanResults = await Promise.allSettled(
180
- channelsToScan.map((conv) => scanChannel(token, conv, oldestTs, includeThreads)),
217
+ channelsToScan.map((conv) =>
218
+ scanChannel(token, conv, oldestTs, includeThreads),
219
+ ),
181
220
  );
182
221
 
183
222
  const digests: ChannelDigest[] = scanResults
184
- .filter((r): r is PromiseFulfilledResult<ChannelDigest> => r.status === 'fulfilled')
223
+ .filter(
224
+ (r): r is PromiseFulfilledResult<ChannelDigest> =>
225
+ r.status === "fulfilled",
226
+ )
185
227
  .map((r) => r.value)
186
228
  .filter((d) => d.messageCount > 0 || d.error);
187
229
 
188
- const skippedCount = scanResults.filter((r) => r.status === 'rejected').length;
230
+ const skippedCount = scanResults.filter(
231
+ (r) => r.status === "rejected",
232
+ ).length;
189
233
 
190
234
  const result = {
191
235
  scannedChannels: digests.length,