nothumanallowed 15.1.62 → 15.1.64

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.62",
3
+ "version": "15.1.64",
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.62';
8
+ export const VERSION = '15.1.64';
9
9
  export const BASE_URL = 'https://nothumanallowed.com/cli';
10
10
  export const API_BASE = 'https://nothumanallowed.com/api/v1';
11
11
 
@@ -17,7 +17,7 @@ import {
17
17
  import { callLLMStream, callLLM, callLLMVision, parseAgentFile } from '../../services/llm.mjs';
18
18
  import { buildMemoryContext } from '../../services/memory.mjs';
19
19
  import { parseActions, executeTool, buildSystemPrompt, stripOrphanFences } from '../../services/tool-executor.mjs';
20
- import { detectLanguage } from '../../services/message-responder.mjs';
20
+ import { detectLanguage, tryDirectActionAll } from '../../services/message-responder.mjs';
21
21
 
22
22
  // Migrate on import (once)
23
23
  migrateOldHistory();
@@ -267,6 +267,32 @@ export function register(router) {
267
267
  }, 3000);
268
268
 
269
269
  try {
270
+ // ── Deterministic direct-action dispatcher (LLM-NLU + server execute) ──
271
+ // Same architecture used by Telegram/Discord. Before invoking the chat
272
+ // LLM, classify the message: if it maps to a state-changing tool
273
+ // (calendar/email/task/file/drive/slack/notion/github/...), execute it
274
+ // deterministically server-side and stream the result. No more "the
275
+ // model said done but didn't call the tool".
276
+ const direct = await tryDirectActionAll(msg, config, {
277
+ auditKey: `chat:${body.conversationId || 'anon'}`,
278
+ });
279
+ if (direct) {
280
+ if (heartbeatInterval) { clearInterval(heartbeatInterval); heartbeatInterval = null; }
281
+ sse('tool', { action: direct.action, status: 'done', result: (direct.message || '').slice(0, 240) });
282
+ sse('token', { content: direct.message });
283
+ // Persist to conversation
284
+ if (body.conversationId) {
285
+ try {
286
+ const conv = loadConversation(body.conversationId);
287
+ if (conv) addMessages(conv, msg, direct.message);
288
+ } catch {}
289
+ }
290
+ sse('done', { content: direct.message });
291
+ res.write('data: [DONE]\n\n');
292
+ res.end();
293
+ return;
294
+ }
295
+
270
296
  let fullResponse = '';
271
297
  fullResponse = await callLLMStream(config, enrichedPrompt, userMessage, (chunk) => {
272
298
  clearInterval(heartbeatInterval);
@@ -9,7 +9,7 @@
9
9
  */
10
10
 
11
11
  import { callAgent, callLLM } from './llm.mjs';
12
- import { buildSystemPrompt, parseActions, executeTool, TOOL_DEFINITIONS, LIARA_TOOL_DEFINITIONS } from './tool-executor.mjs';
12
+ import { buildSystemPrompt, parseActions, executeTool, TOOL_DEFINITIONS, LIARA_TOOL_DEFINITIONS, DESTRUCTIVE_ACTIONS } from './tool-executor.mjs';
13
13
  import https from 'https';
14
14
  import http from 'http';
15
15
  import { URL } from 'url';
@@ -829,9 +829,63 @@ class TelegramResponder {
829
829
  // Track this user for broadcast notifications (update alerts, etc.)
830
830
  touchTelegramUser(chatId, message.from?.username, message.from?.first_name);
831
831
 
832
- let rawText = message.text || '';
832
+ let rawText = message.text || message.caption || '';
833
833
  let isVoice = false;
834
834
 
835
+ // ── Image / photo handler (vision via Liara or fallback provider) ──────
836
+ // Telegram sends `message.photo` as an array of size variants — we pick
837
+ // the largest. For documents (e.g. screenshots sent as files), we accept
838
+ // any mime starting with image/.
839
+ const photo = Array.isArray(message.photo) && message.photo.length
840
+ ? message.photo[message.photo.length - 1]
841
+ : null;
842
+ const isImageDoc = message.document && /^image\//.test(message.document.mime_type || '');
843
+ if (photo || isImageDoc) {
844
+ try {
845
+ await this._telegramCall('sendChatAction', { chat_id: chatId, action: 'typing' });
846
+ const fileId = photo ? photo.file_id : message.document.file_id;
847
+ const fileInfo = await this._telegramCall('getFile', { file_id: fileId });
848
+ const filePath = fileInfo?.result?.file_path;
849
+ if (!filePath) throw new Error('Telegram file_path missing');
850
+ const fileUrl = `https://api.telegram.org/file/bot${this.token}/${filePath}`;
851
+ const fileRes = await fetch(fileUrl);
852
+ if (!fileRes.ok) throw new Error(`Telegram file fetch ${fileRes.status}`);
853
+ const buf = Buffer.from(await fileRes.arrayBuffer());
854
+ const base64 = buf.toString('base64');
855
+ // Infer mediaType from file_path extension.
856
+ const ext = (filePath.split('.').pop() || 'jpg').toLowerCase();
857
+ const mediaType = ext === 'png' ? 'image/png'
858
+ : ext === 'gif' ? 'image/gif'
859
+ : ext === 'webp' ? 'image/webp'
860
+ : 'image/jpeg';
861
+ const userPrompt = rawText.trim()
862
+ || 'Describe this image in detail. If it contains text, transcribe it exactly. Reply in Italian.';
863
+ const langInstruction = detectLanguage(userPrompt) || (rawText ? null : null);
864
+ const sysPrompt = `You are a helpful visual assistant. ${langInstruction === 'English' ? 'Reply in English.' : 'Rispondi in italiano.'} Be specific and accurate. If asked to extract text, transcribe it verbatim. If asked to identify objects, list them clearly.`;
865
+ const { callLLMVision } = await import('./llm.mjs');
866
+ const description = await callLLMVision(this.config, sysPrompt, userPrompt, { base64, mediaType });
867
+ const truncated = description.length > 4000 ? description.slice(0, 3950) + '\n\n... [truncated]' : description;
868
+ // Audit
869
+ this._recordAudit(chatId, {
870
+ tool: 'vision_describe',
871
+ success: true,
872
+ summary: `Image (${Math.round(buf.length / 1024)} KB) — "${(userPrompt).slice(0, 60)}"`,
873
+ });
874
+ const personaName = this.config.responder?.telegram?.botName || this.config.responder?.botName || '';
875
+ const personaMode = this.config.responder?.telegram?.personaMode || (personaName ? 'persona' : 'agent');
876
+ const prefix = personaMode === 'persona-only' && personaName ? ''
877
+ : personaName ? `[${personaName}]\n\n`
878
+ : `[HERALD]\n\n`;
879
+ await this._telegramCall('sendMessage', { chat_id: chatId, text: prefix + truncated });
880
+ this.log(`[Telegram] Image vision response to ${fromUser} (${buf.length} bytes, ${description.length} chars)`);
881
+ } catch (err) {
882
+ this.log(`[Telegram] Vision failed: ${err.message}`);
883
+ await this._telegramCall('sendMessage', { chat_id: chatId,
884
+ text: `Non riesco ad analizzare l'immagine: ${err.message}` }).catch(() => {});
885
+ }
886
+ return;
887
+ }
888
+
835
889
  // Handle voice notes — transcribe with Whisper (Groq or OpenAI)
836
890
  if (message.voice || message.audio) {
837
891
  const fileId = (message.voice || message.audio).file_id;
@@ -1047,13 +1101,17 @@ class TelegramResponder {
1047
1101
  this._lastDirectAuditChatId = chatId;
1048
1102
  // Run the per-domain direct-action dispatcher. First match wins; falls
1049
1103
  // through to LLM if no handler claims the message.
1104
+ // Fast-path specialised handlers (regex-driven, lower latency for the
1105
+ // common cases), then the universal dispatcher that covers ALL 50+
1106
+ // mutation tools via a single LLM-NLU+deterministic-execute pass.
1050
1107
  const directFresh =
1051
1108
  await this._tryDirectFreshCalendarAction(cleanText, this.config) ||
1052
1109
  await this._tryDirectFreshEmailAction(cleanText, this.config) ||
1053
1110
  await this._tryDirectFreshTaskAction(cleanText, this.config) ||
1054
1111
  await this._tryDirectFreshNoteAction(cleanText, this.config) ||
1055
1112
  await this._tryDirectFreshReminderAction(cleanText, this.config) ||
1056
- await this._tryDirectFreshSlackAction(cleanText, this.config);
1113
+ await this._tryDirectFreshSlackAction(cleanText, this.config) ||
1114
+ await this._tryDirectFreshUniversalAction(cleanText, this.config);
1057
1115
  if (directFresh) {
1058
1116
  this.log(`[Telegram] ${fromUser}: direct-fresh ${directFresh.action} → ${directFresh.success ? 'OK' : 'FAIL'}`);
1059
1117
  const personaName = this.config.responder?.telegram?.botName || this.config.responder?.botName || '';
@@ -1663,6 +1721,181 @@ class TelegramResponder {
1663
1721
  try { return this._extractJsonObject(await callLLM(config, sys, userMessage, { temperature: 0, maxTokens: 300 })); } catch { return null; }
1664
1722
  }
1665
1723
 
1724
+ // ════════════════════════════════════════════════════════════════════════
1725
+ // UNIVERSAL DIRECT-ACTION DISPATCHER
1726
+ // ════════════════════════════════════════════════════════════════════════
1727
+ // Covers ALL 22 mutation tools in `DESTRUCTIVE_ACTIONS` (gmail, imap,
1728
+ // calendar, contacts, tasks, slack, github, file, drive, notify).
1729
+ // The LLM is used ONLY to (a) decide if the message maps to a tool and
1730
+ // (b) extract the params as JSON. Tool execution is then deterministic
1731
+ // server-side. No tool block is parsed from natural language by the
1732
+ // model — the model can never "say done" without us actually doing it.
1733
+ async _tryDirectFreshUniversalAction(userMessage, config) {
1734
+ if (!userMessage || typeof userMessage !== 'string' || userMessage.length < 3) return null;
1735
+
1736
+ const todayIso = new Date().toISOString().slice(0, 10);
1737
+ const sys =
1738
+ `You are a tool-routing classifier. Given a user message in any language, decide whether it ` +
1739
+ `requests a state-changing action that maps to ONE of these tools, and extract the params.\n\n` +
1740
+ `ALLOWED TOOLS (you MUST pick one of these OR return null):\n` +
1741
+ // Calendar
1742
+ `- calendar_create(summary, start, end, description?) — start/end ISO "YYYY-MM-DDTHH:MM:00"\n` +
1743
+ `- calendar_move(eventId? OR title, newStart, newEnd?) — if no eventId, title is used to find it\n` +
1744
+ `- calendar_update(eventId? OR title, summary?, start?, end?, description?)\n` +
1745
+ `- calendar_delete(eventId? OR title, date?)\n` +
1746
+ // Email Gmail
1747
+ `- gmail_send(to, subject, body) — primary email account\n` +
1748
+ `- gmail_reply(messageId? OR threadHint, body)\n` +
1749
+ `- gmail_delete(messageId? OR query)\n` +
1750
+ `- gmail_mark_read(messageId, isRead?)\n` +
1751
+ `- gmail_mark_starred(messageId, starred?)\n` +
1752
+ `- gmail_archive(messageId)\n` +
1753
+ // Email IMAP
1754
+ `- imap_send(accountId?, to, subject, body) — custom IMAP account\n` +
1755
+ `- imap_reply(accountId?, messageId, body)\n` +
1756
+ `- imap_trash(messageId)\n` +
1757
+ `- imap_mark_read(messageId, isRead?)\n` +
1758
+ `- imap_draft(accountId?, to, subject, body)\n` +
1759
+ // Contacts
1760
+ `- contact_add(name, email?, phone?, company?, address?)\n` +
1761
+ `- contact_update(query, email?, phone?, company?, address?)\n` +
1762
+ `- contact_delete(query)\n` +
1763
+ // Tasks
1764
+ `- task_add(title, priority?, due?)\n` +
1765
+ `- task_done(title)\n` +
1766
+ `- task_delete(title)\n` +
1767
+ // Google Tasks
1768
+ `- gtask_add(title, notes?, due?)\n` +
1769
+ `- gtask_complete(title)\n` +
1770
+ // Notes
1771
+ `- note_add(title, content?)\n` +
1772
+ // Reminders
1773
+ `- notify_remind(message, when) — when = ISO datetime\n` +
1774
+ `- reminder_create(message, when)\n` +
1775
+ // Slack
1776
+ `- slack_send(channel, text, threadTs?) — channel "#name"\n` +
1777
+ `- slack_dm(user, text) — user = name, id, or email\n` +
1778
+ `- slack_react(channel, ts, emoji)\n` +
1779
+ `- slack_mark_read(channel, ts)\n` +
1780
+ // Notion
1781
+ `- notion_page(title, content) — create a new Notion page\n` +
1782
+ // GitHub
1783
+ `- github_create_issue(repo, title, body?, labels?) — repo "owner/name"\n` +
1784
+ // File system (local to user)
1785
+ `- file_write(path, content)\n` +
1786
+ `- file_move(from, to)\n` +
1787
+ `- file_delete(path)\n` +
1788
+ `- file_mkdir(path)\n` +
1789
+ // Google Drive
1790
+ `- drive_upload(name, content, mimeType?) — Google Drive\n` +
1791
+ `- drive_update(fileId, content)\n` +
1792
+ `- drive_delete(fileId? OR name)\n` +
1793
+ `- drive_move(fileId, newParentFolderId? OR newName?)\n` +
1794
+ `- drive_share(fileId, email, role?) — role = "reader"|"writer"|"commenter"\n` +
1795
+ // Birthdays
1796
+ `- birthday_add(name, date) — date "YYYY-MM-DD" or "MM-DD"\n` +
1797
+ `- birthday_delete(name)\n` +
1798
+ // Alexandria E2E
1799
+ `- alexandria_send(channel, message)\n` +
1800
+ // Cron
1801
+ `- cron_create(name, schedule, command) — schedule = cron expression\n` +
1802
+ `- cron_delete(name)\n\n` +
1803
+ `Today is ${todayIso}. Relative dates: "domani" = ${this._addDaysIso(todayIso, 1).slice(0, 10)}, ` +
1804
+ `"dopodomani" = ${this._addDaysIso(todayIso, 2).slice(0, 10)}, "lunedì/martedì/..." resolve to next occurrence.\n\n` +
1805
+ `OUTPUT FORMAT (strict JSON, no markdown, no prose, no fences):\n` +
1806
+ `{"tool": "tool_name" | null, "params": { ... }}\n\n` +
1807
+ `If the message is a READ/LIST/QUERY operation (e.g. "mostra…", "che ho oggi", "leggi email", "trova"), ` +
1808
+ `OR is conversational chat (greetings, questions, opinions) OR is ambiguous → return {"tool": null}.\n` +
1809
+ `If a required param is genuinely missing AND not inferable → return {"tool": null}.\n` +
1810
+ `Never invent emails, eventIds, or recipient addresses.`;
1811
+
1812
+ let raw;
1813
+ try {
1814
+ raw = await callLLM(config, sys, userMessage, { temperature: 0, maxTokens: 400 });
1815
+ } catch (e) {
1816
+ this.log(`[direct-universal] LLM call failed: ${e.message}`);
1817
+ return null;
1818
+ }
1819
+ const parsed = this._extractJsonObject(raw);
1820
+ if (!parsed || !parsed.tool || !DESTRUCTIVE_ACTIONS.has(parsed.tool)) return null;
1821
+ if (!parsed.params || typeof parsed.params !== 'object') return null;
1822
+
1823
+ // Per-tool param normalization (matches the executor's accepted shapes).
1824
+ const params = { ...parsed.params };
1825
+ if (parsed.tool === 'calendar_create' && !params.summary) params.summary = params.title || params.name || params.subject;
1826
+ if (parsed.tool === 'calendar_create' && params.start && !params.end) params.end = this._addMinutesIso(params.start, 60);
1827
+
1828
+ try {
1829
+ const result = await executeTool(parsed.tool, params, config);
1830
+ const resultStr = typeof result === 'string' ? result : JSON.stringify(result);
1831
+ const ok = !/error|failed|not\s+found|invalid|does\s+not\s+exist|placeholder/i.test(resultStr);
1832
+
1833
+ if (this._lastDirectAuditChatId) {
1834
+ this._recordAudit(this._lastDirectAuditChatId, {
1835
+ tool: parsed.tool,
1836
+ success: ok,
1837
+ summary: this._summarizeParamsForAudit(parsed.tool, params),
1838
+ });
1839
+ }
1840
+ const message = ok
1841
+ ? this._formatActionResultIT(parsed.tool, params, resultStr)
1842
+ : `Non sono riuscito a eseguire ${parsed.tool}: ${resultStr.slice(0, 240)}`;
1843
+ return { action: parsed.tool, success: ok, message };
1844
+ } catch (e) {
1845
+ return { action: parsed.tool, success: false, message: `Errore durante ${parsed.tool}: ${e.message}` };
1846
+ }
1847
+ }
1848
+
1849
+ /** Natural-language Italian summary for the audit log entry. */
1850
+ _summarizeParamsForAudit(tool, params) {
1851
+ if (tool.startsWith('calendar_')) {
1852
+ const t = params.summary || params.title || '';
1853
+ const s = params.start ? this._formatDateIT(String(params.start).slice(0, 10)) + ` ${String(params.start).slice(11, 16)}` : '';
1854
+ return `"${t}"${s ? ` · ${s}` : ''}`;
1855
+ }
1856
+ if (tool === 'gmail_send' || tool === 'imap_send') return `→ ${params.to || '?'} · "${(params.subject || '').slice(0, 60)}"`;
1857
+ if (tool === 'gmail_reply' || tool === 'imap_reply') return `reply → ${params.to || params.messageId || '?'}`;
1858
+ if (tool === 'slack_send') return `→ ${params.channel || '?'} · "${(params.text || '').slice(0, 60)}"`;
1859
+ if (tool === 'notify_remind') return `"${params.message || ''}" @ ${params.when || '?'}`;
1860
+ if (tool === 'github_create_issue') return `${params.repo || '?'}: "${params.title || ''}"`;
1861
+ if (tool === 'file_write') return `${params.path || '?'} (${(params.content || '').length} chars)`;
1862
+ if (tool === 'drive_upload') return `${params.name || '?'} (${(params.content || '').length} chars)`;
1863
+ if (tool === 'task_done' || tool === 'task_delete' || tool === 'contact_delete') return `"${params.title || params.name || '?'}"`;
1864
+ return JSON.stringify(params).slice(0, 80);
1865
+ }
1866
+
1867
+ /** Italian-language natural response for a successful action. */
1868
+ _formatActionResultIT(tool, params, result) {
1869
+ switch (tool) {
1870
+ case 'calendar_create': {
1871
+ const t = params.summary || params.title || 'evento';
1872
+ const when = params.start ? `${this._formatDateIT(String(params.start).slice(0, 10))} alle ${String(params.start).slice(11, 16)}` : '';
1873
+ return `Fatto. Ho creato l'appuntamento "${t}"${when ? ` il ${when}` : ''}.`;
1874
+ }
1875
+ case 'calendar_move': return `Fatto. Appuntamento spostato.`;
1876
+ case 'calendar_update': return `Fatto. Appuntamento aggiornato.`;
1877
+ case 'calendar_delete': return `Fatto. Appuntamento cancellato.`;
1878
+ case 'gmail_send':
1879
+ case 'imap_send': return `Fatto. Email inviata a ${params.to}.`;
1880
+ case 'gmail_reply':
1881
+ case 'imap_reply': return `Fatto. Risposta inviata.`;
1882
+ case 'gmail_delete': return `Fatto. Email eliminata.`;
1883
+ case 'imap_trash': return `Fatto. Email spostata nel cestino.`;
1884
+ case 'contact_delete': return `Fatto. Contatto "${params.name || ''}" cancellato.`;
1885
+ case 'task_done': return `Fatto. Task "${params.title || ''}" completato.`;
1886
+ case 'task_delete': return `Fatto. Task "${params.title || ''}" cancellato.`;
1887
+ case 'task_clear': return `Fatto. Task list pulita.`;
1888
+ case 'notify_remind': return `Promemoria impostato: "${params.message || ''}" per ${params.when || ''}.`;
1889
+ case 'slack_send': return `Fatto. Messaggio inviato a ${params.channel || ''}.`;
1890
+ case 'github_create_issue': return `Issue creata su ${params.repo}: "${params.title}".`;
1891
+ case 'file_write': return `Fatto. File ${params.path} scritto (${(params.content || '').length} caratteri).`;
1892
+ case 'drive_upload': return `Fatto. "${params.name}" caricato su Google Drive.`;
1893
+ case 'drive_update': return `Fatto. File Drive aggiornato.`;
1894
+ case 'drive_delete': return `Fatto. File Drive eliminato.`;
1895
+ default: return `Fatto. ${result.slice(0, 200)}`;
1896
+ }
1897
+ }
1898
+
1666
1899
  // ── Direct fresh calendar action (no LLM) ─────────────────────────────────
1667
1900
  // Detects DELETE / LIST_MONTH / LIST_WEEK / LIST_DAY / LIST_TODAY /
1668
1901
  // LIST_TOMORROW intents from a fresh user message and runs the proper tool
@@ -2010,6 +2243,50 @@ class TelegramResponder {
2010
2243
  }
2011
2244
  }
2012
2245
 
2246
+ // ── Shared direct-action dispatcher (Telegram / Discord / Chat WebUI / Voice) ─
2247
+ // A reusable, instance-less handler. Internally piggybacks on a singleton
2248
+ // TelegramResponder built with a dummy config — we only use it as a host for
2249
+ // the `_tryDirectFresh*` methods. The audit log is keyed by the caller's
2250
+ // own `auditKey` (chatId for Telegram, channelId for Discord, conversationId
2251
+ // for Chat WebUI), so each platform keeps its own action history without
2252
+ // crossing wires.
2253
+ let _sharedDirectHandler = null;
2254
+ function _getDirectHandler() {
2255
+ if (!_sharedDirectHandler) {
2256
+ _sharedDirectHandler = new TelegramResponder(
2257
+ { responder: { telegram: { token: '__noop__' } } },
2258
+ () => {},
2259
+ () => {},
2260
+ );
2261
+ // Ensure the in-memory store exists.
2262
+ _sharedDirectHandler._lastContextByChatId = _sharedDirectHandler._lastContextByChatId || {};
2263
+ }
2264
+ return _sharedDirectHandler;
2265
+ }
2266
+
2267
+ /**
2268
+ * Try every direct-action handler in order (fast-path → universal). Returns
2269
+ * `{action, success, message}` on hit, `null` if nothing claimed the message.
2270
+ *
2271
+ * @param {string} text — the raw user message in any language
2272
+ * @param {object} config — loaded nha config (used by tools + LLM NLU)
2273
+ * @param {object} [opts]
2274
+ * @param {string} [opts.auditKey] — stable key for action audit (chatId, channelId, conversationId…)
2275
+ * @param {(line:string)=>void} [opts.log] — optional logger
2276
+ */
2277
+ export async function tryDirectActionAll(text, config, opts = {}) {
2278
+ const h = _getDirectHandler();
2279
+ if (opts.auditKey) h._lastDirectAuditChatId = opts.auditKey;
2280
+ if (opts.log) h.log = opts.log;
2281
+ return await h._tryDirectFreshCalendarAction(text, config)
2282
+ || await h._tryDirectFreshEmailAction(text, config)
2283
+ || await h._tryDirectFreshTaskAction(text, config)
2284
+ || await h._tryDirectFreshNoteAction(text, config)
2285
+ || await h._tryDirectFreshReminderAction(text, config)
2286
+ || await h._tryDirectFreshSlackAction(text, config)
2287
+ || await h._tryDirectFreshUniversalAction(text, config);
2288
+ }
2289
+
2013
2290
  // ── Discord Bot (Gateway WebSocket via raw TLS, zero dependencies) ───────────
2014
2291
 
2015
2292
  class DiscordResponder {
@@ -2333,6 +2610,21 @@ class DiscordResponder {
2333
2610
  this.pendingRequests++;
2334
2611
 
2335
2612
  try {
2613
+ // Try the deterministic direct-action dispatcher BEFORE routing to an
2614
+ // LLM agent. Same architecture used by Telegram: LLM only for NLU,
2615
+ // tool execution always server-side, audit log per channel.
2616
+ const directFresh = await tryDirectActionAll(cleanText, this.config, {
2617
+ auditKey: `discord:${channelId}`,
2618
+ log: this.log,
2619
+ });
2620
+ if (directFresh) {
2621
+ await this._discordApiCall('POST', `/channels/${channelId}/messages`, {
2622
+ content: directFresh.message,
2623
+ });
2624
+ this.log(`[Discord] direct-action ${directFresh.action} → ${directFresh.success ? 'OK' : 'FAIL'}`);
2625
+ return;
2626
+ }
2627
+
2336
2628
  const agent = routeMessage(cleanText, this.autoRoute);
2337
2629
  this.log(`[Discord] ${fromUser} (#${channelId}): routed to ${agent.toUpperCase()}`);
2338
2630
 
@@ -63,30 +63,39 @@ function getTsxPath() {
63
63
 
64
64
  /** Actions that mutate external state and require user confirmation. */
65
65
  export const DESTRUCTIVE_ACTIONS = new Set([
66
- 'gmail_send',
67
- 'gmail_send_attach',
68
- 'gmail_reply',
69
- 'gmail_delete',
70
- 'imap_send',
71
- 'imap_reply',
72
- 'imap_bulk_send',
73
- 'imap_send_template',
74
- 'imap_trash',
75
- 'calendar_create',
76
- 'calendar_move',
77
- 'calendar_update',
78
- 'calendar_delete',
79
- 'contact_delete',
80
- 'task_done',
81
- 'task_delete',
82
- 'task_clear',
83
- 'notify_remind',
84
- 'slack_send',
85
- 'github_create_issue',
86
- 'file_write',
87
- 'drive_upload',
88
- 'drive_update',
89
- 'drive_delete',
66
+ // Gmail
67
+ 'gmail_send', 'gmail_send_attach', 'gmail_reply', 'gmail_delete',
68
+ 'gmail_mark_read', 'gmail_mark_starred', 'gmail_archive', 'gmail_trash',
69
+ // IMAP (custom email accounts)
70
+ 'imap_send', 'imap_reply', 'imap_bulk_send', 'imap_send_template',
71
+ 'imap_trash', 'imap_mark_read', 'imap_mark_starred', 'imap_draft',
72
+ // Calendar
73
+ 'calendar_create', 'calendar_move', 'calendar_update', 'calendar_delete',
74
+ // Contacts
75
+ 'contact_add', 'contact_update', 'contact_delete',
76
+ // Tasks (local) + Google Tasks
77
+ 'task_add', 'task_done', 'task_delete', 'task_clear',
78
+ 'gtask_add', 'gtask_complete', 'gtask_delete',
79
+ // Notes
80
+ 'note_add',
81
+ // Reminders / notifications
82
+ 'notify_remind', 'reminder_create',
83
+ // Slack
84
+ 'slack_send', 'slack_dm', 'slack_react', 'slack_mark_read',
85
+ // Notion
86
+ 'notion_page', 'notion_update',
87
+ // GitHub
88
+ 'github_create_issue', 'github_comment',
89
+ // File system (local)
90
+ 'file_write', 'file_move', 'file_delete', 'file_mkdir',
91
+ // Google Drive
92
+ 'drive_upload', 'drive_update', 'drive_delete', 'drive_move', 'drive_share',
93
+ // Birthdays
94
+ 'birthday_add', 'birthday_update', 'birthday_delete',
95
+ // Alexandria messaging
96
+ 'alexandria_send',
97
+ // Cron / scheduling
98
+ 'cron_create', 'cron_delete',
90
99
  ]);
91
100
 
92
101
  // ── Tool Definitions (for system prompt) ─────────────────────────────────────