@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.
- package/ARCHITECTURE.md +77 -38
- package/README.md +10 -12
- package/package.json +1 -1
- package/src/__tests__/actor-token-service.test.ts +108 -522
- package/src/__tests__/channel-approval-routes.test.ts +92 -239
- package/src/__tests__/channel-approval.test.ts +100 -0
- package/src/__tests__/conversation-routes-guardian-reply.test.ts +13 -6
- package/src/__tests__/conversation-routes.test.ts +11 -4
- package/src/__tests__/guardian-actions-endpoint.test.ts +26 -19
- package/src/__tests__/mcp-health-check.test.ts +65 -0
- package/src/__tests__/permission-types.test.ts +33 -0
- package/src/__tests__/scan-result-store.test.ts +121 -0
- package/src/__tests__/session-agent-loop.test.ts +120 -0
- package/src/__tests__/session-approval-overrides.test.ts +205 -0
- package/src/__tests__/session-surfaces-task-progress.test.ts +38 -0
- package/src/amazon/client.ts +8 -5
- package/src/approvals/guardian-decision-primitive.ts +14 -9
- package/src/approvals/guardian-request-resolvers.ts +2 -2
- package/src/calls/call-controller.ts +2 -2
- package/src/calls/twilio-routes.ts +2 -2
- package/src/cli/mcp.ts +3 -3
- package/src/cli.ts +24 -0
- package/src/config/bundled-skills/chatgpt-import/tools/chatgpt-import.ts +19 -130
- package/src/config/bundled-skills/doordash/__tests__/doordash-client.test.ts +8 -6
- package/src/config/bundled-skills/google-calendar/SKILL.md +1 -1
- package/src/config/bundled-skills/messaging/SKILL.md +49 -14
- package/src/config/bundled-skills/messaging/TOOLS.json +52 -9
- package/src/config/bundled-skills/messaging/tools/gmail-batch-archive.ts +35 -11
- package/src/config/bundled-skills/messaging/tools/gmail-draft.ts +3 -1
- package/src/config/bundled-skills/messaging/tools/gmail-forward.ts +5 -6
- package/src/config/bundled-skills/messaging/tools/gmail-outreach-scan.ts +10 -2
- package/src/config/bundled-skills/messaging/tools/gmail-send-draft.ts +20 -0
- package/src/config/bundled-skills/messaging/tools/gmail-send-with-attachments.ts +3 -4
- package/src/config/bundled-skills/messaging/tools/gmail-sender-digest.ts +16 -8
- package/src/config/bundled-skills/messaging/tools/messaging-reply.ts +76 -0
- package/src/config/bundled-skills/messaging/tools/messaging-send.ts +10 -0
- package/src/config/bundled-skills/messaging/tools/messaging-sender-digest.ts +11 -3
- package/src/config/bundled-skills/messaging/tools/scan-result-store.ts +86 -0
- package/src/config/bundled-skills/phone-calls/SKILL.md +2 -2
- package/src/config/bundled-skills/skills-catalog/SKILL.md +31 -8
- package/src/config/bundled-skills/slack/tools/slack-add-reaction.ts +1 -1
- package/src/config/bundled-skills/slack/tools/slack-channel-details.ts +1 -1
- package/src/config/bundled-skills/slack/tools/slack-delete-message.ts +1 -1
- package/src/config/bundled-skills/slack/tools/slack-leave-channel.ts +1 -1
- package/src/config/bundled-skills/slack/tools/slack-scan-digest.ts +79 -24
- package/src/config/bundled-skills/sms-setup/SKILL.md +1 -1
- package/src/config/bundled-skills/telegram-setup/SKILL.md +1 -1
- package/src/config/bundled-skills/twilio-setup/SKILL.md +1 -1
- package/src/daemon/approval-generators.ts +6 -3
- package/src/daemon/handlers/config-ingress.ts +2 -6
- package/src/daemon/handlers/guardian-actions.ts +1 -1
- package/src/daemon/handlers/sessions.ts +4 -1
- package/src/daemon/handlers/shared.ts +3 -0
- package/src/daemon/handlers/skills.ts +32 -0
- package/src/daemon/ipc-contract/messages.ts +3 -1
- package/src/daemon/ipc-handler.ts +24 -0
- package/src/daemon/ipc-validate.ts +1 -1
- package/src/daemon/lifecycle.ts +6 -8
- package/src/daemon/server.ts +8 -3
- package/src/daemon/session-agent-loop.ts +19 -1
- package/src/daemon/session-attachments.ts +2 -1
- package/src/daemon/session-history.ts +2 -2
- package/src/daemon/session-process.ts +5 -9
- package/src/daemon/session-surfaces.ts +17 -1
- package/src/daemon/session-tool-setup.ts +216 -69
- package/src/daemon/session.ts +24 -1
- package/src/events/domain-events.ts +1 -1
- package/src/events/tool-domain-event-publisher.ts +5 -10
- package/src/influencer/client.ts +8 -7
- package/src/messaging/providers/gmail/client.ts +33 -1
- package/src/messaging/providers/gmail/mime-builder.ts +5 -1
- package/src/messaging/providers/sms/adapter.ts +3 -7
- package/src/messaging/providers/telegram-bot/adapter.ts +3 -7
- package/src/messaging/providers/whatsapp/adapter.ts +3 -7
- package/src/notifications/adapters/sms.ts +2 -2
- package/src/notifications/adapters/telegram.ts +2 -2
- package/src/permissions/prompter.ts +2 -0
- package/src/permissions/types.ts +11 -1
- package/src/runtime/approval-conversation-turn.ts +4 -0
- package/src/runtime/auth/__tests__/context.test.ts +130 -0
- package/src/runtime/auth/__tests__/credential-service.test.ts +277 -0
- package/src/runtime/auth/__tests__/guard-tests.test.ts +289 -0
- package/src/runtime/auth/__tests__/ipc-auth-context.test.ts +71 -0
- package/src/runtime/auth/__tests__/middleware.test.ts +239 -0
- package/src/runtime/auth/__tests__/policy.test.ts +29 -0
- package/src/runtime/auth/__tests__/route-policy.test.ts +166 -0
- package/src/runtime/auth/__tests__/scopes.test.ts +109 -0
- package/src/runtime/auth/__tests__/subject.test.ts +149 -0
- package/src/runtime/auth/__tests__/token-service.test.ts +263 -0
- package/src/runtime/auth/context.ts +62 -0
- package/src/runtime/{actor-refresh-token-service.ts → auth/credential-service.ts} +112 -79
- package/src/runtime/auth/external-assistant-id.ts +69 -0
- package/src/runtime/auth/index.ts +37 -0
- package/src/runtime/auth/middleware.ts +127 -0
- package/src/runtime/auth/policy.ts +17 -0
- package/src/runtime/auth/route-policy.ts +261 -0
- package/src/runtime/auth/scopes.ts +64 -0
- package/src/runtime/auth/subject.ts +68 -0
- package/src/runtime/auth/token-service.ts +275 -0
- package/src/runtime/auth/types.ts +79 -0
- package/src/runtime/channel-approval-parser.ts +11 -5
- package/src/runtime/channel-approval-types.ts +1 -1
- package/src/runtime/channel-approvals.ts +22 -1
- package/src/runtime/guardian-action-followup-executor.ts +2 -2
- package/src/runtime/guardian-context-resolver.ts +15 -0
- package/src/runtime/guardian-decision-types.ts +23 -6
- package/src/runtime/guardian-outbound-actions.ts +4 -22
- package/src/runtime/guardian-reply-router.ts +5 -3
- package/src/runtime/http-server.ts +210 -182
- package/src/runtime/http-types.ts +11 -1
- package/src/runtime/local-actor-identity.ts +25 -0
- package/src/runtime/pending-interactions.ts +1 -0
- package/src/runtime/routes/approval-routes.ts +42 -59
- package/src/runtime/routes/channel-route-shared.ts +9 -41
- package/src/runtime/routes/channel-routes.ts +0 -2
- package/src/runtime/routes/conversation-routes.ts +39 -49
- package/src/runtime/routes/events-routes.ts +15 -22
- package/src/runtime/routes/guardian-action-routes.ts +46 -51
- package/src/runtime/routes/guardian-approval-interception.ts +6 -5
- package/src/runtime/routes/guardian-bootstrap-routes.ts +12 -8
- package/src/runtime/routes/guardian-refresh-routes.ts +2 -2
- package/src/runtime/routes/inbound-message-handler.ts +39 -45
- package/src/runtime/routes/pairing-routes.ts +9 -9
- package/src/runtime/routes/secret-routes.ts +90 -45
- package/src/runtime/routes/surface-action-routes.ts +12 -2
- package/src/runtime/routes/trust-rules-routes.ts +13 -0
- package/src/runtime/routes/twilio-routes.ts +3 -3
- package/src/runtime/session-approval-overrides.ts +86 -0
- package/src/security/keychain-to-encrypted-migration.ts +8 -1
- package/src/skills/frontmatter.ts +44 -1
- package/src/tools/permission-checker.ts +226 -74
- package/src/runtime/actor-token-service.ts +0 -234
- 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 {
|
|
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
|
|
70
|
+
const draft = await createDraftRaw(token, raw, threadId);
|
|
71
71
|
|
|
72
72
|
const filenames = attachments.map((a) => a.filename).join(', ');
|
|
73
|
-
|
|
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 =
|
|
8
|
-
const MAX_IDS_PER_SENDER =
|
|
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) ??
|
|
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
|
|
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
|
-
|
|
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
|
|
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
|
|
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 `
|
|
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 `
|
|
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
|
|
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
|
-
|
|
26
|
+
```bash
|
|
27
|
+
npx clawhub search "<query>" --limit 10
|
|
28
|
+
```
|
|
27
29
|
|
|
28
|
-
|
|
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
|
-
|
|
34
|
+
```bash
|
|
35
|
+
npx clawhub explore --json --limit 10
|
|
36
|
+
```
|
|
31
37
|
|
|
32
38
|
### Inspecting a community skill
|
|
33
39
|
|
|
34
|
-
Before installing,
|
|
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
|
|
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
|
|
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
|
|
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
|
|
2
|
-
import
|
|
3
|
-
import {
|
|
4
|
-
import type {
|
|
5
|
-
|
|
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
|
|
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 =
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
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(
|
|
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(
|
|
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(
|
|
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(
|
|
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
|
|
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(
|
|
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
|
|
158
|
-
|
|
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) =>
|
|
217
|
+
channelsToScan.map((conv) =>
|
|
218
|
+
scanChannel(token, conv, oldestTs, includeThreads),
|
|
219
|
+
),
|
|
171
220
|
);
|
|
172
221
|
|
|
173
222
|
const digests: ChannelDigest[] = scanResults
|
|
174
|
-
.filter(
|
|
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(
|
|
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
|
};
|