@vellumai/assistant 0.4.13 → 0.4.15

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 (133) 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__/channel-approval-routes.test.ts +92 -239
  6. package/src/__tests__/channel-approval.test.ts +100 -0
  7. package/src/__tests__/conversation-routes-guardian-reply.test.ts +13 -6
  8. package/src/__tests__/conversation-routes.test.ts +11 -4
  9. package/src/__tests__/guardian-actions-endpoint.test.ts +26 -19
  10. package/src/__tests__/mcp-health-check.test.ts +65 -0
  11. package/src/__tests__/permission-types.test.ts +33 -0
  12. package/src/__tests__/scan-result-store.test.ts +121 -0
  13. package/src/__tests__/session-agent-loop.test.ts +120 -0
  14. package/src/__tests__/session-approval-overrides.test.ts +205 -0
  15. package/src/__tests__/session-surfaces-task-progress.test.ts +38 -0
  16. package/src/amazon/client.ts +8 -5
  17. package/src/approvals/guardian-decision-primitive.ts +14 -9
  18. package/src/approvals/guardian-request-resolvers.ts +2 -2
  19. package/src/calls/call-controller.ts +2 -2
  20. package/src/calls/twilio-routes.ts +2 -2
  21. package/src/cli/mcp.ts +3 -3
  22. package/src/cli.ts +24 -0
  23. package/src/config/bundled-skills/chatgpt-import/tools/chatgpt-import.ts +19 -130
  24. package/src/config/bundled-skills/doordash/__tests__/doordash-client.test.ts +8 -6
  25. package/src/config/bundled-skills/google-calendar/SKILL.md +1 -1
  26. package/src/config/bundled-skills/messaging/SKILL.md +49 -14
  27. package/src/config/bundled-skills/messaging/TOOLS.json +52 -9
  28. package/src/config/bundled-skills/messaging/tools/gmail-batch-archive.ts +35 -11
  29. package/src/config/bundled-skills/messaging/tools/gmail-draft.ts +3 -1
  30. package/src/config/bundled-skills/messaging/tools/gmail-forward.ts +5 -6
  31. package/src/config/bundled-skills/messaging/tools/gmail-outreach-scan.ts +10 -2
  32. package/src/config/bundled-skills/messaging/tools/gmail-send-draft.ts +20 -0
  33. package/src/config/bundled-skills/messaging/tools/gmail-send-with-attachments.ts +3 -4
  34. package/src/config/bundled-skills/messaging/tools/gmail-sender-digest.ts +16 -8
  35. package/src/config/bundled-skills/messaging/tools/messaging-reply.ts +76 -0
  36. package/src/config/bundled-skills/messaging/tools/messaging-send.ts +10 -0
  37. package/src/config/bundled-skills/messaging/tools/messaging-sender-digest.ts +11 -3
  38. package/src/config/bundled-skills/messaging/tools/scan-result-store.ts +86 -0
  39. package/src/config/bundled-skills/phone-calls/SKILL.md +2 -2
  40. package/src/config/bundled-skills/skills-catalog/SKILL.md +31 -8
  41. package/src/config/bundled-skills/slack/tools/slack-add-reaction.ts +1 -1
  42. package/src/config/bundled-skills/slack/tools/slack-channel-details.ts +1 -1
  43. package/src/config/bundled-skills/slack/tools/slack-delete-message.ts +1 -1
  44. package/src/config/bundled-skills/slack/tools/slack-leave-channel.ts +1 -1
  45. package/src/config/bundled-skills/slack/tools/slack-scan-digest.ts +79 -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-ingress.ts +2 -6
  51. package/src/daemon/handlers/guardian-actions.ts +1 -1
  52. package/src/daemon/handlers/sessions.ts +4 -1
  53. package/src/daemon/handlers/shared.ts +3 -0
  54. package/src/daemon/handlers/skills.ts +32 -0
  55. package/src/daemon/ipc-contract/messages.ts +3 -1
  56. package/src/daemon/ipc-handler.ts +24 -0
  57. package/src/daemon/ipc-validate.ts +1 -1
  58. package/src/daemon/lifecycle.ts +6 -8
  59. package/src/daemon/server.ts +8 -3
  60. package/src/daemon/session-agent-loop.ts +19 -1
  61. package/src/daemon/session-attachments.ts +2 -1
  62. package/src/daemon/session-history.ts +2 -2
  63. package/src/daemon/session-process.ts +5 -9
  64. package/src/daemon/session-surfaces.ts +17 -1
  65. package/src/daemon/session-tool-setup.ts +216 -69
  66. package/src/daemon/session.ts +24 -1
  67. package/src/events/domain-events.ts +1 -1
  68. package/src/events/tool-domain-event-publisher.ts +5 -10
  69. package/src/influencer/client.ts +8 -7
  70. package/src/messaging/providers/gmail/client.ts +33 -1
  71. package/src/messaging/providers/gmail/mime-builder.ts +5 -1
  72. package/src/messaging/providers/sms/adapter.ts +3 -7
  73. package/src/messaging/providers/telegram-bot/adapter.ts +3 -7
  74. package/src/messaging/providers/whatsapp/adapter.ts +3 -7
  75. package/src/notifications/adapters/sms.ts +2 -2
  76. package/src/notifications/adapters/telegram.ts +2 -2
  77. package/src/permissions/prompter.ts +2 -0
  78. package/src/permissions/types.ts +11 -1
  79. package/src/runtime/approval-conversation-turn.ts +4 -0
  80. package/src/runtime/auth/__tests__/context.test.ts +130 -0
  81. package/src/runtime/auth/__tests__/credential-service.test.ts +277 -0
  82. package/src/runtime/auth/__tests__/guard-tests.test.ts +289 -0
  83. package/src/runtime/auth/__tests__/ipc-auth-context.test.ts +71 -0
  84. package/src/runtime/auth/__tests__/middleware.test.ts +239 -0
  85. package/src/runtime/auth/__tests__/policy.test.ts +29 -0
  86. package/src/runtime/auth/__tests__/route-policy.test.ts +166 -0
  87. package/src/runtime/auth/__tests__/scopes.test.ts +109 -0
  88. package/src/runtime/auth/__tests__/subject.test.ts +149 -0
  89. package/src/runtime/auth/__tests__/token-service.test.ts +263 -0
  90. package/src/runtime/auth/context.ts +62 -0
  91. package/src/runtime/{actor-refresh-token-service.ts → auth/credential-service.ts} +112 -79
  92. package/src/runtime/auth/external-assistant-id.ts +69 -0
  93. package/src/runtime/auth/index.ts +37 -0
  94. package/src/runtime/auth/middleware.ts +127 -0
  95. package/src/runtime/auth/policy.ts +17 -0
  96. package/src/runtime/auth/route-policy.ts +261 -0
  97. package/src/runtime/auth/scopes.ts +64 -0
  98. package/src/runtime/auth/subject.ts +68 -0
  99. package/src/runtime/auth/token-service.ts +275 -0
  100. package/src/runtime/auth/types.ts +79 -0
  101. package/src/runtime/channel-approval-parser.ts +11 -5
  102. package/src/runtime/channel-approval-types.ts +1 -1
  103. package/src/runtime/channel-approvals.ts +22 -1
  104. package/src/runtime/guardian-action-followup-executor.ts +2 -2
  105. package/src/runtime/guardian-context-resolver.ts +15 -0
  106. package/src/runtime/guardian-decision-types.ts +23 -6
  107. package/src/runtime/guardian-outbound-actions.ts +4 -22
  108. package/src/runtime/guardian-reply-router.ts +5 -3
  109. package/src/runtime/http-server.ts +210 -182
  110. package/src/runtime/http-types.ts +11 -1
  111. package/src/runtime/local-actor-identity.ts +25 -0
  112. package/src/runtime/pending-interactions.ts +1 -0
  113. package/src/runtime/routes/approval-routes.ts +42 -59
  114. package/src/runtime/routes/channel-route-shared.ts +9 -41
  115. package/src/runtime/routes/channel-routes.ts +0 -2
  116. package/src/runtime/routes/conversation-routes.ts +39 -49
  117. package/src/runtime/routes/events-routes.ts +15 -22
  118. package/src/runtime/routes/guardian-action-routes.ts +46 -51
  119. package/src/runtime/routes/guardian-approval-interception.ts +6 -5
  120. package/src/runtime/routes/guardian-bootstrap-routes.ts +12 -8
  121. package/src/runtime/routes/guardian-refresh-routes.ts +2 -2
  122. package/src/runtime/routes/inbound-message-handler.ts +39 -45
  123. package/src/runtime/routes/pairing-routes.ts +9 -9
  124. package/src/runtime/routes/secret-routes.ts +90 -45
  125. package/src/runtime/routes/surface-action-routes.ts +12 -2
  126. package/src/runtime/routes/trust-rules-routes.ts +13 -0
  127. package/src/runtime/routes/twilio-routes.ts +3 -3
  128. package/src/runtime/session-approval-overrides.ts +86 -0
  129. package/src/security/keychain-to-encrypted-migration.ts +8 -1
  130. package/src/skills/frontmatter.ts +44 -1
  131. package/src/tools/permission-checker.ts +226 -74
  132. package/src/runtime/actor-token-service.ts +0 -234
  133. 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,11 @@
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
+
4
9
  export async function run(input: Record<string, unknown>, context: ToolContext): Promise<ToolExecutionResult> {
5
10
  const platform = input.platform as string | undefined;
6
11
  const conversationId = input.conversation_id as string;
@@ -19,6 +24,77 @@ export async function run(input: Record<string, unknown>, context: ToolContext):
19
24
 
20
25
  try {
21
26
  const provider = resolveProvider(platform);
27
+
28
+ // Gmail: create a threaded draft with reply-all recipients
29
+ if (provider.id === 'gmail') {
30
+ return withProviderToken(provider, async (token) => {
31
+ // Fetch thread messages to extract recipients and threading headers
32
+ const list = await listMessages(token, `thread:${threadId}`, 10);
33
+ if (!list.messages?.length) {
34
+ return err('No messages found in this thread.');
35
+ }
36
+
37
+ const messages = await batchGetMessages(token, list.messages.map((m) => m.id), 'metadata', [
38
+ 'From', 'To', 'Cc', 'Message-ID', 'Subject',
39
+ ]);
40
+
41
+ // Use the latest message for threading and recipient extraction
42
+ const latest = messages[messages.length - 1];
43
+ const latestHeaders = latest.payload?.headers ?? [];
44
+
45
+ const messageIdHeader = extractHeader(latestHeaders, 'Message-ID');
46
+ let subject = extractHeader(latestHeaders, 'Subject');
47
+ if (subject && !subject.startsWith('Re:')) {
48
+ subject = `Re: ${subject}`;
49
+ }
50
+
51
+ // Build reply-all recipient list, excluding the user's own email
52
+ const profile = await getProfile(token);
53
+ const userEmail = profile.emailAddress.toLowerCase();
54
+
55
+ const allRecipients = new Set<string>();
56
+ const allCc = new Set<string>();
57
+
58
+ // From the latest message: From goes to To, original To/Cc go to Cc
59
+ const fromAddr = extractHeader(latestHeaders, 'From');
60
+ const toAddrs = extractHeader(latestHeaders, 'To');
61
+ const ccAddrs = extractHeader(latestHeaders, 'Cc');
62
+
63
+ if (fromAddr) allRecipients.add(fromAddr);
64
+ for (const addr of toAddrs.split(',').map((a) => a.trim()).filter(Boolean)) {
65
+ allRecipients.add(addr);
66
+ }
67
+ for (const addr of ccAddrs.split(',').map((a) => a.trim()).filter(Boolean)) {
68
+ allCc.add(addr);
69
+ }
70
+
71
+ // Remove user's own email from recipients
72
+ const filterSelf = (addr: string) => !addr.toLowerCase().includes(userEmail);
73
+ const toList = [...allRecipients].filter(filterSelf);
74
+ const ccList = [...allCc].filter(filterSelf);
75
+
76
+ if (toList.length === 0) {
77
+ return err('Could not determine reply recipients from thread.');
78
+ }
79
+
80
+ const draft = await createDraft(
81
+ token,
82
+ toList.join(', '),
83
+ subject,
84
+ text,
85
+ messageIdHeader || undefined,
86
+ ccList.length > 0 ? ccList.join(', ') : undefined,
87
+ undefined,
88
+ threadId,
89
+ );
90
+
91
+ const recipientSummary = ccList.length > 0
92
+ ? `To: ${toList.join(', ')}; Cc: ${ccList.join(', ')}`
93
+ : `To: ${toList.join(', ')}`;
94
+ return ok(`Gmail draft created (ID: ${draft.id}). ${recipientSummary}. Review in Gmail Drafts, then tell me to send it or send it yourself.`);
95
+ });
96
+ }
97
+
22
98
  return withProviderToken(provider, async (token) => {
23
99
  const result = await provider.sendMessage(token, conversationId, text, {
24
100
  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
@@ -12,7 +12,7 @@ export async function run(input: Record<string, unknown>, _context: ToolContext)
12
12
  }
13
13
 
14
14
  try {
15
- return withSlackToken(async (token) => {
15
+ return await withSlackToken(async (token) => {
16
16
  await addReaction(token, channel, timestamp, emoji);
17
17
  return ok(`Added :${emoji}: reaction.`);
18
18
  });
@@ -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
 
@@ -11,7 +11,7 @@ export async function run(input: Record<string, unknown>, _context: ToolContext)
11
11
  }
12
12
 
13
13
  try {
14
- return withSlackToken(async (token) => {
14
+ return await withSlackToken(async (token) => {
15
15
  await deleteMessage(token, channel, timestamp);
16
16
  return ok(`Message deleted.`);
17
17
  });
@@ -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
  await leaveConversation(token, channel);
15
15
  return ok('Left 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,42 +135,74 @@ 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;
130
148
  const maxChannels = (input.max_channels as number) ?? 20;
131
149
 
132
150
  try {
133
- return withSlackToken(async (token) => {
151
+ return await withSlackToken(async (token) => {
134
152
  const oldestTs = String((Date.now() - hoursBack * 60 * 60 * 1000) / 1000);
135
153
 
136
154
  let channelsToScan: SlackConversation[];
155
+ let failedLookups = 0;
137
156
 
138
157
  if (channelIds?.length) {
139
158
  const results = await Promise.allSettled(
140
159
  channelIds.map((id) => slack.conversationInfo(token, id)),
141
160
  );
142
161
  channelsToScan = results
143
- .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
+ )
144
169
  .map((r) => r.value.channel);
170
+ failedLookups = results.filter((r) => r.status === "rejected").length;
145
171
  } else {
146
172
  const config = getConfig();
147
- const preferredIds = config.skills?.entries?.slack?.config?.preferredChannels as string[] | undefined;
173
+ const preferredIds = config.skills?.entries?.slack?.config
174
+ ?.preferredChannels as string[] | undefined;
148
175
 
149
176
  if (preferredIds?.length) {
150
177
  const results = await Promise.allSettled(
151
178
  preferredIds.map((id) => slack.conversationInfo(token, id)),
152
179
  );
153
180
  channelsToScan = results
154
- .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
+ )
155
188
  .map((r) => r.value.channel);
189
+ failedLookups = results.filter((r) => r.status === "rejected").length;
156
190
  } else {
157
- const resp = await slack.listConversations(token, 'public_channel,private_channel', true, 200);
158
- channelsToScan = resp.channels
191
+ const allChannels: SlackConversation[] = [];
192
+ let cursor: string | undefined;
193
+ do {
194
+ const resp = await slack.listConversations(
195
+ token,
196
+ "public_channel,private_channel",
197
+ true,
198
+ 200,
199
+ cursor,
200
+ );
201
+ allChannels.push(...resp.channels);
202
+ cursor = resp.response_metadata?.next_cursor || undefined;
203
+ } while (cursor);
204
+
205
+ channelsToScan = allChannels
159
206
  .filter((c) => c.is_member)
160
207
  .sort((a, b) => {
161
208
  const aTs = a.latest?.ts ? parseFloat(a.latest.ts) : 0;
@@ -167,20 +214,28 @@ export async function run(input: Record<string, unknown>, _context: ToolContext)
167
214
  }
168
215
 
169
216
  const scanResults = await Promise.allSettled(
170
- channelsToScan.map((conv) => scanChannel(token, conv, oldestTs, includeThreads)),
217
+ channelsToScan.map((conv) =>
218
+ scanChannel(token, conv, oldestTs, includeThreads),
219
+ ),
171
220
  );
172
221
 
173
222
  const digests: ChannelDigest[] = scanResults
174
- .filter((r): r is PromiseFulfilledResult<ChannelDigest> => r.status === 'fulfilled')
223
+ .filter(
224
+ (r): r is PromiseFulfilledResult<ChannelDigest> =>
225
+ r.status === "fulfilled",
226
+ )
175
227
  .map((r) => r.value)
176
228
  .filter((d) => d.messageCount > 0 || d.error);
177
229
 
178
- const skippedCount = scanResults.filter((r) => r.status === 'rejected').length;
230
+ const skippedCount = scanResults.filter(
231
+ (r) => r.status === "rejected",
232
+ ).length;
179
233
 
180
234
  const result = {
181
235
  scannedChannels: digests.length,
182
236
  totalChannelsAttempted: channelsToScan.length,
183
237
  skippedDueToErrors: skippedCount,
238
+ failedLookups,
184
239
  hoursBack,
185
240
  channels: digests,
186
241
  };