@vellumai/assistant 0.4.14 → 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 (130) 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-channel-details.ts +1 -1
  42. package/src/config/bundled-skills/slack/tools/slack-scan-digest.ts +68 -24
  43. package/src/config/bundled-skills/sms-setup/SKILL.md +1 -1
  44. package/src/config/bundled-skills/telegram-setup/SKILL.md +1 -1
  45. package/src/config/bundled-skills/twilio-setup/SKILL.md +1 -1
  46. package/src/daemon/approval-generators.ts +6 -3
  47. package/src/daemon/handlers/config-ingress.ts +2 -6
  48. package/src/daemon/handlers/guardian-actions.ts +1 -1
  49. package/src/daemon/handlers/sessions.ts +4 -1
  50. package/src/daemon/handlers/shared.ts +3 -0
  51. package/src/daemon/handlers/skills.ts +32 -0
  52. package/src/daemon/ipc-contract/messages.ts +3 -1
  53. package/src/daemon/ipc-handler.ts +24 -0
  54. package/src/daemon/ipc-validate.ts +1 -1
  55. package/src/daemon/lifecycle.ts +6 -8
  56. package/src/daemon/server.ts +8 -3
  57. package/src/daemon/session-agent-loop.ts +19 -1
  58. package/src/daemon/session-attachments.ts +2 -1
  59. package/src/daemon/session-history.ts +2 -2
  60. package/src/daemon/session-process.ts +5 -9
  61. package/src/daemon/session-surfaces.ts +17 -1
  62. package/src/daemon/session-tool-setup.ts +216 -69
  63. package/src/daemon/session.ts +24 -1
  64. package/src/events/domain-events.ts +1 -1
  65. package/src/events/tool-domain-event-publisher.ts +5 -10
  66. package/src/influencer/client.ts +8 -7
  67. package/src/messaging/providers/gmail/client.ts +33 -1
  68. package/src/messaging/providers/gmail/mime-builder.ts +5 -1
  69. package/src/messaging/providers/sms/adapter.ts +3 -7
  70. package/src/messaging/providers/telegram-bot/adapter.ts +3 -7
  71. package/src/messaging/providers/whatsapp/adapter.ts +3 -7
  72. package/src/notifications/adapters/sms.ts +2 -2
  73. package/src/notifications/adapters/telegram.ts +2 -2
  74. package/src/permissions/prompter.ts +2 -0
  75. package/src/permissions/types.ts +11 -1
  76. package/src/runtime/approval-conversation-turn.ts +4 -0
  77. package/src/runtime/auth/__tests__/context.test.ts +130 -0
  78. package/src/runtime/auth/__tests__/credential-service.test.ts +277 -0
  79. package/src/runtime/auth/__tests__/guard-tests.test.ts +289 -0
  80. package/src/runtime/auth/__tests__/ipc-auth-context.test.ts +71 -0
  81. package/src/runtime/auth/__tests__/middleware.test.ts +239 -0
  82. package/src/runtime/auth/__tests__/policy.test.ts +29 -0
  83. package/src/runtime/auth/__tests__/route-policy.test.ts +166 -0
  84. package/src/runtime/auth/__tests__/scopes.test.ts +109 -0
  85. package/src/runtime/auth/__tests__/subject.test.ts +149 -0
  86. package/src/runtime/auth/__tests__/token-service.test.ts +263 -0
  87. package/src/runtime/auth/context.ts +62 -0
  88. package/src/runtime/{actor-refresh-token-service.ts → auth/credential-service.ts} +112 -79
  89. package/src/runtime/auth/external-assistant-id.ts +69 -0
  90. package/src/runtime/auth/index.ts +37 -0
  91. package/src/runtime/auth/middleware.ts +127 -0
  92. package/src/runtime/auth/policy.ts +17 -0
  93. package/src/runtime/auth/route-policy.ts +261 -0
  94. package/src/runtime/auth/scopes.ts +64 -0
  95. package/src/runtime/auth/subject.ts +68 -0
  96. package/src/runtime/auth/token-service.ts +275 -0
  97. package/src/runtime/auth/types.ts +79 -0
  98. package/src/runtime/channel-approval-parser.ts +11 -5
  99. package/src/runtime/channel-approval-types.ts +1 -1
  100. package/src/runtime/channel-approvals.ts +22 -1
  101. package/src/runtime/guardian-action-followup-executor.ts +2 -2
  102. package/src/runtime/guardian-context-resolver.ts +15 -0
  103. package/src/runtime/guardian-decision-types.ts +23 -6
  104. package/src/runtime/guardian-outbound-actions.ts +4 -22
  105. package/src/runtime/guardian-reply-router.ts +5 -3
  106. package/src/runtime/http-server.ts +210 -182
  107. package/src/runtime/http-types.ts +11 -1
  108. package/src/runtime/local-actor-identity.ts +25 -0
  109. package/src/runtime/pending-interactions.ts +1 -0
  110. package/src/runtime/routes/approval-routes.ts +42 -59
  111. package/src/runtime/routes/channel-route-shared.ts +9 -41
  112. package/src/runtime/routes/channel-routes.ts +0 -2
  113. package/src/runtime/routes/conversation-routes.ts +39 -49
  114. package/src/runtime/routes/events-routes.ts +15 -22
  115. package/src/runtime/routes/guardian-action-routes.ts +46 -51
  116. package/src/runtime/routes/guardian-approval-interception.ts +6 -5
  117. package/src/runtime/routes/guardian-bootstrap-routes.ts +12 -8
  118. package/src/runtime/routes/guardian-refresh-routes.ts +2 -2
  119. package/src/runtime/routes/inbound-message-handler.ts +39 -45
  120. package/src/runtime/routes/pairing-routes.ts +9 -9
  121. package/src/runtime/routes/secret-routes.ts +90 -45
  122. package/src/runtime/routes/surface-action-routes.ts +12 -2
  123. package/src/runtime/routes/trust-rules-routes.ts +13 -0
  124. package/src/runtime/routes/twilio-routes.ts +3 -3
  125. package/src/runtime/session-approval-overrides.ts +86 -0
  126. package/src/security/keychain-to-encrypted-migration.ts +8 -1
  127. package/src/skills/frontmatter.ts +44 -1
  128. package/src/tools/permission-checker.ts +226 -74
  129. package/src/runtime/actor-token-service.ts +0 -234
  130. 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
@@ -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,
@@ -156,7 +156,7 @@ Now link the user's phone number as the trusted SMS guardian. Tell the user: "No
156
156
 
157
157
  Load the **guardian-verify-setup** skill to handle the verification flow:
158
158
 
159
- - Call `skill_load` with `skill_id: "guardian-verify-setup"` to load the dependency skill.
159
+ - Call `skill_load` with `skill: "guardian-verify-setup"` to load the dependency skill.
160
160
 
161
161
  When invoking the skill, indicate the channel is `sms`. The guardian-verify-setup skill manages the full outbound verification flow, including:
162
162
 
@@ -69,7 +69,7 @@ Now link the user's Telegram account as the trusted guardian for this bot. Tell
69
69
 
70
70
  Load the **guardian-verify-setup** skill to handle the verification flow:
71
71
 
72
- - Call `skill_load` with `skill_id: "guardian-verify-setup"` to load the dependency skill.
72
+ - Call `skill_load` with `skill: "guardian-verify-setup"` to load the dependency skill.
73
73
 
74
74
  The guardian-verify-setup skill manages the full outbound verification flow for Telegram, including:
75
75
 
@@ -229,7 +229,7 @@ Now link the user's phone number as the trusted guardian for SMS and/or voice ch
229
229
 
230
230
  Load the **guardian-verify-setup** skill to handle the verification flow:
231
231
 
232
- - Call `skill_load` with `skill_id: "guardian-verify-setup"` to load the dependency skill.
232
+ - Call `skill_load` with `skill: "guardian-verify-setup"` to load the dependency skill.
233
233
 
234
234
  The guardian-verify-setup skill manages the full outbound verification flow for **one channel at a time** (sms, voice, or telegram). Each invocation handles:
235
235