@vellumai/assistant 0.4.14 → 0.4.16
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- 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__/approval-routes-http.test.ts +383 -254
- package/src/__tests__/channel-approval-routes.test.ts +94 -241
- 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__/pairing-concurrent.test.ts +78 -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/guardian-action-sweep.ts +6 -6
- package/src/calls/twilio-routes.ts +3 -5
- 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 +137 -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-channel-details.ts +1 -1
- package/src/config/bundled-skills/slack/tools/slack-scan-digest.ts +68 -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-inbox.ts +5 -5
- 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/pairing-store.ts +15 -2
- 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-slash.ts +4 -4
- 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 +298 -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/channel-retry-sweep.ts +2 -2
- 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 +215 -186
- package/src/runtime/http-types.ts +12 -2
- 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 +40 -50
- 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 +15 -10
- package/src/runtime/routes/guardian-expiry-sweep.ts +5 -5
- 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 +13 -10
- 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/sequence/reply-matcher.ts +8 -2
- package/src/skills/frontmatter.ts +44 -1
- package/src/tools/permission-checker.ts +226 -74
- package/src/tools/ui-surface/definitions.ts +2 -1
- 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,72 @@
|
|
|
1
|
+
import { batchGetMessages, createDraft, getProfile, listMessages } from '../../../../messaging/providers/gmail/client.js';
|
|
1
2
|
import type { ToolContext, ToolExecutionResult } from '../../../../tools/types.js';
|
|
2
3
|
import { err,ok, resolveProvider, withProviderToken } from './shared.js';
|
|
3
4
|
|
|
5
|
+
function extractHeader(headers: Array<{ name: string; value: string }> | undefined, name: string): string {
|
|
6
|
+
return headers?.find((h) => h.name.toLowerCase() === name.toLowerCase())?.value ?? '';
|
|
7
|
+
}
|
|
8
|
+
|
|
9
|
+
/**
|
|
10
|
+
* RFC 5322-aware address list parser. Splits a header value like
|
|
11
|
+
* `"Doe, Jane" <jane@example.com>, bob@example.com` into individual
|
|
12
|
+
* addresses without breaking on commas inside quoted display names.
|
|
13
|
+
*/
|
|
14
|
+
function parseAddressList(header: string): string[] {
|
|
15
|
+
const addresses: string[] = [];
|
|
16
|
+
let current = '';
|
|
17
|
+
let inQuotes = false;
|
|
18
|
+
let inAngle = false;
|
|
19
|
+
|
|
20
|
+
for (let i = 0; i < header.length; i++) {
|
|
21
|
+
const ch = header[i];
|
|
22
|
+
|
|
23
|
+
if (ch === '"' && !inAngle) {
|
|
24
|
+
inQuotes = !inQuotes;
|
|
25
|
+
current += ch;
|
|
26
|
+
} else if (ch === '<' && !inQuotes) {
|
|
27
|
+
inAngle = true;
|
|
28
|
+
current += ch;
|
|
29
|
+
} else if (ch === '>' && !inQuotes) {
|
|
30
|
+
inAngle = false;
|
|
31
|
+
current += ch;
|
|
32
|
+
} else if (ch === ',' && !inQuotes && !inAngle) {
|
|
33
|
+
const trimmed = current.trim();
|
|
34
|
+
if (trimmed) addresses.push(trimmed);
|
|
35
|
+
current = '';
|
|
36
|
+
} else {
|
|
37
|
+
current += ch;
|
|
38
|
+
}
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
const trimmed = current.trim();
|
|
42
|
+
if (trimmed) addresses.push(trimmed);
|
|
43
|
+
|
|
44
|
+
return addresses;
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
/**
|
|
48
|
+
* Extracts the bare email from an address that may be in any of these forms:
|
|
49
|
+
* - `user@example.com`
|
|
50
|
+
* - `<user@example.com>`
|
|
51
|
+
* - `"Display Name" <user@example.com>`
|
|
52
|
+
* - `Display Name <user@example.com>`
|
|
53
|
+
* - `"Team <Ops>" <user@example.com>`
|
|
54
|
+
* - `user@example.com (team <ops>)`
|
|
55
|
+
*
|
|
56
|
+
* Extracts all angle-bracketed segments and picks the last one containing `@`,
|
|
57
|
+
* falling back to the last segment, then to the raw string. This handles both
|
|
58
|
+
* display names with angle brackets and trailing RFC 5322 comments that may
|
|
59
|
+
* contain angle brackets.
|
|
60
|
+
*/
|
|
61
|
+
function extractEmail(address: string): string {
|
|
62
|
+
const segments = [...address.matchAll(/<([^>]+)>/g)].map((m) => m[1]);
|
|
63
|
+
if (segments.length > 0) {
|
|
64
|
+
const emailSegment = segments.findLast((s) => s.includes('@'));
|
|
65
|
+
return (emailSegment ?? segments[segments.length - 1]).trim().toLowerCase();
|
|
66
|
+
}
|
|
67
|
+
return address.trim().toLowerCase();
|
|
68
|
+
}
|
|
69
|
+
|
|
4
70
|
export async function run(input: Record<string, unknown>, context: ToolContext): Promise<ToolExecutionResult> {
|
|
5
71
|
const platform = input.platform as string | undefined;
|
|
6
72
|
const conversationId = input.conversation_id as string;
|
|
@@ -19,6 +85,77 @@ export async function run(input: Record<string, unknown>, context: ToolContext):
|
|
|
19
85
|
|
|
20
86
|
try {
|
|
21
87
|
const provider = resolveProvider(platform);
|
|
88
|
+
|
|
89
|
+
// Gmail: create a threaded draft with reply-all recipients
|
|
90
|
+
if (provider.id === 'gmail') {
|
|
91
|
+
return withProviderToken(provider, async (token) => {
|
|
92
|
+
// Fetch thread messages to extract recipients and threading headers
|
|
93
|
+
const list = await listMessages(token, `thread:${threadId}`, 10);
|
|
94
|
+
if (!list.messages?.length) {
|
|
95
|
+
return err('No messages found in this thread.');
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
const messages = await batchGetMessages(token, list.messages.map((m) => m.id), 'metadata', [
|
|
99
|
+
'From', 'To', 'Cc', 'Message-ID', 'Subject',
|
|
100
|
+
]);
|
|
101
|
+
|
|
102
|
+
// Use the latest message for threading and recipient extraction
|
|
103
|
+
const latest = messages[messages.length - 1];
|
|
104
|
+
const latestHeaders = latest.payload?.headers ?? [];
|
|
105
|
+
|
|
106
|
+
const messageIdHeader = extractHeader(latestHeaders, 'Message-ID');
|
|
107
|
+
let subject = extractHeader(latestHeaders, 'Subject');
|
|
108
|
+
if (subject && !subject.startsWith('Re:')) {
|
|
109
|
+
subject = `Re: ${subject}`;
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
// Build reply-all recipient list, excluding the user's own email
|
|
113
|
+
const profile = await getProfile(token);
|
|
114
|
+
const userEmail = profile.emailAddress.toLowerCase();
|
|
115
|
+
|
|
116
|
+
const allRecipients = new Set<string>();
|
|
117
|
+
const allCc = new Set<string>();
|
|
118
|
+
|
|
119
|
+
// From the latest message: From goes to To, original To/Cc go to Cc
|
|
120
|
+
const fromAddr = extractHeader(latestHeaders, 'From');
|
|
121
|
+
const toAddrs = extractHeader(latestHeaders, 'To');
|
|
122
|
+
const ccAddrs = extractHeader(latestHeaders, 'Cc');
|
|
123
|
+
|
|
124
|
+
if (fromAddr) allRecipients.add(fromAddr);
|
|
125
|
+
for (const addr of parseAddressList(toAddrs)) {
|
|
126
|
+
allRecipients.add(addr);
|
|
127
|
+
}
|
|
128
|
+
for (const addr of parseAddressList(ccAddrs)) {
|
|
129
|
+
allCc.add(addr);
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
// Remove user's own email from recipients using exact email comparison
|
|
133
|
+
const filterSelf = (addr: string) => extractEmail(addr) !== userEmail;
|
|
134
|
+
const toList = [...allRecipients].filter(filterSelf);
|
|
135
|
+
const ccList = [...allCc].filter(filterSelf);
|
|
136
|
+
|
|
137
|
+
if (toList.length === 0) {
|
|
138
|
+
return err('Could not determine reply recipients from thread.');
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
const draft = await createDraft(
|
|
142
|
+
token,
|
|
143
|
+
toList.join(', '),
|
|
144
|
+
subject,
|
|
145
|
+
text,
|
|
146
|
+
messageIdHeader || undefined,
|
|
147
|
+
ccList.length > 0 ? ccList.join(', ') : undefined,
|
|
148
|
+
undefined,
|
|
149
|
+
threadId,
|
|
150
|
+
);
|
|
151
|
+
|
|
152
|
+
const recipientSummary = ccList.length > 0
|
|
153
|
+
? `To: ${toList.join(', ')}; Cc: ${ccList.join(', ')}`
|
|
154
|
+
: `To: ${toList.join(', ')}`;
|
|
155
|
+
return ok(`Gmail draft created (ID: ${draft.id}). ${recipientSummary}. Review in Gmail Drafts, then tell me to send it or send it yourself.`);
|
|
156
|
+
});
|
|
157
|
+
}
|
|
158
|
+
|
|
22
159
|
return withProviderToken(provider, async (token) => {
|
|
23
160
|
const result = await provider.sendMessage(token, conversationId, text, {
|
|
24
161
|
threadId,
|
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
import { createDraft } from '../../../../messaging/providers/gmail/client.js';
|
|
1
2
|
import type { ToolContext, ToolExecutionResult } from '../../../../tools/types.js';
|
|
2
3
|
import { err,ok, resolveProvider, withProviderToken } from './shared.js';
|
|
3
4
|
|
|
@@ -17,6 +18,15 @@ export async function run(input: Record<string, unknown>, context: ToolContext):
|
|
|
17
18
|
|
|
18
19
|
try {
|
|
19
20
|
const provider = resolveProvider(platform);
|
|
21
|
+
|
|
22
|
+
// Gmail: create a draft instead of sending directly
|
|
23
|
+
if (provider.id === 'gmail') {
|
|
24
|
+
return withProviderToken(provider, async (token) => {
|
|
25
|
+
const draft = await createDraft(token, conversationId, subject ?? '', text, inReplyTo);
|
|
26
|
+
return ok(`Gmail draft created (ID: ${draft.id}). Review it in your Gmail Drafts, then tell me to send it or send it yourself from Gmail.`);
|
|
27
|
+
});
|
|
28
|
+
}
|
|
29
|
+
|
|
20
30
|
return withProviderToken(provider, async (token) => {
|
|
21
31
|
const result = await provider.sendMessage(token, conversationId, text, {
|
|
22
32
|
subject,
|
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
import type { ToolContext, ToolExecutionResult } from '../../../../tools/types.js';
|
|
2
|
+
import { storeScanResult } from './scan-result-store.js';
|
|
2
3
|
import { err, ok, resolveProvider, withProviderToken } from './shared.js';
|
|
3
4
|
|
|
4
5
|
export async function run(input: Record<string, unknown>, _context: ToolContext): Promise<ToolExecutionResult> {
|
|
@@ -37,16 +38,23 @@ export async function run(input: Record<string, unknown>, _context: ToolContext)
|
|
|
37
38
|
has_unsubscribe: s.hasUnsubscribe,
|
|
38
39
|
newest_message_id: s.newestMessageId,
|
|
39
40
|
search_query: s.searchQuery,
|
|
40
|
-
message_ids: s.messageIds,
|
|
41
|
-
has_more: s.hasMore,
|
|
42
41
|
}));
|
|
43
42
|
|
|
43
|
+
// Store message IDs server-side to keep them out of LLM context
|
|
44
|
+
const scanId = storeScanResult(result.senders.map((s) => ({
|
|
45
|
+
id: s.id,
|
|
46
|
+
messageIds: s.messageIds,
|
|
47
|
+
newestMessageId: s.newestMessageId,
|
|
48
|
+
newestUnsubscribableMessageId: null,
|
|
49
|
+
})));
|
|
50
|
+
|
|
44
51
|
return ok(JSON.stringify({
|
|
52
|
+
scan_id: scanId,
|
|
45
53
|
senders,
|
|
46
54
|
total_scanned: result.totalScanned,
|
|
47
55
|
query_used: result.queryUsed,
|
|
48
56
|
...(result.truncated ? { truncated: true, next_page_token: result.nextPageToken } : {}),
|
|
49
|
-
note: `message_count reflects emails found per sender within the ${result.totalScanned} messages scanned. Use
|
|
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
|
|
@@ -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
|
|
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,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(
|
|
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(
|
|
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 ===
|
|
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
|
|
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(
|
|
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 ===
|
|
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(
|
|
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) =>
|
|
217
|
+
channelsToScan.map((conv) =>
|
|
218
|
+
scanChannel(token, conv, oldestTs, includeThreads),
|
|
219
|
+
),
|
|
181
220
|
);
|
|
182
221
|
|
|
183
222
|
const digests: ChannelDigest[] = scanResults
|
|
184
|
-
.filter(
|
|
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(
|
|
230
|
+
const skippedCount = scanResults.filter(
|
|
231
|
+
(r) => r.status === "rejected",
|
|
232
|
+
).length;
|
|
189
233
|
|
|
190
234
|
const result = {
|
|
191
235
|
scannedChannels: digests.length,
|