nothumanallowed 15.1.35 → 15.1.37

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 CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "nothumanallowed",
3
- "version": "15.1.35",
3
+ "version": "15.1.37",
4
4
  "description": "NotHumanAllowed — 38 AI agents, 80 tools, Studio (visual agentic workflows). Email, calendar, browser automation, screen capture, canvas, cron/heartbeat, Alexandria E2E messaging, GitHub, Notion, Slack, voice chat, free AI (Liara), 28 languages. Zero-dependency CLI.",
5
5
  "type": "module",
6
6
  "bin": {
package/src/constants.mjs CHANGED
@@ -5,7 +5,7 @@ import { fileURLToPath } from 'url';
5
5
  const __filename = fileURLToPath(import.meta.url);
6
6
  const __dirname = path.dirname(__filename);
7
7
 
8
- export const VERSION = '15.1.35';
8
+ export const VERSION = '15.1.37';
9
9
  export const BASE_URL = 'https://nothumanallowed.com/cli';
10
10
  export const API_BASE = 'https://nothumanallowed.com/api/v1';
11
11
 
@@ -102,22 +102,87 @@ export function register(router) {
102
102
  } catch (e) { sendError(res, 500, e.message); }
103
103
  });
104
104
 
105
- // ── Slack ─────────────────────────────────────────────────────────────
105
+ // ── Slack (enterprise UI: rich channels, search, threads, reactions) ──
106
106
 
107
107
  router.get('/api/slack/channels', async (_req, res) => {
108
108
  try {
109
- const { listChannels } = await import('../../services/slack.mjs');
109
+ const { listChannelsRich, getWorkspaceInfo } = await import('../../services/slack.mjs');
110
110
  const config = loadConfig();
111
- sendJSON(res, 200, { channels: await listChannels(config) });
112
- } catch (e) { sendError(res, 500, e.message); }
111
+ const [channels, workspace] = await Promise.all([
112
+ listChannelsRich(config, { maxResults: 200 }),
113
+ getWorkspaceInfo(config),
114
+ ]);
115
+ sendJSON(res, 200, { channels, workspace: workspace?.name || '', workspaceIcon: workspace?.icon || '' });
116
+ } catch (e) { sendJSON(res, 200, { channels: [], error: e.message }); }
113
117
  });
114
118
 
115
119
  router.get('/api/slack/messages', async (req, res) => {
116
120
  try {
117
- const { getChannelMessages } = await import('../../services/slack.mjs');
121
+ const { listMessagesRich } = await import('../../services/slack.mjs');
118
122
  const config = loadConfig();
119
123
  const url = new URL(req.url, 'http://localhost');
120
- sendJSON(res, 200, { messages: await getChannelMessages(config, url.searchParams.get('channel')) });
124
+ const channel = url.searchParams.get('channel');
125
+ const limit = parseInt(url.searchParams.get('limit') || '50', 10);
126
+ if (!channel) return sendError(res, 400, 'channel param required');
127
+ sendJSON(res, 200, { messages: await listMessagesRich(config, channel, { limit }) });
128
+ } catch (e) { sendError(res, 500, e.message); }
129
+ });
130
+
131
+ router.get('/api/slack/thread', async (req, res) => {
132
+ try {
133
+ const { getThreadReplies } = await import('../../services/slack.mjs');
134
+ const config = loadConfig();
135
+ const url = new URL(req.url, 'http://localhost');
136
+ const channel = url.searchParams.get('channel');
137
+ const ts = url.searchParams.get('ts');
138
+ if (!channel || !ts) return sendError(res, 400, 'channel and ts required');
139
+ sendJSON(res, 200, { messages: await getThreadReplies(config, channel, ts) });
140
+ } catch (e) { sendError(res, 500, e.message); }
141
+ });
142
+
143
+ router.get('/api/slack/search', async (req, res) => {
144
+ try {
145
+ const { searchMessages } = await import('../../services/slack.mjs');
146
+ const config = loadConfig();
147
+ const url = new URL(req.url, 'http://localhost');
148
+ const q = url.searchParams.get('q') || '';
149
+ if (!q.trim()) return sendJSON(res, 200, { results: [] });
150
+ const count = parseInt(url.searchParams.get('count') || '20', 10);
151
+ sendJSON(res, 200, { results: await searchMessages(config, q, { count }) });
152
+ } catch (e) { sendJSON(res, 200, { results: [], error: e.message }); }
153
+ });
154
+
155
+ router.post('/api/slack/react', async (req, res) => {
156
+ try {
157
+ const { addReaction } = await import('../../services/slack.mjs');
158
+ const config = loadConfig();
159
+ const body = await parseBody(req);
160
+ if (!body.channel || !body.ts || !body.emoji) return sendError(res, 400, 'channel, ts, emoji required');
161
+ await addReaction(config, body.channel, body.ts, body.emoji);
162
+ sendJSON(res, 200, { ok: true });
163
+ } catch (e) { sendError(res, 500, e.message); }
164
+ });
165
+
166
+ router.post('/api/slack/mark-read', async (req, res) => {
167
+ try {
168
+ const { markRead } = await import('../../services/slack.mjs');
169
+ const config = loadConfig();
170
+ const body = await parseBody(req);
171
+ if (!body.channel) return sendError(res, 400, 'channel required');
172
+ await markRead(config, body.channel, body.ts);
173
+ sendJSON(res, 200, { ok: true });
174
+ } catch (e) { sendError(res, 500, e.message); }
175
+ });
176
+
177
+ router.post('/api/slack/dm', async (req, res) => {
178
+ try {
179
+ const { openDM, sendMessage } = await import('../../services/slack.mjs');
180
+ const config = loadConfig();
181
+ const body = await parseBody(req);
182
+ if (!body.user) return sendError(res, 400, 'user required (id or name or email)');
183
+ const channelId = await openDM(config, body.user);
184
+ if (body.text) await sendMessage(config, channelId, body.text);
185
+ sendJSON(res, 200, { ok: true, channelId });
121
186
  } catch (e) { sendError(res, 500, e.message); }
122
187
  });
123
188
 
@@ -308,7 +373,8 @@ export function register(router) {
308
373
  const body = await parseBody(req);
309
374
  const config = loadConfig();
310
375
  if (!body.channel || !body.text) return sendError(res, 400, 'channel and text required');
311
- await sendMessage(config, body.channel, body.text);
376
+ // threadTs optional — if provided, the message is posted as a thread reply
377
+ await sendMessage(config, body.channel, body.text, body.threadTs || null);
312
378
  sendJSON(res, 200, { ok: true });
313
379
  } catch (e) { sendError(res, 500, e.message); }
314
380
  });
@@ -530,6 +530,38 @@ class TelegramResponder {
530
530
  saveTelegramContext(this._lastContextByChatId);
531
531
  }
532
532
 
533
+ /**
534
+ * Route a fresh message to the best agent.
535
+ *
536
+ * Tier 1 — keyword table (fast, no LLM cost). Falls through to CONDUCTOR
537
+ * when nothing matches.
538
+ *
539
+ * Tier 2 — if Tier 1 returned the fallback CONDUCTOR AND the user has an
540
+ * LLM key configured, ask the LLM to pick the right agent given the
541
+ * conversation history. This rescues paraphrased queries like
542
+ * "mostrami quanto vale ora il metallo prezioso giallo" (no keyword match)
543
+ * which v13 used to route correctly because of a similar LLM router.
544
+ */
545
+ async _routeFreshMessage(text, lastCtx) {
546
+ const keywordAgent = routeMessage(text, this.autoRoute);
547
+ if (keywordAgent !== 'conductor') return keywordAgent;
548
+ // Tier 2 fallback
549
+ const hasKey = !!(this.config.llm?.apiKey || this.config.llm?.openaiKey || this.config.llm?.geminiKey || this.config.llm?.deepseekKey || (this.config.llm?.provider === 'nha'));
550
+ if (!hasKey) return keywordAgent;
551
+ try {
552
+ const AGENTS = ['herald', 'mercury', 'athena', 'oracle', 'forge', 'scheherazade', 'saber', 'sauron', 'conductor'];
553
+ const tail = (lastCtx?.conversationLog || []).slice(-6)
554
+ .map(t => `${t.role === 'user' ? 'User' : 'Bot'}: ${String(t.content).slice(0, 200)}`)
555
+ .join('\n');
556
+ const sys = 'You are a routing classifier. Reply with EXACTLY one lowercase agent name from this list and nothing else: ' + AGENTS.join(', ') + '. herald=calendar/email/weather/news, mercury=finance/markets/crypto, athena=audit/compliance, oracle=data/analytics, forge=code/architecture, scheherazade=writing/content, saber=security, sauron=monitoring, conductor=anything else.';
557
+ const usr = `Recent conversation (most recent last):\n${tail || '(none)'}\n\nUser message: "${text}"\n\nWhich agent? Reply with ONLY the lowercase agent name.`;
558
+ const ans = await callLLM(this.config, sys, usr, { max_tokens: 16 });
559
+ const picked = String(ans || '').trim().toLowerCase().split(/\s+/)[0];
560
+ if (AGENTS.includes(picked)) return picked;
561
+ } catch { /* LLM unavailable, keep keyword fallback */ }
562
+ return keywordAgent;
563
+ }
564
+
533
565
  get enabled() {
534
566
  return !!this.token;
535
567
  }
@@ -802,10 +834,25 @@ class TelegramResponder {
802
834
  let enrichedMessage = cleanText;
803
835
  let preHistory = null;
804
836
 
837
+ // ── Always inject multi-turn rolling history (15.1.36) ─────────────
838
+ // Even when the new message isn't a "continuation" of the very last
839
+ // turn, the user may still be referring to something earlier in the
840
+ // conversation ("come ti avevo detto ieri", "ricorda che..."). Pass
841
+ // the full conversation log to the agent as preHistory.
842
+ const rollingLog = (lastCtx && Array.isArray(lastCtx.conversationLog) && lastCtx.conversationLog.length > 0)
843
+ ? lastCtx.conversationLog
844
+ .filter(t => t && (t.role === 'user' || t.role === 'assistant') && typeof t.content === 'string')
845
+ .map(t => ({ role: t.role, content: t.content }))
846
+ : null;
847
+
805
848
  if (isContinuation && !lastWasCompleted) {
806
849
  // Continue with same agent and inject full history for context
807
850
  agent = lastCtx.agent;
808
- if (lastCtx.history && lastCtx.history.length > 0) {
851
+ // Prefer the rolling multi-turn log if available; fall back to the
852
+ // legacy single-turn `history` for older saved contexts.
853
+ if (rollingLog && rollingLog.length > 0) {
854
+ preHistory = rollingLog;
855
+ } else if (lastCtx.history && lastCtx.history.length > 0) {
809
856
  preHistory = lastCtx.history;
810
857
  }
811
858
 
@@ -870,9 +917,13 @@ class TelegramResponder {
870
917
 
871
918
  this.log(`[Telegram] ${fromUser}: continuation → ${agent.toUpperCase()} (ctx ${Math.round(stickyAge/1000)}s ago, history=${preHistory ? preHistory.length : 0})`);
872
919
  } else {
873
- // Fresh request — route normally
874
- agent = routeMessage(cleanText, this.autoRoute);
875
- this.log(`[Telegram] ${fromUser}: new request ${agent.toUpperCase()}${isVoice ? ' [voice]' : ''}${lastWasCompleted ? ' [prev completed]' : ''}`);
920
+ // Fresh request — route normally, but STILL pass the rolling log so
921
+ // the model has the conversation context even when the new message
922
+ // is on a fresh topic (e.g. user starts a new task but the previous
923
+ // session is still relevant for memory: names, preferences, etc.).
924
+ agent = await this._routeFreshMessage(cleanText, lastCtx);
925
+ if (rollingLog && rollingLog.length > 0) preHistory = rollingLog;
926
+ this.log(`[Telegram] ${fromUser}: new request → ${agent.toUpperCase()}${isVoice ? ' [voice]' : ''}${lastWasCompleted ? ' [prev completed]' : ''} (rolling history=${preHistory ? preHistory.length : 0})`);
876
927
  }
877
928
 
878
929
  // Broadcast event
@@ -906,12 +957,25 @@ class TelegramResponder {
906
957
  ? responseText.slice(0, 3950) + '\n\n... [truncated]'
907
958
  : responseText;
908
959
 
909
- // Save context if action was completed, mark it so next turn starts fresh
960
+ // ── Multi-turn rolling memory (15.1.36) ────────────────────────────
961
+ // v13 worked well partially because every chat retained a real
962
+ // conversation history. The v14 refactor reduced this to 1 turn —
963
+ // hence "Telegram doesn't understand me anymore". We now keep the
964
+ // last MAX_CONVERSATION_TURNS turns per chat, persisted to disk.
965
+ const MAX_CONVERSATION_TURNS = 20; // user+assistant pairs
966
+ const prevLog = (lastCtx && Array.isArray(lastCtx.conversationLog)) ? lastCtx.conversationLog : [];
967
+ const conversationLog = [
968
+ ...prevLog,
969
+ { role: 'user', content: cleanText, ts: Date.now() },
970
+ { role: 'assistant', content: responseText, ts: Date.now() },
971
+ ].slice(-MAX_CONVERSATION_TURNS * 2);
972
+
910
973
  this._lastContextByChatId[chatId] = {
911
974
  agent,
912
975
  userMsg: cleanText,
913
976
  agentReply: responseText,
914
- history: isCompletedAction(responseText) ? null : responseHistory, // clear history after success
977
+ history: isCompletedAction(responseText) ? null : responseHistory, // single-turn (legacy)
978
+ conversationLog, // multi-turn (new)
915
979
  ts: Date.now(),
916
980
  };
917
981
  this._lastAgentByChatId[chatId] = agent;
@@ -127,19 +127,181 @@ export async function listMessages(config, channel, maxResults = 15) {
127
127
  }
128
128
 
129
129
  /**
130
- * Send a message to a channel.
131
- * @returns {Promise<string>} formatted result
130
+ * Send a message to a channel — supports thread replies via threadTs.
131
+ * @returns {Promise<string>} formatted result with the new message ts
132
132
  */
133
- export async function sendMessage(config, channel, text) {
133
+ export async function sendMessage(config, channel, text, threadTs = null) {
134
134
  if (!channel) return 'Channel name or ID required.';
135
135
  if (!text) return 'Message text required.';
136
136
 
137
137
  const channelId = await resolveChannelId(config, channel);
138
138
 
139
- await slackFetch(config, 'chat.postMessage', {
140
- channel: channelId,
141
- text,
139
+ const params = { channel: channelId, text };
140
+ if (threadTs) params.thread_ts = threadTs;
141
+
142
+ const res = await slackFetch(config, 'chat.postMessage', params);
143
+
144
+ return `Message sent to #${channel}${threadTs ? ' (thread reply)' : ''}. ts=${res.ts}`;
145
+ }
146
+
147
+ // ── Advanced operations (15.1.37) ───────────────────────────────────────────
148
+
149
+ /**
150
+ * Cache of user_id → display info to avoid re-fetching on every list call.
151
+ * Cleared when the responder restarts. TTL implicit per process lifetime.
152
+ */
153
+ const _userCache = new Map();
154
+
155
+ async function _resolveUsers(config, userIds) {
156
+ const unknown = [...new Set(userIds)].filter(u => u && !_userCache.has(u));
157
+ for (const uid of unknown.slice(0, 30)) {
158
+ try {
159
+ const u = await slackFetch(config, 'users.info', { user: uid });
160
+ _userCache.set(uid, {
161
+ id: uid,
162
+ name: u.user?.real_name || u.user?.name || uid,
163
+ avatar: u.user?.profile?.image_48 || '',
164
+ title: u.user?.profile?.title || '',
165
+ is_bot: !!u.user?.is_bot,
166
+ });
167
+ } catch {
168
+ _userCache.set(uid, { id: uid, name: uid, avatar: '', title: '', is_bot: false });
169
+ }
170
+ }
171
+ return _userCache;
172
+ }
173
+
174
+ /** Return raw channel objects with member-resolved metadata. */
175
+ export async function listChannelsRich(config, { types = 'public_channel,private_channel,mpim,im', maxResults = 200, excludeArchived = true } = {}) {
176
+ const data = await slackFetch(config, 'conversations.list', {
177
+ types, limit: maxResults, exclude_archived: excludeArchived,
142
178
  });
179
+ const channels = data.channels || [];
180
+ // For DMs, resolve the other user's name
181
+ const dmUserIds = channels.filter(c => c.is_im).map(c => c.user).filter(Boolean);
182
+ if (dmUserIds.length) await _resolveUsers(config, dmUserIds);
183
+ return channels.map(c => ({
184
+ id: c.id,
185
+ name: c.is_im ? (`@${_userCache.get(c.user)?.name || c.user}`) : c.name,
186
+ is_member: c.is_member,
187
+ is_private: c.is_private,
188
+ is_im: c.is_im,
189
+ is_mpim: c.is_mpim,
190
+ is_archived: c.is_archived,
191
+ num_members: c.num_members,
192
+ purpose: c.purpose?.value || '',
193
+ topic: c.topic?.value || '',
194
+ unread_count: c.unread_count_display || 0,
195
+ }));
196
+ }
143
197
 
144
- return `Message sent to #${channel}.`;
198
+ /** Return raw message objects with user names resolved + thread metadata. */
199
+ export async function listMessagesRich(config, channel, { limit = 50, oldest, latest } = {}) {
200
+ const channelId = await resolveChannelId(config, channel);
201
+ const params = { channel: channelId, limit };
202
+ if (oldest) params.oldest = oldest;
203
+ if (latest) params.latest = latest;
204
+ const data = await slackFetch(config, 'conversations.history', params);
205
+ const messages = (data.messages || []).reverse();
206
+ const userIds = messages.map(m => m.user || m.bot_id).filter(Boolean);
207
+ await _resolveUsers(config, userIds);
208
+ return messages.map(m => ({
209
+ ts: m.ts,
210
+ user: _userCache.get(m.user)?.name || m.user || (m.username ? m.username : 'bot'),
211
+ user_id: m.user,
212
+ avatar: _userCache.get(m.user)?.avatar || '',
213
+ text: m.text || '',
214
+ type: m.type,
215
+ subtype: m.subtype,
216
+ thread_ts: m.thread_ts,
217
+ reply_count: m.reply_count || 0,
218
+ reactions: (m.reactions || []).map(r => ({ name: r.name, count: r.count })),
219
+ files: (m.files || []).map(f => ({ id: f.id, name: f.name, mimetype: f.mimetype, url: f.url_private })),
220
+ }));
145
221
  }
222
+
223
+ /** Get all replies in a thread. */
224
+ export async function getThreadReplies(config, channel, threadTs) {
225
+ const channelId = await resolveChannelId(config, channel);
226
+ const data = await slackFetch(config, 'conversations.replies', {
227
+ channel: channelId, ts: threadTs, limit: 100,
228
+ });
229
+ const messages = data.messages || [];
230
+ const userIds = messages.map(m => m.user || m.bot_id).filter(Boolean);
231
+ await _resolveUsers(config, userIds);
232
+ return messages.map(m => ({
233
+ ts: m.ts,
234
+ user: _userCache.get(m.user)?.name || m.user || 'bot',
235
+ text: m.text || '',
236
+ reactions: (m.reactions || []).map(r => ({ name: r.name, count: r.count })),
237
+ }));
238
+ }
239
+
240
+ /** Search messages across the workspace (requires search:read scope). */
241
+ export async function searchMessages(config, query, { count = 20 } = {}) {
242
+ const data = await slackFetch(config, 'search.messages', { query, count, sort: 'timestamp', sort_dir: 'desc' });
243
+ const matches = data.messages?.matches || [];
244
+ const userIds = matches.map(m => m.user).filter(Boolean);
245
+ await _resolveUsers(config, userIds);
246
+ return matches.map(m => ({
247
+ ts: m.ts,
248
+ user: _userCache.get(m.user)?.name || m.user || m.username || 'unknown',
249
+ text: m.text || '',
250
+ channel: m.channel?.name || m.channel?.id,
251
+ channel_id: m.channel?.id,
252
+ permalink: m.permalink,
253
+ }));
254
+ }
255
+
256
+ /** Add an emoji reaction to a message. */
257
+ export async function addReaction(config, channel, ts, emoji) {
258
+ const channelId = await resolveChannelId(config, channel);
259
+ await slackFetch(config, 'reactions.add', {
260
+ channel: channelId, timestamp: ts, name: emoji.replace(/^:|:$/g, ''),
261
+ });
262
+ return `Added :${emoji}: reaction.`;
263
+ }
264
+
265
+ /** Mark a channel as read up to a given ts (or now). */
266
+ export async function markRead(config, channel, ts = null) {
267
+ const channelId = await resolveChannelId(config, channel);
268
+ await slackFetch(config, 'conversations.mark', {
269
+ channel: channelId, ts: ts || (Date.now() / 1000).toString(),
270
+ });
271
+ return `Marked #${channel} as read.`;
272
+ }
273
+
274
+ /** Open a DM with a user by ID or name lookup. */
275
+ export async function openDM(config, userIdOrName) {
276
+ let uid = userIdOrName;
277
+ // If it doesn't look like a user ID (U...), try to look it up
278
+ if (!/^U[A-Z0-9]{8,}$/.test(uid)) {
279
+ const data = await slackFetch(config, 'users.list', { limit: 1000 });
280
+ const match = (data.members || []).find(u =>
281
+ u.real_name?.toLowerCase() === uid.toLowerCase() ||
282
+ u.name?.toLowerCase() === uid.toLowerCase() ||
283
+ u.profile?.email?.toLowerCase() === uid.toLowerCase(),
284
+ );
285
+ if (!match) throw new Error(`User "${userIdOrName}" not found in workspace.`);
286
+ uid = match.id;
287
+ }
288
+ const data = await slackFetch(config, 'conversations.open', { users: uid });
289
+ return data.channel?.id;
290
+ }
291
+
292
+ /** Workspace info (team name, icon). */
293
+ export async function getWorkspaceInfo(config) {
294
+ try {
295
+ const data = await slackFetch(config, 'team.info', {});
296
+ return {
297
+ id: data.team?.id,
298
+ name: data.team?.name,
299
+ domain: data.team?.domain,
300
+ icon: data.team?.icon?.image_88,
301
+ };
302
+ } catch { return null; }
303
+ }
304
+
305
+ /** Update the cached user info — useful before listing messages. */
306
+ export { _resolveUsers as resolveUsers };
307
+
@@ -752,7 +752,7 @@ browser_open(url) → returns sessionId. browser_screenshot(sessionId) · browse
752
752
  note_add(text) · note_list()
753
753
  github_issues(repo) · github_prs(repo) · github_notifications() · github_create_issue(repo, title, body)
754
754
  notion_search(query) · notion_page(pageId)
755
- slack_channels() · slack_messages(channel) · slack_send(channel, text)
755
+ slack_channels() · slack_messages(channel) · slack_send(channel, text, threadTs?) · slack_search(query) · slack_dm(user, text?) · slack_thread(channel, ts) · slack_react(channel, ts, emoji) · slack_mark_read(channel, ts?)
756
756
 
757
757
  ### Files / Drive
758
758
  file_list(dir?) · file_read(path) · file_write(path, content) · file_info(path) · file_search(query)
@@ -2009,7 +2009,44 @@ export async function executeTool(action, params, config) {
2009
2009
 
2010
2010
  case 'slack_send': {
2011
2011
  const sl = await import('./slack.mjs');
2012
- return sl.sendMessage(config, params.channel, params.text);
2012
+ return sl.sendMessage(config, params.channel, params.text, params.threadTs || null);
2013
+ }
2014
+
2015
+ case 'slack_search': {
2016
+ const sl = await import('./slack.mjs');
2017
+ const results = await sl.searchMessages(config, params.query, { count: params.count || 20 });
2018
+ if (!results.length) return `No messages match "${params.query}".`;
2019
+ return results.map((r, i) =>
2020
+ `${i + 1}. [#${r.channel}] ${r.user} (${new Date(parseFloat(r.ts) * 1000).toLocaleString()}): ${r.text.slice(0, 200)}${r.permalink ? `\n → ${r.permalink}` : ''}`,
2021
+ ).join('\n');
2022
+ }
2023
+
2024
+ case 'slack_dm': {
2025
+ const sl = await import('./slack.mjs');
2026
+ const channelId = await sl.openDM(config, params.user);
2027
+ if (params.text) await sl.sendMessage(config, channelId, params.text);
2028
+ return params.text
2029
+ ? `DM sent to ${params.user} (channel ${channelId}).`
2030
+ : `Opened DM channel ${channelId} with ${params.user}.`;
2031
+ }
2032
+
2033
+ case 'slack_thread': {
2034
+ const sl = await import('./slack.mjs');
2035
+ const replies = await sl.getThreadReplies(config, params.channel, params.ts);
2036
+ if (!replies.length) return 'Thread has no replies.';
2037
+ return replies.map((r, i) =>
2038
+ `${i + 1}. ${r.user} (${new Date(parseFloat(r.ts) * 1000).toLocaleTimeString()}): ${r.text.slice(0, 200)}`,
2039
+ ).join('\n');
2040
+ }
2041
+
2042
+ case 'slack_react': {
2043
+ const sl = await import('./slack.mjs');
2044
+ return sl.addReaction(config, params.channel, params.ts, params.emoji || 'thumbsup');
2045
+ }
2046
+
2047
+ case 'slack_mark_read': {
2048
+ const sl = await import('./slack.mjs');
2049
+ return sl.markRead(config, params.channel, params.ts || null);
2013
2050
  }
2014
2051
 
2015
2052
  // ── Maps Directions ──────────────────────────────────────────────────