nothumanallowed 15.1.36 → 15.1.38

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.36",
3
+ "version": "15.1.38",
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.36';
8
+ export const VERSION = '15.1.38';
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,37 @@ class TelegramResponder {
530
530
  saveTelegramContext(this._lastContextByChatId);
531
531
  }
532
532
 
533
+ /**
534
+ * Language-agnostic intent classifier (15.1.38).
535
+ *
536
+ * Previous versions hard-coded confirmation/reaction keywords for Italian
537
+ * and English. That couldn't scale — every new language ("schedule it"
538
+ * in Spanish, German, Japanese, Polish, Turkish, ...) would need a new
539
+ * keyword list. Now we delegate to a tiny LLM call: 16 tokens output,
540
+ * works in any human language the model knows.
541
+ *
542
+ * Fast path: if the keyword matcher already returned a confident verdict,
543
+ * skip the LLM. Only ambiguous cases (short messages, no obvious keyword)
544
+ * pay the ~50ms LLM cost.
545
+ *
546
+ * @returns 'continuation' | 'new_request' | 'unknown'
547
+ */
548
+ async _classifyIntent(text, lastCtx) {
549
+ if (!lastCtx) return 'new_request';
550
+ const hasKey = !!(this.config.llm?.apiKey || this.config.llm?.openaiKey || this.config.llm?.geminiKey || (this.config.llm?.provider === 'nha'));
551
+ if (!hasKey) return 'unknown';
552
+ try {
553
+ const prev = (lastCtx.agentReply || '').slice(0, 600);
554
+ const sys = 'You are a binary intent classifier for a chat assistant. Reply with EXACTLY one word: "continuation" or "new_request". Use "continuation" when the user is confirming, denying, reacting to, or refining the assistant\'s previous message (in any language). Use "new_request" when the user is starting an unrelated topic. Do not output anything else.';
555
+ const usr = `Assistant just said:\n"${prev}"\n\nUser now writes:\n"${text}"\n\nClassify the user\'s message: continuation or new_request?`;
556
+ const ans = await callLLM(this.config, sys, usr, { max_tokens: 8 });
557
+ const v = String(ans || '').trim().toLowerCase().replace(/[^a-z_]/g, '');
558
+ if (v === 'continuation' || v.startsWith('cont')) return 'continuation';
559
+ if (v === 'new_request' || v.startsWith('new')) return 'new_request';
560
+ return 'unknown';
561
+ } catch { return 'unknown'; }
562
+ }
563
+
533
564
  /**
534
565
  * Route a fresh message to the best agent.
535
566
  *
@@ -823,8 +854,27 @@ class TelegramResponder {
823
854
  const withinStickyWindow = stickyAge < 5 * 60 * 1000; // 5 min
824
855
 
825
856
  // Determine if this message is a continuation of the previous turn
826
- // (confirmation, reaction, short reply) vs a new independent request
827
- const isContinuation = withinStickyWindow && isContinuationMessage(cleanText, lastCtx);
857
+ // (confirmation, reaction, short reply) vs a new independent request.
858
+ //
859
+ // Two-stage detection:
860
+ // 1. Fast keyword path (IT + EN) — handles the obvious cases (~80%) with zero cost.
861
+ // 2. LLM classifier fallback — for ambiguous cases in any language
862
+ // ("agéndalo", "termin verschieben", "schedule it",
863
+ // "ya", "tamam", "harika", ...). Costs ~50 ms and 8 output tokens
864
+ // but is language-agnostic by design.
865
+ let isContinuation = withinStickyWindow && isContinuationMessage(cleanText, lastCtx);
866
+ if (withinStickyWindow && !isContinuation && lastCtx) {
867
+ // Only invoke the LLM if keyword fast-path didn't flag continuation
868
+ // AND the message is short enough to plausibly be a reaction (≤ 12 words).
869
+ const wordCount = cleanText.trim().split(/\s+/).length;
870
+ if (wordCount <= 12) {
871
+ const verdict = await this._classifyIntent(cleanText, lastCtx);
872
+ if (verdict === 'continuation') {
873
+ isContinuation = true;
874
+ this.log(`[Telegram] ${fromUser}: LLM classifier overrode keyword → continuation`);
875
+ }
876
+ }
877
+ }
828
878
 
829
879
  // If last response was a completed action, don't carry history forward —
830
880
  // the next message is a fresh request even if it looks like a reaction
@@ -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
+
@@ -294,8 +294,34 @@ TOOLS:
294
294
  40. slack_messages(channel: string, maxResults?: number)
295
295
  List recent messages in a Slack channel (name or ID). Default max 15.
296
296
 
297
- 41. slack_send(channel: string, text: string)
297
+ 41. slack_send(channel: string, text: string, threadTs?: string)
298
298
  Send a message to a Slack channel. ALWAYS confirm before sending.
299
+ If `threadTs` is provided, post as a thread reply instead of a top-level message.
300
+
301
+ 41b. slack_search(query: string, count?: number)
302
+ Full-text search messages across the whole Slack workspace. Returns the most
303
+ recent matches with channel, user, text and a Slack permalink each. Use this
304
+ for "find the message where X talked about Y", "trova il messaggio di Marco
305
+ su X", "did anyone post about the release", etc.
306
+
307
+ 41c. slack_dm(user: string, text?: string)
308
+ Open or send a direct message to a user. `user` accepts Slack user ID (Uxxx),
309
+ username, real name, or email — the tool resolves them automatically.
310
+ If `text` is provided, the message is sent immediately; otherwise the DM
311
+ channel is just opened.
312
+
313
+ 41d. slack_thread(channel: string, ts: string)
314
+ List all replies in a thread. `ts` is the parent message timestamp (returned
315
+ by slack_messages or slack_search). Use this before posting a contextual
316
+ reply with slack_send + threadTs.
317
+
318
+ 41e. slack_react(channel: string, ts: string, emoji: string)
319
+ Add an emoji reaction to a message. `emoji` is the name without colons
320
+ (e.g. "thumbsup", "rocket", "white_check_mark"). Confirm before reacting on
321
+ someone else's message.
322
+
323
+ 41f. slack_mark_read(channel: string, ts?: string)
324
+ Mark a Slack channel as read up to a given timestamp (default: now).
299
325
 
300
326
  --- FILE ATTACHMENT ---
301
327
 
@@ -752,7 +778,7 @@ browser_open(url) → returns sessionId. browser_screenshot(sessionId) · browse
752
778
  note_add(text) · note_list()
753
779
  github_issues(repo) · github_prs(repo) · github_notifications() · github_create_issue(repo, title, body)
754
780
  notion_search(query) · notion_page(pageId)
755
- slack_channels() · slack_messages(channel) · slack_send(channel, text)
781
+ 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
782
 
757
783
  ### Files / Drive
758
784
  file_list(dir?) · file_read(path) · file_write(path, content) · file_info(path) · file_search(query)
@@ -2009,7 +2035,44 @@ export async function executeTool(action, params, config) {
2009
2035
 
2010
2036
  case 'slack_send': {
2011
2037
  const sl = await import('./slack.mjs');
2012
- return sl.sendMessage(config, params.channel, params.text);
2038
+ return sl.sendMessage(config, params.channel, params.text, params.threadTs || null);
2039
+ }
2040
+
2041
+ case 'slack_search': {
2042
+ const sl = await import('./slack.mjs');
2043
+ const results = await sl.searchMessages(config, params.query, { count: params.count || 20 });
2044
+ if (!results.length) return `No messages match "${params.query}".`;
2045
+ return results.map((r, i) =>
2046
+ `${i + 1}. [#${r.channel}] ${r.user} (${new Date(parseFloat(r.ts) * 1000).toLocaleString()}): ${r.text.slice(0, 200)}${r.permalink ? `\n → ${r.permalink}` : ''}`,
2047
+ ).join('\n');
2048
+ }
2049
+
2050
+ case 'slack_dm': {
2051
+ const sl = await import('./slack.mjs');
2052
+ const channelId = await sl.openDM(config, params.user);
2053
+ if (params.text) await sl.sendMessage(config, channelId, params.text);
2054
+ return params.text
2055
+ ? `DM sent to ${params.user} (channel ${channelId}).`
2056
+ : `Opened DM channel ${channelId} with ${params.user}.`;
2057
+ }
2058
+
2059
+ case 'slack_thread': {
2060
+ const sl = await import('./slack.mjs');
2061
+ const replies = await sl.getThreadReplies(config, params.channel, params.ts);
2062
+ if (!replies.length) return 'Thread has no replies.';
2063
+ return replies.map((r, i) =>
2064
+ `${i + 1}. ${r.user} (${new Date(parseFloat(r.ts) * 1000).toLocaleTimeString()}): ${r.text.slice(0, 200)}`,
2065
+ ).join('\n');
2066
+ }
2067
+
2068
+ case 'slack_react': {
2069
+ const sl = await import('./slack.mjs');
2070
+ return sl.addReaction(config, params.channel, params.ts, params.emoji || 'thumbsup');
2071
+ }
2072
+
2073
+ case 'slack_mark_read': {
2074
+ const sl = await import('./slack.mjs');
2075
+ return sl.markRead(config, params.channel, params.ts || null);
2013
2076
  }
2014
2077
 
2015
2078
  // ── Maps Directions ──────────────────────────────────────────────────