@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.
Files changed (26) hide show
  1. package/package.json +1 -1
  2. package/src/__tests__/slack-skill.test.ts +124 -0
  3. package/src/agent/loop.ts +1 -1
  4. package/src/config/bundled-skills/messaging/TOOLS.json +12 -90
  5. package/src/config/bundled-skills/messaging/tools/gmail-outreach-scan.ts +8 -1
  6. package/src/config/bundled-skills/messaging/tools/gmail-sender-digest.ts +3 -2
  7. package/src/config/bundled-skills/messaging/tools/messaging-sender-digest.ts +4 -2
  8. package/src/config/bundled-skills/skills-catalog/SKILL.md +1 -1
  9. package/src/config/bundled-skills/slack/SKILL.md +49 -0
  10. package/src/config/bundled-skills/slack/TOOLS.json +167 -0
  11. package/src/config/bundled-skills/slack/tools/shared.ts +23 -0
  12. package/src/config/bundled-skills/{messaging → slack}/tools/slack-add-reaction.ts +2 -5
  13. package/src/config/bundled-skills/slack/tools/slack-channel-details.ts +33 -0
  14. package/src/config/bundled-skills/slack/tools/slack-configure-channels.ts +75 -0
  15. package/src/config/bundled-skills/{messaging → slack}/tools/slack-delete-message.ts +2 -5
  16. package/src/config/bundled-skills/{messaging → slack}/tools/slack-leave-channel.ts +2 -5
  17. package/src/config/bundled-skills/slack/tools/slack-scan-digest.ts +193 -0
  18. package/src/config/bundled-tool-registry.ts +15 -4
  19. package/src/config/schema.ts +1 -1
  20. package/src/daemon/session-agent-loop-handlers.ts +7 -6
  21. package/src/messaging/provider-types.ts +3 -0
  22. package/src/messaging/provider.ts +1 -1
  23. package/src/messaging/providers/gmail/adapter.ts +3 -3
  24. package/src/messaging/providers/slack/adapter.ts +1 -0
  25. package/src/messaging/providers/slack/client.ts +8 -0
  26. package/src/messaging/providers/slack/types.ts +5 -0
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@vellumai/assistant",
3
- "version": "0.4.12",
3
+ "version": "0.4.13",
4
4
  "type": "module",
5
5
  "bin": {
6
6
  "vellum": "./src/index.ts"
@@ -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
@@ -41,7 +41,7 @@ export type AgentEvent =
41
41
 
42
42
  const DEFAULT_CONFIG: AgentLoopConfig = {
43
43
  maxTokens: 16000,
44
- maxToolUseTurns: 0,
44
+ maxToolUseTurns: 40,
45
45
  minTurnIntervalMs: 150,
46
46
  };
47
47
 
@@ -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 both bundled and community skills.
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
- const provider = getMessagingProvider('slack');
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
- const provider = getMessagingProvider('slack');
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
- const provider = getMessagingProvider('slack');
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],
@@ -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(0),
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 we have a tool name to report.
189
- // When lastCompletedToolName is undefined (e.g. right after
190
- // confirmation_resolved), emitting without statusText would clear
191
- // the client's current status (e.g. "Resuming after approval").
192
- // The phase is already 'thinking' from either message_dequeued or
193
- // confirmation_resolved, so skipping here is safe.
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
  }