@vellumai/assistant 0.4.12 → 0.4.13
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/package.json +1 -1
- package/src/__tests__/slack-skill.test.ts +124 -0
- package/src/agent/loop.ts +1 -1
- package/src/config/bundled-skills/messaging/TOOLS.json +12 -90
- package/src/config/bundled-skills/messaging/tools/gmail-outreach-scan.ts +8 -1
- package/src/config/bundled-skills/messaging/tools/gmail-sender-digest.ts +3 -2
- package/src/config/bundled-skills/messaging/tools/messaging-sender-digest.ts +4 -2
- package/src/config/bundled-skills/skills-catalog/SKILL.md +1 -1
- package/src/config/bundled-skills/slack/SKILL.md +49 -0
- package/src/config/bundled-skills/slack/TOOLS.json +167 -0
- package/src/config/bundled-skills/slack/tools/shared.ts +23 -0
- package/src/config/bundled-skills/{messaging → slack}/tools/slack-add-reaction.ts +2 -5
- package/src/config/bundled-skills/slack/tools/slack-channel-details.ts +33 -0
- package/src/config/bundled-skills/slack/tools/slack-configure-channels.ts +75 -0
- package/src/config/bundled-skills/{messaging → slack}/tools/slack-delete-message.ts +2 -5
- package/src/config/bundled-skills/{messaging → slack}/tools/slack-leave-channel.ts +2 -5
- package/src/config/bundled-skills/slack/tools/slack-scan-digest.ts +193 -0
- package/src/config/bundled-tool-registry.ts +15 -4
- package/src/config/schema.ts +1 -1
- package/src/daemon/session-agent-loop-handlers.ts +7 -6
- package/src/messaging/provider-types.ts +3 -0
- package/src/messaging/provider.ts +1 -1
- package/src/messaging/providers/gmail/adapter.ts +3 -3
- package/src/messaging/providers/slack/adapter.ts +1 -0
- package/src/messaging/providers/slack/client.ts +8 -0
- package/src/messaging/providers/slack/types.ts +5 -0
package/package.json
CHANGED
|
@@ -0,0 +1,124 @@
|
|
|
1
|
+
import { readFileSync } from 'node:fs';
|
|
2
|
+
import { join } from 'node:path';
|
|
3
|
+
|
|
4
|
+
import { describe, expect, test } from 'bun:test';
|
|
5
|
+
|
|
6
|
+
import type { SlackConversation } from '../messaging/providers/slack/types.js';
|
|
7
|
+
|
|
8
|
+
const BUNDLED_SKILLS_DIR = join(import.meta.dir, '..', 'config', 'bundled-skills');
|
|
9
|
+
|
|
10
|
+
describe('slack adapter isPrivate mapping', () => {
|
|
11
|
+
// Inline the mapping logic to test it independently of the adapter module
|
|
12
|
+
function mapIsPrivate(conv: Partial<SlackConversation>): boolean {
|
|
13
|
+
return conv.is_private ?? conv.is_group ?? false;
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
test('public channel is not private', () => {
|
|
17
|
+
expect(mapIsPrivate({ is_channel: true, is_private: false })).toBe(false);
|
|
18
|
+
});
|
|
19
|
+
|
|
20
|
+
test('private channel with is_private flag', () => {
|
|
21
|
+
expect(mapIsPrivate({ is_channel: true, is_private: true })).toBe(true);
|
|
22
|
+
});
|
|
23
|
+
|
|
24
|
+
test('private channel via is_group (legacy)', () => {
|
|
25
|
+
expect(mapIsPrivate({ is_group: true })).toBe(true);
|
|
26
|
+
});
|
|
27
|
+
|
|
28
|
+
test('is_private takes precedence over is_group', () => {
|
|
29
|
+
expect(mapIsPrivate({ is_private: false, is_group: true })).toBe(false);
|
|
30
|
+
});
|
|
31
|
+
|
|
32
|
+
test('DM defaults to not private when flags absent', () => {
|
|
33
|
+
expect(mapIsPrivate({ is_im: true })).toBe(false);
|
|
34
|
+
});
|
|
35
|
+
|
|
36
|
+
test('mpim (group DM) defaults to not private when is_private absent', () => {
|
|
37
|
+
expect(mapIsPrivate({ is_mpim: true })).toBe(false);
|
|
38
|
+
});
|
|
39
|
+
|
|
40
|
+
test('undefined flags default to false', () => {
|
|
41
|
+
expect(mapIsPrivate({})).toBe(false);
|
|
42
|
+
});
|
|
43
|
+
});
|
|
44
|
+
|
|
45
|
+
describe('slack skill TOOLS.json', () => {
|
|
46
|
+
const toolsPath = join(BUNDLED_SKILLS_DIR, 'slack', 'TOOLS.json');
|
|
47
|
+
const toolsJson = JSON.parse(readFileSync(toolsPath, 'utf-8'));
|
|
48
|
+
|
|
49
|
+
test('is valid JSON with correct version', () => {
|
|
50
|
+
expect(toolsJson.version).toBe(1);
|
|
51
|
+
expect(Array.isArray(toolsJson.tools)).toBe(true);
|
|
52
|
+
});
|
|
53
|
+
|
|
54
|
+
test('has expected tools', () => {
|
|
55
|
+
const names = toolsJson.tools.map((t: { name: string }) => t.name);
|
|
56
|
+
expect(names).toContain('slack_scan_digest');
|
|
57
|
+
expect(names).toContain('slack_channel_details');
|
|
58
|
+
expect(names).toContain('slack_configure_channels');
|
|
59
|
+
expect(names).toContain('slack_add_reaction');
|
|
60
|
+
expect(names).toContain('slack_delete_message');
|
|
61
|
+
expect(names).toContain('slack_leave_channel');
|
|
62
|
+
});
|
|
63
|
+
|
|
64
|
+
test('has 6 tools total', () => {
|
|
65
|
+
expect(toolsJson.tools.length).toBe(6);
|
|
66
|
+
});
|
|
67
|
+
|
|
68
|
+
test('all tools have required fields', () => {
|
|
69
|
+
for (const tool of toolsJson.tools) {
|
|
70
|
+
expect(tool.name).toBeDefined();
|
|
71
|
+
expect(tool.description).toBeDefined();
|
|
72
|
+
expect(tool.category).toBeDefined();
|
|
73
|
+
expect(tool.risk).toBeDefined();
|
|
74
|
+
expect(tool.input_schema).toBeDefined();
|
|
75
|
+
expect(tool.executor).toBeDefined();
|
|
76
|
+
expect(tool.execution_target).toBeDefined();
|
|
77
|
+
}
|
|
78
|
+
});
|
|
79
|
+
|
|
80
|
+
test('all executor files exist', () => {
|
|
81
|
+
const slackSkillDir = join(BUNDLED_SKILLS_DIR, 'slack');
|
|
82
|
+
for (const tool of toolsJson.tools) {
|
|
83
|
+
const executorPath = join(slackSkillDir, tool.executor);
|
|
84
|
+
expect(() => readFileSync(executorPath)).not.toThrow();
|
|
85
|
+
}
|
|
86
|
+
});
|
|
87
|
+
});
|
|
88
|
+
|
|
89
|
+
describe('messaging skill no longer has Slack tools', () => {
|
|
90
|
+
const messagingToolsPath = join(BUNDLED_SKILLS_DIR, 'messaging', 'TOOLS.json');
|
|
91
|
+
const messagingToolsJson = JSON.parse(readFileSync(messagingToolsPath, 'utf-8'));
|
|
92
|
+
|
|
93
|
+
test('slack_add_reaction not in messaging TOOLS.json', () => {
|
|
94
|
+
const names = messagingToolsJson.tools.map((t: { name: string }) => t.name);
|
|
95
|
+
expect(names).not.toContain('slack_add_reaction');
|
|
96
|
+
});
|
|
97
|
+
|
|
98
|
+
test('slack_delete_message not in messaging TOOLS.json', () => {
|
|
99
|
+
const names = messagingToolsJson.tools.map((t: { name: string }) => t.name);
|
|
100
|
+
expect(names).not.toContain('slack_delete_message');
|
|
101
|
+
});
|
|
102
|
+
|
|
103
|
+
test('slack_leave_channel not in messaging TOOLS.json', () => {
|
|
104
|
+
const names = messagingToolsJson.tools.map((t: { name: string }) => t.name);
|
|
105
|
+
expect(names).not.toContain('slack_leave_channel');
|
|
106
|
+
});
|
|
107
|
+
});
|
|
108
|
+
|
|
109
|
+
describe('slack skill SKILL.md', () => {
|
|
110
|
+
const skillMd = readFileSync(join(BUNDLED_SKILLS_DIR, 'slack', 'SKILL.md'), 'utf-8');
|
|
111
|
+
|
|
112
|
+
test('has correct frontmatter name', () => {
|
|
113
|
+
expect(skillMd).toContain('name: "Slack"');
|
|
114
|
+
});
|
|
115
|
+
|
|
116
|
+
test('is user-invocable', () => {
|
|
117
|
+
expect(skillMd).toContain('user-invocable: true');
|
|
118
|
+
});
|
|
119
|
+
|
|
120
|
+
test('mentions privacy rules', () => {
|
|
121
|
+
expect(skillMd).toContain('isPrivate');
|
|
122
|
+
expect(skillMd).toContain('MUST NEVER be shared');
|
|
123
|
+
});
|
|
124
|
+
});
|
package/src/agent/loop.ts
CHANGED
|
@@ -221,96 +221,6 @@
|
|
|
221
221
|
"executor": "tools/messaging-mark-read.ts",
|
|
222
222
|
"execution_target": "host"
|
|
223
223
|
},
|
|
224
|
-
{
|
|
225
|
-
"name": "slack_add_reaction",
|
|
226
|
-
"description": "Add an emoji reaction to a Slack message. Include a confidence score (0-1).",
|
|
227
|
-
"category": "messaging",
|
|
228
|
-
"risk": "medium",
|
|
229
|
-
"input_schema": {
|
|
230
|
-
"type": "object",
|
|
231
|
-
"properties": {
|
|
232
|
-
"channel": {
|
|
233
|
-
"type": "string",
|
|
234
|
-
"description": "Slack channel ID"
|
|
235
|
-
},
|
|
236
|
-
"timestamp": {
|
|
237
|
-
"type": "string",
|
|
238
|
-
"description": "Message timestamp (ts)"
|
|
239
|
-
},
|
|
240
|
-
"emoji": {
|
|
241
|
-
"type": "string",
|
|
242
|
-
"description": "Emoji name without colons (e.g. \"thumbsup\")"
|
|
243
|
-
},
|
|
244
|
-
"confidence": {
|
|
245
|
-
"type": "number",
|
|
246
|
-
"description": "Confidence score (0-1) for this action"
|
|
247
|
-
}
|
|
248
|
-
},
|
|
249
|
-
"required": [
|
|
250
|
-
"channel",
|
|
251
|
-
"timestamp",
|
|
252
|
-
"emoji",
|
|
253
|
-
"confidence"
|
|
254
|
-
]
|
|
255
|
-
},
|
|
256
|
-
"executor": "tools/slack-add-reaction.ts",
|
|
257
|
-
"execution_target": "host"
|
|
258
|
-
},
|
|
259
|
-
{
|
|
260
|
-
"name": "slack_delete_message",
|
|
261
|
-
"description": "Delete a Slack message posted by the bot. Include a confidence score (0-1).",
|
|
262
|
-
"category": "messaging",
|
|
263
|
-
"risk": "high",
|
|
264
|
-
"input_schema": {
|
|
265
|
-
"type": "object",
|
|
266
|
-
"properties": {
|
|
267
|
-
"channel": {
|
|
268
|
-
"type": "string",
|
|
269
|
-
"description": "Slack channel ID"
|
|
270
|
-
},
|
|
271
|
-
"timestamp": {
|
|
272
|
-
"type": "string",
|
|
273
|
-
"description": "Message timestamp (ts) to delete"
|
|
274
|
-
},
|
|
275
|
-
"confidence": {
|
|
276
|
-
"type": "number",
|
|
277
|
-
"description": "Confidence score (0-1) for this action"
|
|
278
|
-
}
|
|
279
|
-
},
|
|
280
|
-
"required": [
|
|
281
|
-
"channel",
|
|
282
|
-
"timestamp",
|
|
283
|
-
"confidence"
|
|
284
|
-
]
|
|
285
|
-
},
|
|
286
|
-
"executor": "tools/slack-delete-message.ts",
|
|
287
|
-
"execution_target": "host"
|
|
288
|
-
},
|
|
289
|
-
{
|
|
290
|
-
"name": "slack_leave_channel",
|
|
291
|
-
"description": "Leave a Slack channel. Include a confidence score (0-1).",
|
|
292
|
-
"category": "messaging",
|
|
293
|
-
"risk": "medium",
|
|
294
|
-
"input_schema": {
|
|
295
|
-
"type": "object",
|
|
296
|
-
"properties": {
|
|
297
|
-
"channel": {
|
|
298
|
-
"type": "string",
|
|
299
|
-
"description": "Slack channel ID to leave"
|
|
300
|
-
},
|
|
301
|
-
"confidence": {
|
|
302
|
-
"type": "number",
|
|
303
|
-
"description": "Confidence score (0-1) for this action"
|
|
304
|
-
}
|
|
305
|
-
},
|
|
306
|
-
"required": [
|
|
307
|
-
"channel",
|
|
308
|
-
"confidence"
|
|
309
|
-
]
|
|
310
|
-
},
|
|
311
|
-
"executor": "tools/slack-leave-channel.ts",
|
|
312
|
-
"execution_target": "host"
|
|
313
|
-
},
|
|
314
224
|
{
|
|
315
225
|
"name": "messaging_analyze_activity",
|
|
316
226
|
"description": "Analyze channel or conversation activity levels. Groups conversations as high/medium/low/dead activity.",
|
|
@@ -1011,6 +921,10 @@
|
|
|
1011
921
|
"max_senders": {
|
|
1012
922
|
"type": "number",
|
|
1013
923
|
"description": "Maximum senders to return (default 30)"
|
|
924
|
+
},
|
|
925
|
+
"page_token": {
|
|
926
|
+
"type": "string",
|
|
927
|
+
"description": "Resume token from a previous scan's next_page_token to continue scanning beyond the cap"
|
|
1014
928
|
}
|
|
1015
929
|
}
|
|
1016
930
|
},
|
|
@@ -1040,6 +954,10 @@
|
|
|
1040
954
|
"max_senders": {
|
|
1041
955
|
"type": "number",
|
|
1042
956
|
"description": "Maximum senders to return (default 30)"
|
|
957
|
+
},
|
|
958
|
+
"page_token": {
|
|
959
|
+
"type": "string",
|
|
960
|
+
"description": "Resume token from a previous scan's next_page_token to continue scanning beyond the cap"
|
|
1043
961
|
}
|
|
1044
962
|
}
|
|
1045
963
|
},
|
|
@@ -1098,6 +1016,10 @@
|
|
|
1098
1016
|
"min_confidence": {
|
|
1099
1017
|
"type": "number",
|
|
1100
1018
|
"description": "Minimum confidence threshold 0-1 (default 0.5)"
|
|
1019
|
+
},
|
|
1020
|
+
"page_token": {
|
|
1021
|
+
"type": "string",
|
|
1022
|
+
"description": "Resume token from a previous scan's next_page_token to continue scanning beyond the cap"
|
|
1101
1023
|
}
|
|
1102
1024
|
}
|
|
1103
1025
|
},
|
|
@@ -70,6 +70,7 @@ export async function run(input: Record<string, unknown>, _context: ToolContext)
|
|
|
70
70
|
const maxSenders = (input.max_senders as number) ?? 30;
|
|
71
71
|
const timeRange = (input.time_range as string) ?? '90d';
|
|
72
72
|
const minConfidence = (input.min_confidence as number) ?? 0.5;
|
|
73
|
+
const inputPageToken = input.page_token as string | undefined;
|
|
73
74
|
|
|
74
75
|
const query = `in:inbox -has:unsubscribe newer_than:${timeRange}`;
|
|
75
76
|
|
|
@@ -78,7 +79,8 @@ export async function run(input: Record<string, unknown>, _context: ToolContext)
|
|
|
78
79
|
return withValidToken(provider.credentialService, async (token) => {
|
|
79
80
|
// Paginate through listMessages to collect up to maxMessages IDs
|
|
80
81
|
const allMessageIds: string[] = [];
|
|
81
|
-
let pageToken: string | undefined;
|
|
82
|
+
let pageToken: string | undefined = inputPageToken;
|
|
83
|
+
let truncated = false;
|
|
82
84
|
|
|
83
85
|
while (allMessageIds.length < maxMessages) {
|
|
84
86
|
const pageSize = Math.min(100, maxMessages - allMessageIds.length);
|
|
@@ -90,6 +92,10 @@ export async function run(input: Record<string, unknown>, _context: ToolContext)
|
|
|
90
92
|
if (!pageToken) break;
|
|
91
93
|
}
|
|
92
94
|
|
|
95
|
+
if (allMessageIds.length >= maxMessages && pageToken) {
|
|
96
|
+
truncated = true;
|
|
97
|
+
}
|
|
98
|
+
|
|
93
99
|
if (allMessageIds.length === 0) {
|
|
94
100
|
return ok(JSON.stringify({ senders: [], total_scanned: 0, outreach_detected: 0, message: 'No emails found matching the query.' }));
|
|
95
101
|
}
|
|
@@ -215,6 +221,7 @@ export async function run(input: Record<string, unknown>, _context: ToolContext)
|
|
|
215
221
|
senders,
|
|
216
222
|
total_scanned: allMessageIds.length,
|
|
217
223
|
outreach_detected: totalOutreachDetected,
|
|
224
|
+
...(truncated ? { truncated: true, next_page_token: pageToken } : {}),
|
|
218
225
|
}));
|
|
219
226
|
});
|
|
220
227
|
} catch (e) {
|
|
@@ -38,13 +38,14 @@ export async function run(input: Record<string, unknown>, _context: ToolContext)
|
|
|
38
38
|
const query = (input.query as string) ?? 'category:promotions newer_than:90d';
|
|
39
39
|
const maxMessages = Math.min((input.max_messages as number) ?? 500, MAX_MESSAGES_CAP);
|
|
40
40
|
const maxSenders = (input.max_senders as number) ?? 30;
|
|
41
|
+
const inputPageToken = input.page_token as string | undefined;
|
|
41
42
|
|
|
42
43
|
try {
|
|
43
44
|
const provider = getMessagingProvider('gmail');
|
|
44
45
|
return withValidToken(provider.credentialService, async (token) => {
|
|
45
46
|
// Paginate through listMessages to collect up to maxMessages IDs
|
|
46
47
|
const allMessageIds: string[] = [];
|
|
47
|
-
let pageToken: string | undefined;
|
|
48
|
+
let pageToken: string | undefined = inputPageToken;
|
|
48
49
|
let truncated = false;
|
|
49
50
|
|
|
50
51
|
while (allMessageIds.length < maxMessages) {
|
|
@@ -171,7 +172,7 @@ export async function run(input: Record<string, unknown>, _context: ToolContext)
|
|
|
171
172
|
senders: result,
|
|
172
173
|
total_scanned: allMessageIds.length,
|
|
173
174
|
query_used: query,
|
|
174
|
-
...(truncated ? { truncated: true } : {}),
|
|
175
|
+
...(truncated ? { truncated: true, next_page_token: pageToken } : {}),
|
|
175
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.`,
|
|
176
177
|
}));
|
|
177
178
|
});
|
|
@@ -6,6 +6,7 @@ export async function run(input: Record<string, unknown>, _context: ToolContext)
|
|
|
6
6
|
const query = (input.query as string) ?? 'category:promotions newer_than:90d';
|
|
7
7
|
const maxMessages = input.max_messages as number | undefined;
|
|
8
8
|
const maxSenders = input.max_senders as number | undefined;
|
|
9
|
+
const pageToken = input.page_token as string | undefined;
|
|
9
10
|
|
|
10
11
|
try {
|
|
11
12
|
const provider = resolveProvider(platform);
|
|
@@ -15,13 +16,14 @@ export async function run(input: Record<string, unknown>, _context: ToolContext)
|
|
|
15
16
|
}
|
|
16
17
|
|
|
17
18
|
return withProviderToken(provider, async (token) => {
|
|
18
|
-
const result = await provider.senderDigest!(token, query, { maxMessages, maxSenders });
|
|
19
|
+
const result = await provider.senderDigest!(token, query, { maxMessages, maxSenders, pageToken });
|
|
19
20
|
|
|
20
21
|
if (result.senders.length === 0) {
|
|
21
22
|
return ok(JSON.stringify({
|
|
22
23
|
senders: [],
|
|
23
24
|
total_scanned: result.totalScanned,
|
|
24
25
|
query_used: result.queryUsed,
|
|
26
|
+
...(result.truncated ? { truncated: true, next_page_token: result.nextPageToken } : {}),
|
|
25
27
|
message: 'No emails found matching the query. Try broadening the search (e.g. remove category filter or extend date range).',
|
|
26
28
|
}));
|
|
27
29
|
}
|
|
@@ -43,7 +45,7 @@ export async function run(input: Record<string, unknown>, _context: ToolContext)
|
|
|
43
45
|
senders,
|
|
44
46
|
total_scanned: result.totalScanned,
|
|
45
47
|
query_used: result.queryUsed,
|
|
46
|
-
...(result.truncated ? { truncated: true } : {}),
|
|
48
|
+
...(result.truncated ? { truncated: true, next_page_token: result.nextPageToken } : {}),
|
|
47
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.`,
|
|
48
50
|
}));
|
|
49
51
|
});
|
|
@@ -23,7 +23,7 @@ Community skills are published on [Clawhub](https://clawhub.com) and can be sear
|
|
|
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
|
|
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.
|
|
27
27
|
|
|
28
28
|
### Installing a community skill
|
|
29
29
|
|
|
@@ -0,0 +1,49 @@
|
|
|
1
|
+
---
|
|
2
|
+
name: "Slack"
|
|
3
|
+
description: "Scan channels, summarize threads, and manage Slack with privacy guardrails"
|
|
4
|
+
user-invocable: true
|
|
5
|
+
metadata: {"vellum": {"emoji": "💬"}}
|
|
6
|
+
---
|
|
7
|
+
|
|
8
|
+
You are a Slack assistant that helps users stay on top of their Slack workspace. Use the slack tools for channel scanning, thread summarization, and Slack-specific operations.
|
|
9
|
+
|
|
10
|
+
## Channel Scanning
|
|
11
|
+
|
|
12
|
+
When the user says "scan my Slack", "what's happening on Slack", or similar:
|
|
13
|
+
|
|
14
|
+
1. Call `slack_scan_digest` immediately. If preferred channels are configured, scan those; otherwise scan top active channels.
|
|
15
|
+
2. Present results progressively: overview first (channel names, message counts, top threads), then offer to drill into specific threads.
|
|
16
|
+
3. For threads the user wants to explore, use `messaging_read` with `thread_id` to fetch full content, then summarize with attribution (who said what, decisions made, open questions).
|
|
17
|
+
|
|
18
|
+
## Thread Summarization
|
|
19
|
+
|
|
20
|
+
When summarizing threads surfaced by the digest:
|
|
21
|
+
|
|
22
|
+
- Include attribution: who said what, what decisions were made, what questions remain open
|
|
23
|
+
- Note the thread's channel and whether it's private
|
|
24
|
+
- Keep summaries concise but complete
|
|
25
|
+
|
|
26
|
+
## Context Sharing (Privacy Rules)
|
|
27
|
+
|
|
28
|
+
**This is critical.** Channel privacy must be respected at all times:
|
|
29
|
+
|
|
30
|
+
- Content from `isPrivate: true` channels MUST NEVER be shared to other channels, DMs, or external destinations
|
|
31
|
+
- Before sharing any content, always check the source channel's `isPrivate` flag in the digest data
|
|
32
|
+
- If the user asks to share private channel content, explain that the content is from a private channel and cannot be shared externally, then offer alternatives (e.g., summarize the topic without quoting, ask the user to share manually)
|
|
33
|
+
- Public channel content can be shared with attribution ("From #channel: ...")
|
|
34
|
+
- Always confirm with the user before sending content to any destination
|
|
35
|
+
|
|
36
|
+
## Channel Preferences
|
|
37
|
+
|
|
38
|
+
Use `slack_configure_channels` to save and load preferred channels for scanning.
|
|
39
|
+
|
|
40
|
+
- After a first scan, suggest configuring defaults: "Want me to remember these channels for future scans?"
|
|
41
|
+
- Saved preferences are used automatically by `slack_scan_digest` when no specific channels are requested
|
|
42
|
+
|
|
43
|
+
## Watcher Integration
|
|
44
|
+
|
|
45
|
+
For real-time monitoring (not just on-demand scanning), the user can set up a Slack watcher using the watcher skill with the same channel IDs. Mention this if the user wants ongoing monitoring.
|
|
46
|
+
|
|
47
|
+
## Connection
|
|
48
|
+
|
|
49
|
+
Before using any Slack tool, verify that Slack is connected. If not connected, guide the user through the Slack setup flow described in the messaging skill.
|
|
@@ -0,0 +1,167 @@
|
|
|
1
|
+
{
|
|
2
|
+
"version": 1,
|
|
3
|
+
"tools": [
|
|
4
|
+
{
|
|
5
|
+
"name": "slack_scan_digest",
|
|
6
|
+
"description": "Scan multiple Slack channels and return a digest of recent activity including top threads, participants, and privacy status.",
|
|
7
|
+
"category": "slack",
|
|
8
|
+
"risk": "low",
|
|
9
|
+
"input_schema": {
|
|
10
|
+
"type": "object",
|
|
11
|
+
"properties": {
|
|
12
|
+
"channel_ids": {
|
|
13
|
+
"type": "array",
|
|
14
|
+
"items": { "type": "string" },
|
|
15
|
+
"description": "Specific channel IDs to scan. If omitted, scans preferred or top active channels."
|
|
16
|
+
},
|
|
17
|
+
"hours_back": {
|
|
18
|
+
"type": "number",
|
|
19
|
+
"description": "How many hours of history to scan (default 24)"
|
|
20
|
+
},
|
|
21
|
+
"include_threads": {
|
|
22
|
+
"type": "boolean",
|
|
23
|
+
"description": "Whether to fetch thread replies for top threads (default true)"
|
|
24
|
+
},
|
|
25
|
+
"max_channels": {
|
|
26
|
+
"type": "number",
|
|
27
|
+
"description": "Maximum number of channels to scan (default 20)"
|
|
28
|
+
}
|
|
29
|
+
}
|
|
30
|
+
},
|
|
31
|
+
"executor": "tools/slack-scan-digest.ts",
|
|
32
|
+
"execution_target": "host"
|
|
33
|
+
},
|
|
34
|
+
{
|
|
35
|
+
"name": "slack_channel_details",
|
|
36
|
+
"description": "Get detailed information about a single Slack channel including name, topic, purpose, privacy status, and member count.",
|
|
37
|
+
"category": "slack",
|
|
38
|
+
"risk": "low",
|
|
39
|
+
"input_schema": {
|
|
40
|
+
"type": "object",
|
|
41
|
+
"properties": {
|
|
42
|
+
"channel_id": {
|
|
43
|
+
"type": "string",
|
|
44
|
+
"description": "The Slack channel ID to get details for"
|
|
45
|
+
}
|
|
46
|
+
},
|
|
47
|
+
"required": ["channel_id"]
|
|
48
|
+
},
|
|
49
|
+
"executor": "tools/slack-channel-details.ts",
|
|
50
|
+
"execution_target": "host"
|
|
51
|
+
},
|
|
52
|
+
{
|
|
53
|
+
"name": "slack_configure_channels",
|
|
54
|
+
"description": "Manage preferred Slack channels for scanning. List, add, remove, or set the preferred channel list.",
|
|
55
|
+
"category": "slack",
|
|
56
|
+
"risk": "low",
|
|
57
|
+
"input_schema": {
|
|
58
|
+
"type": "object",
|
|
59
|
+
"properties": {
|
|
60
|
+
"action": {
|
|
61
|
+
"type": "string",
|
|
62
|
+
"enum": ["list", "add", "remove", "set"],
|
|
63
|
+
"description": "Action to perform on the preferred channels list"
|
|
64
|
+
},
|
|
65
|
+
"channel_ids": {
|
|
66
|
+
"type": "array",
|
|
67
|
+
"items": { "type": "string" },
|
|
68
|
+
"description": "Channel IDs to add, remove, or set (not needed for 'list')"
|
|
69
|
+
}
|
|
70
|
+
},
|
|
71
|
+
"required": ["action"]
|
|
72
|
+
},
|
|
73
|
+
"executor": "tools/slack-configure-channels.ts",
|
|
74
|
+
"execution_target": "host"
|
|
75
|
+
},
|
|
76
|
+
{
|
|
77
|
+
"name": "slack_add_reaction",
|
|
78
|
+
"description": "Add an emoji reaction to a Slack message. Include a confidence score (0-1).",
|
|
79
|
+
"category": "slack",
|
|
80
|
+
"risk": "medium",
|
|
81
|
+
"input_schema": {
|
|
82
|
+
"type": "object",
|
|
83
|
+
"properties": {
|
|
84
|
+
"channel": {
|
|
85
|
+
"type": "string",
|
|
86
|
+
"description": "Slack channel ID"
|
|
87
|
+
},
|
|
88
|
+
"timestamp": {
|
|
89
|
+
"type": "string",
|
|
90
|
+
"description": "Message timestamp (ts)"
|
|
91
|
+
},
|
|
92
|
+
"emoji": {
|
|
93
|
+
"type": "string",
|
|
94
|
+
"description": "Emoji name without colons (e.g. \"thumbsup\")"
|
|
95
|
+
},
|
|
96
|
+
"confidence": {
|
|
97
|
+
"type": "number",
|
|
98
|
+
"description": "Confidence score (0-1) for this action"
|
|
99
|
+
}
|
|
100
|
+
},
|
|
101
|
+
"required": [
|
|
102
|
+
"channel",
|
|
103
|
+
"timestamp",
|
|
104
|
+
"emoji",
|
|
105
|
+
"confidence"
|
|
106
|
+
]
|
|
107
|
+
},
|
|
108
|
+
"executor": "tools/slack-add-reaction.ts",
|
|
109
|
+
"execution_target": "host"
|
|
110
|
+
},
|
|
111
|
+
{
|
|
112
|
+
"name": "slack_delete_message",
|
|
113
|
+
"description": "Delete a Slack message posted by the bot. Include a confidence score (0-1).",
|
|
114
|
+
"category": "slack",
|
|
115
|
+
"risk": "high",
|
|
116
|
+
"input_schema": {
|
|
117
|
+
"type": "object",
|
|
118
|
+
"properties": {
|
|
119
|
+
"channel": {
|
|
120
|
+
"type": "string",
|
|
121
|
+
"description": "Slack channel ID"
|
|
122
|
+
},
|
|
123
|
+
"timestamp": {
|
|
124
|
+
"type": "string",
|
|
125
|
+
"description": "Message timestamp (ts) to delete"
|
|
126
|
+
},
|
|
127
|
+
"confidence": {
|
|
128
|
+
"type": "number",
|
|
129
|
+
"description": "Confidence score (0-1) for this action"
|
|
130
|
+
}
|
|
131
|
+
},
|
|
132
|
+
"required": [
|
|
133
|
+
"channel",
|
|
134
|
+
"timestamp",
|
|
135
|
+
"confidence"
|
|
136
|
+
]
|
|
137
|
+
},
|
|
138
|
+
"executor": "tools/slack-delete-message.ts",
|
|
139
|
+
"execution_target": "host"
|
|
140
|
+
},
|
|
141
|
+
{
|
|
142
|
+
"name": "slack_leave_channel",
|
|
143
|
+
"description": "Leave a Slack channel. Include a confidence score (0-1).",
|
|
144
|
+
"category": "slack",
|
|
145
|
+
"risk": "medium",
|
|
146
|
+
"input_schema": {
|
|
147
|
+
"type": "object",
|
|
148
|
+
"properties": {
|
|
149
|
+
"channel": {
|
|
150
|
+
"type": "string",
|
|
151
|
+
"description": "Slack channel ID to leave"
|
|
152
|
+
},
|
|
153
|
+
"confidence": {
|
|
154
|
+
"type": "number",
|
|
155
|
+
"description": "Confidence score (0-1) for this action"
|
|
156
|
+
}
|
|
157
|
+
},
|
|
158
|
+
"required": [
|
|
159
|
+
"channel",
|
|
160
|
+
"confidence"
|
|
161
|
+
]
|
|
162
|
+
},
|
|
163
|
+
"executor": "tools/slack-leave-channel.ts",
|
|
164
|
+
"execution_target": "host"
|
|
165
|
+
}
|
|
166
|
+
]
|
|
167
|
+
}
|
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Shared utilities for slack skill tools.
|
|
3
|
+
*/
|
|
4
|
+
|
|
5
|
+
import { getMessagingProvider } from '../../../../messaging/registry.js';
|
|
6
|
+
import { withValidToken } from '../../../../security/token-manager.js';
|
|
7
|
+
import type { ToolExecutionResult } from '../../../../tools/types.js';
|
|
8
|
+
|
|
9
|
+
export function ok(content: string): ToolExecutionResult {
|
|
10
|
+
return { content, isError: false };
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
export function err(message: string): ToolExecutionResult {
|
|
14
|
+
return { content: message, isError: true };
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
/**
|
|
18
|
+
* Execute a callback with a valid Slack OAuth token.
|
|
19
|
+
*/
|
|
20
|
+
export async function withSlackToken<T>(fn: (token: string) => Promise<T>): Promise<T> {
|
|
21
|
+
const provider = getMessagingProvider('slack');
|
|
22
|
+
return withValidToken(provider.credentialService, fn);
|
|
23
|
+
}
|
|
@@ -1,8 +1,6 @@
|
|
|
1
1
|
import { addReaction } from '../../../../messaging/providers/slack/client.js';
|
|
2
|
-
import { getMessagingProvider } from '../../../../messaging/registry.js';
|
|
3
|
-
import { withValidToken } from '../../../../security/token-manager.js';
|
|
4
2
|
import type { ToolContext, ToolExecutionResult } from '../../../../tools/types.js';
|
|
5
|
-
import { err,ok } from './shared.js';
|
|
3
|
+
import { err, ok, withSlackToken } from './shared.js';
|
|
6
4
|
|
|
7
5
|
export async function run(input: Record<string, unknown>, _context: ToolContext): Promise<ToolExecutionResult> {
|
|
8
6
|
const channel = input.channel as string;
|
|
@@ -14,8 +12,7 @@ export async function run(input: Record<string, unknown>, _context: ToolContext)
|
|
|
14
12
|
}
|
|
15
13
|
|
|
16
14
|
try {
|
|
17
|
-
|
|
18
|
-
return withValidToken(provider.credentialService, async (token) => {
|
|
15
|
+
return withSlackToken(async (token) => {
|
|
19
16
|
await addReaction(token, channel, timestamp, emoji);
|
|
20
17
|
return ok(`Added :${emoji}: reaction.`);
|
|
21
18
|
});
|
|
@@ -0,0 +1,33 @@
|
|
|
1
|
+
import * as slack from '../../../../messaging/providers/slack/client.js';
|
|
2
|
+
import type { ToolContext, ToolExecutionResult } from '../../../../tools/types.js';
|
|
3
|
+
import { err, ok, withSlackToken } from './shared.js';
|
|
4
|
+
|
|
5
|
+
export async function run(input: Record<string, unknown>, _context: ToolContext): Promise<ToolExecutionResult> {
|
|
6
|
+
const channelId = input.channel_id as string;
|
|
7
|
+
|
|
8
|
+
if (!channelId) {
|
|
9
|
+
return err('channel_id is required.');
|
|
10
|
+
}
|
|
11
|
+
|
|
12
|
+
try {
|
|
13
|
+
return withSlackToken(async (token) => {
|
|
14
|
+
const resp = await slack.conversationInfo(token, channelId);
|
|
15
|
+
const conv = resp.channel;
|
|
16
|
+
|
|
17
|
+
const result = {
|
|
18
|
+
channelId: conv.id,
|
|
19
|
+
name: conv.name ?? conv.id,
|
|
20
|
+
topic: conv.topic?.value || null,
|
|
21
|
+
purpose: conv.purpose?.value || null,
|
|
22
|
+
isPrivate: conv.is_private ?? conv.is_group ?? false,
|
|
23
|
+
isArchived: conv.is_archived ?? false,
|
|
24
|
+
memberCount: conv.num_members ?? null,
|
|
25
|
+
latestActivityTs: conv.latest?.ts ?? null,
|
|
26
|
+
};
|
|
27
|
+
|
|
28
|
+
return ok(JSON.stringify(result, null, 2));
|
|
29
|
+
});
|
|
30
|
+
} catch (e) {
|
|
31
|
+
return err(e instanceof Error ? e.message : String(e));
|
|
32
|
+
}
|
|
33
|
+
}
|
|
@@ -0,0 +1,75 @@
|
|
|
1
|
+
import { getConfig, saveConfig } from '../../../../config/loader.js';
|
|
2
|
+
import type { ToolContext, ToolExecutionResult } from '../../../../tools/types.js';
|
|
3
|
+
import { err, ok } from './shared.js';
|
|
4
|
+
|
|
5
|
+
function getPreferredChannels(): string[] {
|
|
6
|
+
const config = getConfig();
|
|
7
|
+
const channels = config.skills?.entries?.slack?.config?.preferredChannels;
|
|
8
|
+
return Array.isArray(channels) ? channels as string[] : [];
|
|
9
|
+
}
|
|
10
|
+
|
|
11
|
+
function setPreferredChannels(channels: string[]): void {
|
|
12
|
+
const config = getConfig();
|
|
13
|
+
if (!config.skills) config.skills = {} as typeof config.skills;
|
|
14
|
+
if (!config.skills.entries) config.skills.entries = {};
|
|
15
|
+
if (!config.skills.entries.slack) config.skills.entries.slack = { enabled: true };
|
|
16
|
+
if (!config.skills.entries.slack.config) config.skills.entries.slack.config = {};
|
|
17
|
+
config.skills.entries.slack.config.preferredChannels = channels;
|
|
18
|
+
saveConfig(config);
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
export async function run(input: Record<string, unknown>, _context: ToolContext): Promise<ToolExecutionResult> {
|
|
22
|
+
const action = input.action as string;
|
|
23
|
+
const channelIds = input.channel_ids as string[] | undefined;
|
|
24
|
+
|
|
25
|
+
if (!action) {
|
|
26
|
+
return err('action is required (list, add, remove, or set).');
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
try {
|
|
30
|
+
switch (action) {
|
|
31
|
+
case 'list': {
|
|
32
|
+
const channels = getPreferredChannels();
|
|
33
|
+
if (channels.length === 0) {
|
|
34
|
+
return ok('No preferred channels configured. Use action "set" or "add" to configure channels.');
|
|
35
|
+
}
|
|
36
|
+
return ok(JSON.stringify({ preferredChannels: channels }, null, 2));
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
case 'add': {
|
|
40
|
+
if (!channelIds?.length) {
|
|
41
|
+
return err('channel_ids required for "add" action.');
|
|
42
|
+
}
|
|
43
|
+
const current = getPreferredChannels();
|
|
44
|
+
const merged = [...new Set([...current, ...channelIds])];
|
|
45
|
+
setPreferredChannels(merged);
|
|
46
|
+
return ok(JSON.stringify({ preferredChannels: merged, added: channelIds.filter((id) => !current.includes(id)) }, null, 2));
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
case 'remove': {
|
|
50
|
+
if (!channelIds?.length) {
|
|
51
|
+
return err('channel_ids required for "remove" action.');
|
|
52
|
+
}
|
|
53
|
+
const current = getPreferredChannels();
|
|
54
|
+
const removeSet = new Set(channelIds);
|
|
55
|
+
const remaining = current.filter((id) => !removeSet.has(id));
|
|
56
|
+
setPreferredChannels(remaining);
|
|
57
|
+
return ok(JSON.stringify({ preferredChannels: remaining, removed: channelIds.filter((id) => current.includes(id)) }, null, 2));
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
case 'set': {
|
|
61
|
+
if (!channelIds) {
|
|
62
|
+
return err('channel_ids required for "set" action.');
|
|
63
|
+
}
|
|
64
|
+
const unique = [...new Set(channelIds)];
|
|
65
|
+
setPreferredChannels(unique);
|
|
66
|
+
return ok(JSON.stringify({ preferredChannels: unique }, null, 2));
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
default:
|
|
70
|
+
return err(`Unknown action "${action}". Use list, add, remove, or set.`);
|
|
71
|
+
}
|
|
72
|
+
} catch (e) {
|
|
73
|
+
return err(e instanceof Error ? e.message : String(e));
|
|
74
|
+
}
|
|
75
|
+
}
|
|
@@ -1,8 +1,6 @@
|
|
|
1
1
|
import { deleteMessage } from '../../../../messaging/providers/slack/client.js';
|
|
2
|
-
import { getMessagingProvider } from '../../../../messaging/registry.js';
|
|
3
|
-
import { withValidToken } from '../../../../security/token-manager.js';
|
|
4
2
|
import type { ToolContext, ToolExecutionResult } from '../../../../tools/types.js';
|
|
5
|
-
import { err, ok } from './shared.js';
|
|
3
|
+
import { err, ok, withSlackToken } from './shared.js';
|
|
6
4
|
|
|
7
5
|
export async function run(input: Record<string, unknown>, _context: ToolContext): Promise<ToolExecutionResult> {
|
|
8
6
|
const channel = input.channel as string;
|
|
@@ -13,8 +11,7 @@ export async function run(input: Record<string, unknown>, _context: ToolContext)
|
|
|
13
11
|
}
|
|
14
12
|
|
|
15
13
|
try {
|
|
16
|
-
|
|
17
|
-
return withValidToken(provider.credentialService, async (token) => {
|
|
14
|
+
return withSlackToken(async (token) => {
|
|
18
15
|
await deleteMessage(token, channel, timestamp);
|
|
19
16
|
return ok(`Message deleted.`);
|
|
20
17
|
});
|
|
@@ -1,8 +1,6 @@
|
|
|
1
1
|
import { leaveConversation } from '../../../../messaging/providers/slack/client.js';
|
|
2
|
-
import { getMessagingProvider } from '../../../../messaging/registry.js';
|
|
3
|
-
import { withValidToken } from '../../../../security/token-manager.js';
|
|
4
2
|
import type { ToolContext, ToolExecutionResult } from '../../../../tools/types.js';
|
|
5
|
-
import { err,ok } from './shared.js';
|
|
3
|
+
import { err, ok, withSlackToken } from './shared.js';
|
|
6
4
|
|
|
7
5
|
export async function run(input: Record<string, unknown>, _context: ToolContext): Promise<ToolExecutionResult> {
|
|
8
6
|
const channel = input.channel as string;
|
|
@@ -12,8 +10,7 @@ export async function run(input: Record<string, unknown>, _context: ToolContext)
|
|
|
12
10
|
}
|
|
13
11
|
|
|
14
12
|
try {
|
|
15
|
-
|
|
16
|
-
return withValidToken(provider.credentialService, async (token) => {
|
|
13
|
+
return withSlackToken(async (token) => {
|
|
17
14
|
await leaveConversation(token, channel);
|
|
18
15
|
return ok('Left channel.');
|
|
19
16
|
});
|
|
@@ -0,0 +1,193 @@
|
|
|
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';
|
|
6
|
+
|
|
7
|
+
interface ThreadSummary {
|
|
8
|
+
threadTs: string;
|
|
9
|
+
previewText: string;
|
|
10
|
+
replyCount: number;
|
|
11
|
+
participants: string[];
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
interface ChannelDigest {
|
|
15
|
+
channelId: string;
|
|
16
|
+
channelName: string;
|
|
17
|
+
isPrivate: boolean;
|
|
18
|
+
messageCount: number;
|
|
19
|
+
topThreads: ThreadSummary[];
|
|
20
|
+
keyParticipants: string[];
|
|
21
|
+
error?: string;
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
const userNameCache = new Map<string, string>();
|
|
25
|
+
|
|
26
|
+
async function resolveUserName(token: string, userId: string): Promise<string> {
|
|
27
|
+
if (!userId) return 'unknown';
|
|
28
|
+
const cached = userNameCache.get(userId);
|
|
29
|
+
if (cached) return cached;
|
|
30
|
+
|
|
31
|
+
try {
|
|
32
|
+
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;
|
|
37
|
+
userNameCache.set(userId, name);
|
|
38
|
+
return name;
|
|
39
|
+
} catch {
|
|
40
|
+
return userId;
|
|
41
|
+
}
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
async function scanChannel(
|
|
45
|
+
token: string,
|
|
46
|
+
conv: SlackConversation,
|
|
47
|
+
oldestTs: string,
|
|
48
|
+
includeThreads: boolean,
|
|
49
|
+
): Promise<ChannelDigest> {
|
|
50
|
+
const channelId = conv.id;
|
|
51
|
+
const channelName = conv.name ?? channelId;
|
|
52
|
+
const isPrivate = conv.is_private ?? conv.is_group ?? false;
|
|
53
|
+
|
|
54
|
+
try {
|
|
55
|
+
const history = await slack.conversationHistory(token, channelId, 100, undefined, oldestTs);
|
|
56
|
+
const messages = history.messages;
|
|
57
|
+
|
|
58
|
+
const participantIds = new Set<string>();
|
|
59
|
+
for (const msg of messages) {
|
|
60
|
+
if (msg.user) participantIds.add(msg.user);
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
const keyParticipants: string[] = [];
|
|
64
|
+
for (const uid of participantIds) {
|
|
65
|
+
keyParticipants.push(await resolveUserName(token, uid));
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
const threadMessages = messages
|
|
69
|
+
.filter((m) => (m.reply_count ?? 0) > 0)
|
|
70
|
+
.sort((a, b) => (b.reply_count ?? 0) - (a.reply_count ?? 0))
|
|
71
|
+
.slice(0, 3);
|
|
72
|
+
|
|
73
|
+
const topThreads: ThreadSummary[] = [];
|
|
74
|
+
for (const msg of threadMessages) {
|
|
75
|
+
let participants: string[] = [];
|
|
76
|
+
|
|
77
|
+
if (includeThreads) {
|
|
78
|
+
try {
|
|
79
|
+
const replies = await slack.conversationReplies(token, channelId, msg.ts, 10);
|
|
80
|
+
const threadParticipantIds = new Set<string>();
|
|
81
|
+
for (const reply of replies.messages) {
|
|
82
|
+
if (reply.user) threadParticipantIds.add(reply.user);
|
|
83
|
+
}
|
|
84
|
+
for (const uid of threadParticipantIds) {
|
|
85
|
+
participants.push(await resolveUserName(token, uid));
|
|
86
|
+
}
|
|
87
|
+
} catch {
|
|
88
|
+
participants = [await resolveUserName(token, msg.user ?? '')];
|
|
89
|
+
}
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
topThreads.push({
|
|
93
|
+
threadTs: msg.ts,
|
|
94
|
+
previewText: truncate(msg.text, 150),
|
|
95
|
+
replyCount: msg.reply_count ?? 0,
|
|
96
|
+
participants,
|
|
97
|
+
});
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
return {
|
|
101
|
+
channelId,
|
|
102
|
+
channelName,
|
|
103
|
+
isPrivate,
|
|
104
|
+
messageCount: messages.length,
|
|
105
|
+
topThreads,
|
|
106
|
+
keyParticipants,
|
|
107
|
+
};
|
|
108
|
+
} catch (e) {
|
|
109
|
+
return {
|
|
110
|
+
channelId,
|
|
111
|
+
channelName,
|
|
112
|
+
isPrivate,
|
|
113
|
+
messageCount: 0,
|
|
114
|
+
topThreads: [],
|
|
115
|
+
keyParticipants: [],
|
|
116
|
+
error: e instanceof Error ? e.message : String(e),
|
|
117
|
+
};
|
|
118
|
+
}
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
function truncate(text: string, maxLen: number): string {
|
|
122
|
+
if (text.length <= maxLen) return text;
|
|
123
|
+
return text.slice(0, maxLen - 3) + '...';
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
export async function run(input: Record<string, unknown>, _context: ToolContext): Promise<ToolExecutionResult> {
|
|
127
|
+
const channelIds = input.channel_ids as string[] | undefined;
|
|
128
|
+
const hoursBack = (input.hours_back as number) ?? 24;
|
|
129
|
+
const includeThreads = (input.include_threads as boolean) ?? true;
|
|
130
|
+
const maxChannels = (input.max_channels as number) ?? 20;
|
|
131
|
+
|
|
132
|
+
try {
|
|
133
|
+
return withSlackToken(async (token) => {
|
|
134
|
+
const oldestTs = String((Date.now() - hoursBack * 60 * 60 * 1000) / 1000);
|
|
135
|
+
|
|
136
|
+
let channelsToScan: SlackConversation[];
|
|
137
|
+
|
|
138
|
+
if (channelIds?.length) {
|
|
139
|
+
const results = await Promise.allSettled(
|
|
140
|
+
channelIds.map((id) => slack.conversationInfo(token, id)),
|
|
141
|
+
);
|
|
142
|
+
channelsToScan = results
|
|
143
|
+
.filter((r): r is PromiseFulfilledResult<Awaited<ReturnType<typeof slack.conversationInfo>>> => r.status === 'fulfilled')
|
|
144
|
+
.map((r) => r.value.channel);
|
|
145
|
+
} else {
|
|
146
|
+
const config = getConfig();
|
|
147
|
+
const preferredIds = config.skills?.entries?.slack?.config?.preferredChannels as string[] | undefined;
|
|
148
|
+
|
|
149
|
+
if (preferredIds?.length) {
|
|
150
|
+
const results = await Promise.allSettled(
|
|
151
|
+
preferredIds.map((id) => slack.conversationInfo(token, id)),
|
|
152
|
+
);
|
|
153
|
+
channelsToScan = results
|
|
154
|
+
.filter((r): r is PromiseFulfilledResult<Awaited<ReturnType<typeof slack.conversationInfo>>> => r.status === 'fulfilled')
|
|
155
|
+
.map((r) => r.value.channel);
|
|
156
|
+
} else {
|
|
157
|
+
const resp = await slack.listConversations(token, 'public_channel,private_channel', true, 200);
|
|
158
|
+
channelsToScan = resp.channels
|
|
159
|
+
.filter((c) => c.is_member)
|
|
160
|
+
.sort((a, b) => {
|
|
161
|
+
const aTs = a.latest?.ts ? parseFloat(a.latest.ts) : 0;
|
|
162
|
+
const bTs = b.latest?.ts ? parseFloat(b.latest.ts) : 0;
|
|
163
|
+
return bTs - aTs;
|
|
164
|
+
})
|
|
165
|
+
.slice(0, maxChannels);
|
|
166
|
+
}
|
|
167
|
+
}
|
|
168
|
+
|
|
169
|
+
const scanResults = await Promise.allSettled(
|
|
170
|
+
channelsToScan.map((conv) => scanChannel(token, conv, oldestTs, includeThreads)),
|
|
171
|
+
);
|
|
172
|
+
|
|
173
|
+
const digests: ChannelDigest[] = scanResults
|
|
174
|
+
.filter((r): r is PromiseFulfilledResult<ChannelDigest> => r.status === 'fulfilled')
|
|
175
|
+
.map((r) => r.value)
|
|
176
|
+
.filter((d) => d.messageCount > 0 || d.error);
|
|
177
|
+
|
|
178
|
+
const skippedCount = scanResults.filter((r) => r.status === 'rejected').length;
|
|
179
|
+
|
|
180
|
+
const result = {
|
|
181
|
+
scannedChannels: digests.length,
|
|
182
|
+
totalChannelsAttempted: channelsToScan.length,
|
|
183
|
+
skippedDueToErrors: skippedCount,
|
|
184
|
+
hoursBack,
|
|
185
|
+
channels: digests,
|
|
186
|
+
};
|
|
187
|
+
|
|
188
|
+
return ok(JSON.stringify(result, null, 2));
|
|
189
|
+
});
|
|
190
|
+
} catch (e) {
|
|
191
|
+
return err(e instanceof Error ? e.message : String(e));
|
|
192
|
+
}
|
|
193
|
+
}
|
|
@@ -122,8 +122,6 @@ import * as sequenceList from "./bundled-skills/messaging/tools/sequence-list.js
|
|
|
122
122
|
import * as sequencePause from "./bundled-skills/messaging/tools/sequence-pause.js";
|
|
123
123
|
import * as sequenceResume from "./bundled-skills/messaging/tools/sequence-resume.js";
|
|
124
124
|
import * as sequenceUpdate from "./bundled-skills/messaging/tools/sequence-update.js";
|
|
125
|
-
import * as slackAddReaction from "./bundled-skills/messaging/tools/slack-add-reaction.js";
|
|
126
|
-
import * as slackLeaveChannel from "./bundled-skills/messaging/tools/slack-leave-channel.js";
|
|
127
125
|
// ── notifications ───────────────────────────────────────────────────────────
|
|
128
126
|
import * as sendNotification from "./bundled-skills/notifications/tools/send-notification.js";
|
|
129
127
|
// ── phone-calls ─────────────────────────────────────────────────────────────
|
|
@@ -144,6 +142,13 @@ import * as scheduleCreate from "./bundled-skills/schedule/tools/schedule-create
|
|
|
144
142
|
import * as scheduleDelete from "./bundled-skills/schedule/tools/schedule-delete.js";
|
|
145
143
|
import * as scheduleList from "./bundled-skills/schedule/tools/schedule-list.js";
|
|
146
144
|
import * as scheduleUpdate from "./bundled-skills/schedule/tools/schedule-update.js";
|
|
145
|
+
// ── slack ────────────────────────────────────────────────────────────────────
|
|
146
|
+
import * as slackAddReaction from "./bundled-skills/slack/tools/slack-add-reaction.js";
|
|
147
|
+
import * as slackChannelDetails from "./bundled-skills/slack/tools/slack-channel-details.js";
|
|
148
|
+
import * as slackConfigureChannels from "./bundled-skills/slack/tools/slack-configure-channels.js";
|
|
149
|
+
import * as slackDeleteMessage from "./bundled-skills/slack/tools/slack-delete-message.js";
|
|
150
|
+
import * as slackLeaveChannel from "./bundled-skills/slack/tools/slack-leave-channel.js";
|
|
151
|
+
import * as slackScanDigest from "./bundled-skills/slack/tools/slack-scan-digest.js";
|
|
147
152
|
import * as subagentAbort from "./bundled-skills/subagent/tools/subagent-abort.js";
|
|
148
153
|
import * as subagentMessage from "./bundled-skills/subagent/tools/subagent-message.js";
|
|
149
154
|
import * as subagentRead from "./bundled-skills/subagent/tools/subagent-read.js";
|
|
@@ -271,8 +276,6 @@ export const bundledToolRegistry = new Map<string, SkillToolScript>([
|
|
|
271
276
|
["messaging:tools/messaging-send.ts", messagingSend],
|
|
272
277
|
["messaging:tools/messaging-reply.ts", messagingReply],
|
|
273
278
|
["messaging:tools/messaging-mark-read.ts", messagingMarkRead],
|
|
274
|
-
["messaging:tools/slack-add-reaction.ts", slackAddReaction],
|
|
275
|
-
["messaging:tools/slack-leave-channel.ts", slackLeaveChannel],
|
|
276
279
|
["messaging:tools/messaging-analyze-activity.ts", messagingAnalyzeActivity],
|
|
277
280
|
["messaging:tools/messaging-analyze-style.ts", messagingAnalyzeStyle],
|
|
278
281
|
["messaging:tools/messaging-draft.ts", messagingDraft],
|
|
@@ -333,6 +336,14 @@ export const bundledToolRegistry = new Map<string, SkillToolScript>([
|
|
|
333
336
|
["schedule:tools/schedule-update.ts", scheduleUpdate],
|
|
334
337
|
["schedule:tools/schedule-delete.ts", scheduleDelete],
|
|
335
338
|
|
|
339
|
+
// slack
|
|
340
|
+
["slack:tools/slack-scan-digest.ts", slackScanDigest],
|
|
341
|
+
["slack:tools/slack-channel-details.ts", slackChannelDetails],
|
|
342
|
+
["slack:tools/slack-configure-channels.ts", slackConfigureChannels],
|
|
343
|
+
["slack:tools/slack-add-reaction.ts", slackAddReaction],
|
|
344
|
+
["slack:tools/slack-delete-message.ts", slackDeleteMessage],
|
|
345
|
+
["slack:tools/slack-leave-channel.ts", slackLeaveChannel],
|
|
346
|
+
|
|
336
347
|
// subagent
|
|
337
348
|
["subagent:tools/subagent-spawn.ts", subagentSpawn],
|
|
338
349
|
["subagent:tools/subagent-status.ts", subagentStatus],
|
package/src/config/schema.ts
CHANGED
|
@@ -205,7 +205,7 @@ export const AssistantConfigSchema = z.object({
|
|
|
205
205
|
.number({ error: 'maxToolUseTurns must be a number' })
|
|
206
206
|
.int('maxToolUseTurns must be an integer')
|
|
207
207
|
.nonnegative('maxToolUseTurns must be a non-negative integer')
|
|
208
|
-
.default(
|
|
208
|
+
.default(40),
|
|
209
209
|
thinking: ThinkingConfigSchema.default(ThinkingConfigSchema.parse({})),
|
|
210
210
|
contextWindow: ContextWindowConfigSchema.default(ContextWindowConfigSchema.parse({})),
|
|
211
211
|
memory: MemoryConfigSchema.default(MemoryConfigSchema.parse({})),
|
|
@@ -185,12 +185,13 @@ export function handleThinkingDelta(
|
|
|
185
185
|
if (!state.firstThinkingDeltaEmitted) {
|
|
186
186
|
state.firstThinkingDeltaEmitted = true;
|
|
187
187
|
const lastToolName = state.lastCompletedToolName;
|
|
188
|
-
// Only emit activity state when
|
|
189
|
-
// When
|
|
190
|
-
// confirmation_resolved),
|
|
191
|
-
// the client
|
|
192
|
-
//
|
|
193
|
-
//
|
|
188
|
+
// Only emit an activity state when a tool just completed, so we can
|
|
189
|
+
// show "Processing <tool> results". When no tool has completed yet
|
|
190
|
+
// (e.g. right after confirmation_resolved), skip the emission entirely
|
|
191
|
+
// so the client preserves its current status text (e.g. "Resuming
|
|
192
|
+
// after approval"). Even omitting statusText from the message would
|
|
193
|
+
// cause the client to clear it, since the client overwrites
|
|
194
|
+
// assistantStatusText for every assistant_activity_state event.
|
|
194
195
|
if (lastToolName) {
|
|
195
196
|
const statusText = `Processing ${friendlyToolName(lastToolName)} results`;
|
|
196
197
|
deps.ctx.emitActivityState('thinking', 'thinking_delta', 'assistant_turn', deps.reqId, statusText);
|
|
@@ -10,6 +10,7 @@ export interface Conversation {
|
|
|
10
10
|
memberCount?: number;
|
|
11
11
|
topic?: string;
|
|
12
12
|
isArchived?: boolean;
|
|
13
|
+
isPrivate?: boolean;
|
|
13
14
|
metadata?: Record<string, unknown>;
|
|
14
15
|
}
|
|
15
16
|
|
|
@@ -101,6 +102,8 @@ export interface SenderDigestResult {
|
|
|
101
102
|
queryUsed: string;
|
|
102
103
|
/** True when pagination was stopped because the scan cap was reached but more messages exist. */
|
|
103
104
|
truncated?: boolean;
|
|
105
|
+
/** Resume token for sequential scanning — present when truncated is true. */
|
|
106
|
+
nextPageToken?: string;
|
|
104
107
|
}
|
|
105
108
|
|
|
106
109
|
export interface ArchiveResult {
|
|
@@ -41,7 +41,7 @@ export interface MessagingProvider {
|
|
|
41
41
|
markRead?(token: string, conversationId: string, messageId?: string): Promise<void>;
|
|
42
42
|
|
|
43
43
|
/** Scan messages and group by sender for bulk cleanup (e.g. newsletter decluttering). */
|
|
44
|
-
senderDigest?(token: string, query: string, options?: { maxMessages?: number; maxSenders?: number }): Promise<SenderDigestResult>;
|
|
44
|
+
senderDigest?(token: string, query: string, options?: { maxMessages?: number; maxSenders?: number; pageToken?: string }): Promise<SenderDigestResult>;
|
|
45
45
|
/** Archive messages matching a search query. */
|
|
46
46
|
archiveByQuery?(token: string, query: string): Promise<ArchiveResult>;
|
|
47
47
|
|
|
@@ -195,13 +195,13 @@ export const gmailMessagingProvider: MessagingProvider = {
|
|
|
195
195
|
await gmail.modifyMessage(token, messageId, { removeLabelIds: ['UNREAD'] });
|
|
196
196
|
},
|
|
197
197
|
|
|
198
|
-
async senderDigest(token: string, query: string, options?: { maxMessages?: number; maxSenders?: number }): Promise<SenderDigestResult> {
|
|
198
|
+
async senderDigest(token: string, query: string, options?: { maxMessages?: number; maxSenders?: number; pageToken?: string }): Promise<SenderDigestResult> {
|
|
199
199
|
const maxMessages = Math.min(options?.maxMessages ?? 2000, 2000);
|
|
200
200
|
const maxSenders = options?.maxSenders ?? 30;
|
|
201
201
|
const maxIdsPerSender = 2000;
|
|
202
202
|
|
|
203
203
|
const allMessageIds: string[] = [];
|
|
204
|
-
let pageToken: string | undefined;
|
|
204
|
+
let pageToken: string | undefined = options?.pageToken;
|
|
205
205
|
let truncated = false;
|
|
206
206
|
|
|
207
207
|
while (allMessageIds.length < maxMessages) {
|
|
@@ -289,7 +289,7 @@ export const gmailMessagingProvider: MessagingProvider = {
|
|
|
289
289
|
hasMore: s.hasMore,
|
|
290
290
|
}));
|
|
291
291
|
|
|
292
|
-
return { senders, totalScanned: allMessageIds.length, queryUsed: query, ...(truncated ? { truncated } : {}) };
|
|
292
|
+
return { senders, totalScanned: allMessageIds.length, queryUsed: query, ...(truncated ? { truncated, nextPageToken: pageToken } : {}) };
|
|
293
293
|
},
|
|
294
294
|
|
|
295
295
|
async archiveByQuery(token: string, query: string): Promise<ArchiveResult> {
|
|
@@ -60,6 +60,7 @@ function mapConversation(conv: SlackConversation): Conversation {
|
|
|
60
60
|
memberCount: conv.num_members,
|
|
61
61
|
topic: conv.topic?.value || undefined,
|
|
62
62
|
isArchived: conv.is_archived,
|
|
63
|
+
isPrivate: conv.is_private ?? conv.is_group ?? false,
|
|
63
64
|
metadata: conv.is_im ? { dmUserId: conv.user } : undefined,
|
|
64
65
|
};
|
|
65
66
|
}
|
|
@@ -10,6 +10,7 @@ import type {
|
|
|
10
10
|
SlackAuthTestResponse,
|
|
11
11
|
SlackChatDeleteResponse,
|
|
12
12
|
SlackConversationHistoryResponse,
|
|
13
|
+
SlackConversationInfoResponse,
|
|
13
14
|
SlackConversationLeaveResponse,
|
|
14
15
|
SlackConversationMarkResponse,
|
|
15
16
|
SlackConversationRepliesResponse,
|
|
@@ -95,6 +96,13 @@ export async function listConversations(
|
|
|
95
96
|
});
|
|
96
97
|
}
|
|
97
98
|
|
|
99
|
+
export async function conversationInfo(
|
|
100
|
+
token: string,
|
|
101
|
+
channel: string,
|
|
102
|
+
): Promise<SlackConversationInfoResponse> {
|
|
103
|
+
return request<SlackConversationInfoResponse>(token, 'conversations.info', { channel });
|
|
104
|
+
}
|
|
105
|
+
|
|
98
106
|
export async function conversationHistory(
|
|
99
107
|
token: string,
|
|
100
108
|
channel: string,
|
|
@@ -20,6 +20,7 @@ export interface SlackConversation {
|
|
|
20
20
|
is_group?: boolean;
|
|
21
21
|
is_im?: boolean;
|
|
22
22
|
is_mpim?: boolean;
|
|
23
|
+
is_private?: boolean;
|
|
23
24
|
is_archived?: boolean;
|
|
24
25
|
is_member?: boolean;
|
|
25
26
|
topic?: { value: string };
|
|
@@ -108,6 +109,10 @@ export interface SlackSearchMatch {
|
|
|
108
109
|
thread_ts?: string;
|
|
109
110
|
}
|
|
110
111
|
|
|
112
|
+
export interface SlackConversationInfoResponse extends SlackApiResponse {
|
|
113
|
+
channel: SlackConversation;
|
|
114
|
+
}
|
|
115
|
+
|
|
111
116
|
export interface SlackConversationsOpenResponse extends SlackApiResponse {
|
|
112
117
|
channel: { id: string };
|
|
113
118
|
}
|