nothumanallowed 15.1.9 → 15.1.11

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.9",
3
+ "version": "15.1.11",
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": {
@@ -84,11 +84,42 @@ export async function cmdUI(args) {
84
84
  }
85
85
  } catch { /* port is free */ }
86
86
 
87
- // Auto-start ops daemon (Telegram + cron) if not already running
87
+ // Detect if we were spawned by the self-restart helper after an npm-update.
88
+ // The marker file is written by /api/npm-update right before exiting.
89
+ // This lets us:
90
+ // - confirm to the user that the upgrade landed (visible in the boot log)
91
+ // - guarantee the ops daemon (Telegram/Discord bot) is back up
92
+ let restartedFromUpdate = false;
93
+ try {
94
+ const fs = await import('fs');
95
+ const path = await import('path');
96
+ const os = await import('os');
97
+ const markerPath = path.join(os.default.homedir(), '.nha', 'last-restart.json');
98
+ if (fs.existsSync(markerPath)) {
99
+ const stat = fs.statSync(markerPath);
100
+ // Consider the marker fresh only if written within the last 5 minutes
101
+ if (Date.now() - stat.mtimeMs < 5 * 60_000) {
102
+ const meta = JSON.parse(fs.readFileSync(markerPath, 'utf-8'));
103
+ restartedFromUpdate = true;
104
+ console.log(` \x1b[0;32m↻\x1b[0m Restarted after update: v${meta.fromVersion} → v${meta.toVersion}`);
105
+ }
106
+ // Always clean up so we don't repeat the message on next manual restart
107
+ try { fs.unlinkSync(markerPath); } catch {}
108
+ }
109
+ } catch { /* non-fatal */ }
110
+
111
+ // Auto-start ops daemon (Telegram + cron) if not already running.
112
+ // After a self-restart this is what brings the Telegram bot back online —
113
+ // critical for VM users whose only control channel is Telegram.
88
114
  if (!isRunning()) {
89
115
  const result = startDaemon();
90
116
  if (result.ok) {
91
- console.log(` \x1b[0;32m✓\x1b[0m PAO daemon started (PID ${result.pid})`);
117
+ console.log(` \x1b[0;32m✓\x1b[0m PAO daemon ${restartedFromUpdate ? 'restarted' : 'started'} (PID ${result.pid})`);
118
+ } else {
119
+ console.log(` \x1b[0;31m✗\x1b[0m PAO daemon failed to start: ${result.error || 'unknown'}`);
120
+ if (restartedFromUpdate) {
121
+ console.log(` Telegram/Discord bot may be offline. Run "nha ops start" manually.`);
122
+ }
92
123
  }
93
124
  }
94
125
 
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.9';
8
+ export const VERSION = '15.1.11';
9
9
  export const BASE_URL = 'https://nothumanallowed.com/cli';
10
10
  export const API_BASE = 'https://nothumanallowed.com/api/v1';
11
11
 
@@ -376,15 +376,40 @@ export async function startServer({ port = 3847, host = '127.0.0.1', noBrowser =
376
376
 
377
377
  const server = http.createServer(handleRequest);
378
378
 
379
- // Attach WebSocket AFTER HTTP server is listening to avoid WS error events
380
- await new Promise((resolve, reject) => {
381
- server.once('error', reject);
379
+ // Retry the bind for up to 12 s on EADDRINUSE. The classic case is a fresh
380
+ // self-restart right after the previous process died: the listen socket can
381
+ // be stuck in TIME_WAIT (no PID owns it, so the lsof-kill above can't help).
382
+ // Linear backoff 600 ms × 20 = 12 s — more than enough on every kernel I've
383
+ // seen for a localhost listen socket to clear.
384
+ const tryListen = () => new Promise((resolve, reject) => {
385
+ const onErr = (err) => reject(err);
386
+ server.once('error', onErr);
382
387
  server.listen(port, host, () => {
383
- server.removeListener('error', reject);
388
+ server.removeListener('error', onErr);
384
389
  resolve(undefined);
385
390
  });
386
391
  });
387
392
 
393
+ let listenAttempts = 0;
394
+ const MAX_LISTEN_ATTEMPTS = 20;
395
+ while (true) {
396
+ try {
397
+ await tryListen();
398
+ break;
399
+ } catch (err) {
400
+ listenAttempts++;
401
+ const isAddrInUse = err && (err.code === 'EADDRINUSE' || err.code === 'EACCES');
402
+ if (!isAddrInUse || listenAttempts >= MAX_LISTEN_ATTEMPTS) {
403
+ throw err;
404
+ }
405
+ // Brief notice every 3 s so the user knows we're not stuck
406
+ if (listenAttempts % 5 === 0) {
407
+ console.log(`\x1b[33m ! Port ${port} still busy (TIME_WAIT?), retry ${listenAttempts}/${MAX_LISTEN_ATTEMPTS}...\x1b[0m`);
408
+ }
409
+ await new Promise((r) => setTimeout(r, 600));
410
+ }
411
+ }
412
+
388
413
  setupWebSocket(server);
389
414
 
390
415
  const G = '\x1b[0;32m', NC = '\x1b[0m', D = '\x1b[2m', BOLD = '\x1b[1m', Y = '\x1b[33m', R = '\x1b[31m';
@@ -146,10 +146,17 @@ export function register(router) {
146
146
  }
147
147
  } catch { /* ignore */ }
148
148
 
149
- // Stop the ops daemon if it's running it must reconnect with new code
149
+ // Remember if the ops daemon (Telegram/Discord bot) was running so the
150
+ // newly-spawned server can restart it. Without this, a VM user who
151
+ // controls everything via Telegram would lose the bot until they ran
152
+ // `nha ops start` manually.
153
+ let daemonWasRunning = false;
150
154
  try {
151
155
  const { isRunning, stopDaemon } = await import('../../services/ops-daemon.mjs');
152
- if (isRunning()) await stopDaemon();
156
+ if (isRunning()) {
157
+ daemonWasRunning = true;
158
+ await stopDaemon();
159
+ }
153
160
  } catch { /* ignore */ }
154
161
 
155
162
  // Send the response NOW, before we kick off the restart. The client
@@ -166,62 +173,87 @@ export function register(router) {
166
173
  });
167
174
 
168
175
  // ── Schedule the self-restart ──
169
- // We do this OUTSIDE the response cycle: a detached child that survives
170
- // our death, watches our PID, then re-spawns with the same argv.
171
- // On POSIX we use `sh -c`. On Windows we use a detached spawn directly
172
- // because cmd.exe handles the "wait for PID" idiom differently.
173
176
  const parentPid = process.pid;
174
- const respawnNode = process.execPath; // absolute path to node
175
- const respawnArgs = process.argv.slice(1); // [cli.mjs, "ui", ...]
176
- const argvJson = JSON.stringify({ node: respawnNode, args: respawnArgs, parentPid });
177
+ const respawnNode = process.execPath;
178
+ const respawnArgs = process.argv.slice(1);
179
+ const home = os.homedir();
180
+ const nhaDir = path.join(home, '.nha');
181
+ if (!fs.existsSync(nhaDir)) fs.mkdirSync(nhaDir, { recursive: true });
182
+
183
+ // Write the restart marker BEFORE spawning the helper. The new server
184
+ // reads this on boot to know:
185
+ // - the update happened (so it can show "Updated to v…" on first load)
186
+ // - whether the ops daemon (Telegram bot) was running and must be restarted
187
+ const markerPath = path.join(nhaDir, 'last-restart.json');
188
+ try {
189
+ fs.writeFileSync(
190
+ markerPath,
191
+ JSON.stringify({
192
+ at: new Date().toISOString(),
193
+ fromVersion: VERSION,
194
+ toVersion: newVersion,
195
+ daemonWasRunning,
196
+ parentPid,
197
+ }, null, 2),
198
+ );
199
+ } catch { /* non-fatal */ }
200
+
201
+ // Open log file for the detached child — if anything goes wrong on boot
202
+ // we want the user to be able to read why instead of staring at a black
203
+ // screen. Append mode so previous restart logs survive.
204
+ const logPath = path.join(nhaDir, 'server.log');
205
+ let logFd;
206
+ try {
207
+ logFd = fs.openSync(logPath, 'a');
208
+ fs.writeSync(logFd, `\n[${new Date().toISOString()}] === Restart helper spawning. Parent PID: ${parentPid}. Target version: ${newVersion} ===\n`);
209
+ } catch { /* will fall back to 'ignore' */ }
177
210
 
178
211
  setTimeout(() => {
179
212
  try {
180
213
  if (process.platform === 'win32') {
181
- // Windows: schedule via PowerShell wait-process then start the new node.
182
- const psScript = `
183
- try { Wait-Process -Id ${parentPid} -Timeout 30 } catch {};
184
- Start-Sleep -Milliseconds 1200;
185
- Start-Process -FilePath "${respawnNode.replace(/\\/g, '\\\\')}" -ArgumentList ${JSON.stringify(respawnArgs).replace(/"/g, '\\"')} -WindowStyle Hidden
186
- `.replace(/\s+/g, ' ').trim();
214
+ // Windows: PowerShell waits for the parent to die, frees the port,
215
+ // then starts node hidden. Output is redirected to server.log.
216
+ const psScript = [
217
+ `try { Wait-Process -Id ${parentPid} -Timeout 30 } catch {};`,
218
+ `Start-Sleep -Milliseconds 1500;`,
219
+ `$logPath = "${logPath.replace(/\\/g, '\\\\')}";`,
220
+ `Start-Process -FilePath "${respawnNode.replace(/\\/g, '\\\\')}"`,
221
+ ` -ArgumentList ${JSON.stringify(respawnArgs).replace(/"/g, '\\"')}`,
222
+ ` -RedirectStandardOutput $logPath -RedirectStandardError $logPath`,
223
+ ` -WindowStyle Hidden`,
224
+ ].join('').replace(/\s+/g, ' ').trim();
187
225
  spawn('powershell.exe', ['-NoProfile', '-Command', psScript], {
188
226
  detached: true,
189
227
  stdio: 'ignore',
190
228
  windowsHide: true,
191
229
  }).unref();
192
230
  } else {
193
- // POSIX: shell loop that polls `kill -0 PID`, then re-exec the cmd.
194
- // Using exec replaces the shell process so we don't leave a wrapper.
195
- const escapedArgs = respawnArgs.map(a => `'${a.replace(/'/g, `'\\''`)}'`).join(' ');
196
- const escapedNode = `'${respawnNode.replace(/'/g, `'\\''`)}'`;
231
+ // POSIX: poll for parent death (max ~12s), then wait an extra 2s
232
+ // for TIME_WAIT to clear on the listen socket, then exec node
233
+ // with stdio redirected to the log file.
234
+ const escArg = a => `'${a.replace(/'/g, `'\\''`)}'`;
235
+ const escapedArgs = respawnArgs.map(escArg).join(' ');
236
+ const escapedNode = escArg(respawnNode);
237
+ const escapedLog = escArg(logPath);
197
238
  const sh = `
198
239
  i=0
199
240
  while kill -0 ${parentPid} 2>/dev/null && [ $i -lt 60 ]; do
200
241
  sleep 0.2
201
242
  i=$((i+1))
202
243
  done
203
- sleep 1
204
- exec ${escapedNode} ${escapedArgs}
244
+ # Extra wait: lets TIME_WAIT on the listen socket release and
245
+ # gives the kernel a moment to fully reap the parent.
246
+ sleep 2
247
+ exec ${escapedNode} ${escapedArgs} >> ${escapedLog} 2>&1
205
248
  `;
249
+ const childStdio = logFd ? ['ignore', logFd, logFd] : 'ignore';
206
250
  spawn('sh', ['-c', sh], {
207
251
  detached: true,
208
- stdio: 'ignore',
252
+ stdio: childStdio,
209
253
  }).unref();
210
254
  }
211
-
212
- // Mark the restart so the next process knows it was self-spawned
213
- try {
214
- const home = os.homedir();
215
- const nhaDir = path.join(home, '.nha');
216
- if (!fs.existsSync(nhaDir)) fs.mkdirSync(nhaDir, { recursive: true });
217
- fs.writeFileSync(
218
- path.join(nhaDir, 'last-restart.json'),
219
- JSON.stringify({ at: new Date().toISOString(), fromVersion: VERSION, toVersion: newVersion, meta: argvJson }, null, 2),
220
- );
221
- } catch { /* non-fatal */ }
222
255
  } catch { /* spawn failed — user will need to restart manually */ }
223
256
 
224
- // Give the response a moment, then exit so the helper can take over.
225
257
  setTimeout(() => process.exit(0), 200);
226
258
  }, 600);
227
259
  } catch (e) {
@@ -374,6 +374,40 @@ async function checkNpmVersion() {
374
374
 
375
375
  // ── Telegram Bot (Long Polling via native fetch) ─────────────────────────────
376
376
 
377
+ // Persistent storage for per-chat conversational state. Without this, every
378
+ // process restart (npm self-update, crash, "nha ops stop && start") loses the
379
+ // sticky-agent + turn history, and the user's next "Procedi" / "Si" routes
380
+ // to a random agent because the planner has no thread to anchor on.
381
+ const TELEGRAM_CTX_FILE = path.join(os.homedir(), '.nha', 'telegram-context.json');
382
+ const TELEGRAM_CTX_MAX_AGE_MS = 30 * 60 * 1000; // 30 min — older than that, discard
383
+
384
+ function loadTelegramContext() {
385
+ try {
386
+ if (!fs.existsSync(TELEGRAM_CTX_FILE)) return {};
387
+ const raw = JSON.parse(fs.readFileSync(TELEGRAM_CTX_FILE, 'utf-8'));
388
+ const now = Date.now();
389
+ const fresh = {};
390
+ for (const [chatId, ctx] of Object.entries(raw)) {
391
+ if (ctx && typeof ctx.ts === 'number' && (now - ctx.ts) < TELEGRAM_CTX_MAX_AGE_MS) {
392
+ fresh[chatId] = ctx;
393
+ }
394
+ }
395
+ return fresh;
396
+ } catch { return {}; }
397
+ }
398
+
399
+ function saveTelegramContext(byChatId) {
400
+ try {
401
+ const dir = path.dirname(TELEGRAM_CTX_FILE);
402
+ if (!fs.existsSync(dir)) fs.mkdirSync(dir, { recursive: true });
403
+ // Atomic write — temp file + rename so a crash during write doesn't leave
404
+ // half-written JSON that breaks the next boot.
405
+ const tmp = TELEGRAM_CTX_FILE + '.tmp';
406
+ fs.writeFileSync(tmp, JSON.stringify(byChatId, null, 2));
407
+ fs.renameSync(tmp, TELEGRAM_CTX_FILE);
408
+ } catch { /* non-fatal */ }
409
+ }
410
+
377
411
  class TelegramResponder {
378
412
  constructor(config, log, wsBroadcast) {
379
413
  this.config = config;
@@ -389,9 +423,25 @@ class TelegramResponder {
389
423
  this.maxConcurrent = 3;
390
424
  this._updateCheckTimer = null;
391
425
  this._lastNotifiedVersion = null;
392
- // Per-chat sticky agent: remembers last agent used, plus last turn context
393
- this._lastAgentByChatId = {}; // chatId agentName
394
- this._lastContextByChatId = {}; // chatId → { agent, userMsg, agentReply, ts }
426
+ // Per-chat sticky agent restored from disk so self-restarts and npm
427
+ // updates don't break in-flight conversations.
428
+ this._lastContextByChatId = loadTelegramContext();
429
+ this._lastAgentByChatId = {};
430
+ for (const [chatId, ctx] of Object.entries(this._lastContextByChatId)) {
431
+ if (ctx && ctx.agent) this._lastAgentByChatId[chatId] = ctx.agent;
432
+ }
433
+ const restoredCount = Object.keys(this._lastContextByChatId).length;
434
+ if (restoredCount > 0) {
435
+ this.log(`[Telegram] Restored conversational context for ${restoredCount} chat(s) from disk`);
436
+ }
437
+ this._saveCtxTimer = null;
438
+ }
439
+
440
+ _persistContext() {
441
+ // Synchronous write — guarantees the context is on disk even if the
442
+ // process exits immediately after (npm self-update, SIGTERM, crash).
443
+ // The file is tiny (< 10 KB typical), so this stays sub-millisecond.
444
+ saveTelegramContext(this._lastContextByChatId);
395
445
  }
396
446
 
397
447
  get enabled() {
@@ -718,6 +768,10 @@ class TelegramResponder {
718
768
  ts: Date.now(),
719
769
  };
720
770
  this._lastAgentByChatId[chatId] = agent;
771
+ // Persist to disk so the context survives npm self-update / crashes.
772
+ // Without this, a restart between "HERALD proposes" and user's "Procedi"
773
+ // routes the confirmation to a random agent (FORGE in the reported bug).
774
+ this._persistContext();
721
775
 
722
776
  await this._telegramCall('sendMessage', {
723
777
  chat_id: chatId,
@@ -163,8 +163,18 @@ TOOLS:
163
163
  14. calendar_create(summary: string, start: string, end: string, attendees?: string[], description?: string)
164
164
  Create a NEW calendar event. start/end are ISO 8601 datetime strings.
165
165
  Use this when the user says: "inserisci", "aggiungi", "crea", "metti", "fissa", "prenota", "add", "create", "schedule", "book".
166
+
167
+ PARAMETER MEANING (CRITICAL — most common mistake):
168
+ - summary = the TITLE of the event (what you'd write on a calendar grid). Short, like "Tagliando BMW", "Riunione Cliente X", "Cena con Marta".
169
+ - description = optional NOTES/details. Leave empty unless the user gave extra context that doesn't fit in the title.
170
+ - NEVER put the title in description. NEVER leave summary empty.
171
+
172
+ Example — user says "fissa tagliando BMW per il 15 maggio alle 17":
173
+ { "action": "calendar_create", "params": { "summary": "Tagliando BMW", "start": "2026-05-15T17:00:00", "end": "2026-05-15T18:00:00" } }
174
+
166
175
  IMPORTANT: When user says "inserisci appuntamento" or "crea evento" → use calendar_create, NOT calendar_find.
167
176
  Extract the summary, date, and time from the user message. If end time is not specified, default to 1 hour after start.
177
+ The tool RESPONSE will include "(eventId: ABC123)" — REMEMBER this ID. If the user later says "correggi", "modifica", "cambia", "sposta", "elimina" referring to this same event, use calendar_update / calendar_move / calendar_delete with that exact eventId — do NOT create a second event.
168
178
 
169
179
  15. calendar_move(eventId: string, newStart: string, newEnd: string)
170
180
  Reschedule an event. ALWAYS confirm before moving.
@@ -178,7 +188,16 @@ TOOLS:
178
188
 
179
189
  18. calendar_update(eventId: string, summary?: string, location?: string, description?: string, start?: string, end?: string)
180
190
  Update ANY field of an existing calendar event: title, location, description, start time, end time.
181
- You MUST call calendar_find first to get the eventId. Only include fields that need to change. ALWAYS confirm before updating.
191
+ Only include fields that need to change.
192
+
193
+ HOW TO GET eventId — IN ORDER OF PREFERENCE:
194
+ 1. From your OWN previous tool response in this conversation: when you ran calendar_create earlier, the response said "Event \"X\" created ... (eventId: ABC123)". Use that ABC123 directly. Do NOT search again.
195
+ 2. If step 1 doesn't apply (event was created before this conversation), call calendar_find FIRST to look it up.
196
+
197
+ CRITICAL — "CORREGGI" / "MODIFICA" / "CAMBIA TITOLO" mappings:
198
+ When the user says "correggi", "modifica", "cambia", "rinomina", "sposta", "aggiorna" referring to the most recent event you just created, you MUST use calendar_update with the eventId from step 1 above.
199
+ Do NOT call calendar_create a second time — that would create a DUPLICATE event.
200
+ Example: you just created event ABC123 ("Visita BMW"). User says "correggi, il titolo è Tagliando BMW" → call calendar_update(eventId: "ABC123", summary: "Tagliando BMW"). Don't create a new event, don't delete-then-create.
182
201
 
183
202
  19. calendar_delete(eventId: string)
184
203
  Delete (permanently remove) a calendar event by its eventId.
@@ -657,29 +676,95 @@ When the user's request requires an action, output one or more fenced JSON block
657
676
  \`\`\`
658
677
  Multiple blocks allowed for chaining. Include natural text before/between/after blocks.
659
678
  Never output a JSON block as a suggestion — every block executes immediately.
660
- FINANCE: For financial analysis — ALWAYS call real data tools first (market_price → market_chart → market_indicators OR crypto_data + macro_data), then canvas_render with a full Chart.js HTML dashboard (dark theme: bg #070b0f, green #00ff41, amber #f59e0b, red #ff4444, blue #00e5ff). Never invent prices.
661
-
662
- AVAILABLE TOOLS:
663
- gmail_list · gmail_read · gmail_send · gmail_draft · gmail_reply · gmail_mark_read · gmail_mark_unread · gmail_archive · gmail_delete · gmail_send_attach
664
- imap_accounts · imap_list · imap_read · imap_send · imap_sync · imap_labels · imap_mark_read · imap_reply · imap_thread · imap_search · imap_mark_starred · imap_trash · imap_draft · imap_send_template · imap_bulk_send
665
- calendar_today · calendar_tomorrow · calendar_date · calendar_upcoming · calendar_week · calendar_month · calendar_create · calendar_move · calendar_find · calendar_update · calendar_delete · schedule_meeting · schedule_draft_email
666
- task_list · task_add · task_done · task_move · task_delete · task_clear · task_edit
667
- contact_search · contact_add · contact_update · contact_delete
668
- gtask_list · gtask_add · gtask_complete
669
- note_add · note_list
670
- github_issues · github_prs · github_notifications · github_create_issue
671
- notion_search · notion_page
672
- slack_channels · slack_messages · slack_send
673
- web_search · fetch_url · get_weather
674
- browser_open · browser_screenshot · browser_click · browser_type · browser_extract · browser_js · browser_wait · browser_scroll · browser_key · browser_close
675
- cron_add · cron_list · cron_remove
676
- screen_capture · screen_analyze
677
- canvas_render · canvas_clear
678
- collab_send · collab_read
679
- file_list · file_read · file_write · file_info · file_search
680
- drive_list · drive_read · drive_upload · drive_update · drive_delete · drive_info · drive_folder · drive_download
681
- maps_directions · notify_remind · birthdays_upcoming · birthday_add · execute_code
682
- market_price · market_chart · market_indicators · macro_data · crypto_data · market_news`.trim();
679
+
680
+ ## ABSOLUTE RULES (violate these and the user loses trust)
681
+
682
+ 1. NEVER invent tool output. If you need data (an event ID, a price, a search result), you MUST emit a tool JSON block and wait for its real response. Do NOT write fake "(eventId: 123456789)" or fake results.
683
+ 2. NEVER duplicate. If the user says "correggi/modifica/sposta/aggiorna" referring to an item you just created in this conversation, use the corresponding *_update / *_move tool with the eventId/taskId from your previous tool response. Do NOT create a second item.
684
+ 3. NEVER put the TITLE in the description field. The "summary" / "title" field is the SHORT name (what shows on a calendar grid). The "description" field is ONLY for extra notes.
685
+ 4. When in doubt, ASK ONE concise question do not invent details.
686
+
687
+ ## TOOL SIGNATURES (parameters and how to use them)
688
+
689
+ ### Calendar
690
+ calendar_create(summary, start, end, description?, attendees?, location?)
691
+ - summary = SHORT title (e.g. "Tagliando BMW"). REQUIRED. Do NOT leave empty.
692
+ - description = optional notes only. Do NOT put the title here.
693
+ - start/end = ISO 8601 datetimes. Default end = start + 1 hour.
694
+ - The tool response includes "(eventId: ABC123)" — REMEMBER it for later updates.
695
+ Example user: "fissa tagliando BMW 15 maggio ore 17":
696
+ {"action":"calendar_create","params":{"summary":"Tagliando BMW","start":"2026-05-15T17:00:00","end":"2026-05-15T18:00:00"}}
697
+
698
+ calendar_update(eventId, summary?, location?, description?, start?, end?)
699
+ - Use this for "correggi", "modifica", "rinomina", "cambia titolo", "sposta", "aggiorna" referring to an event you JUST created or found.
700
+ - eventId comes from the previous calendar_create response or calendar_find result. NEVER guess it.
701
+ - Example you just created event ABC123. User: "correggi, il titolo è Tagliando BMW":
702
+ {"action":"calendar_update","params":{"eventId":"ABC123","summary":"Tagliando BMW"}}
703
+
704
+ calendar_delete(eventId) — Delete an event you have the eventId for. Confirm first.
705
+ calendar_move(eventId, newStart, newEnd) — Reschedule.
706
+ calendar_find(query, daysAhead?) — Search events by name. Returns eventIds. Use ONLY when you don't already have the id.
707
+ calendar_today() / calendar_tomorrow() / calendar_date(date) / calendar_upcoming(hours?) / calendar_week(startDate?) / calendar_month(month?) — Read-only listings.
708
+ schedule_meeting(clientName, subject, location, durationMinutes, dateFrom, dateTo) — Find optimal slots considering travel time.
709
+
710
+ ### Gmail / IMAP
711
+ gmail_list(limit?, query?) — List recent emails. query supports Gmail search syntax.
712
+ gmail_read(messageId) — Read full email body.
713
+ gmail_send(to, subject, body, cc?, bcc?) — Send a new email.
714
+ gmail_reply(messageId, body) — Reply to a thread.
715
+ gmail_draft(to, subject, body) — Save a draft without sending.
716
+ gmail_mark_read(messageId) / gmail_mark_unread(messageId) / gmail_archive(messageId) / gmail_delete(messageId)
717
+ gmail_send_attach(to, subject, body, filePath) — Send with attachment.
718
+ imap_* — Same operations for non-Gmail accounts. imap_send_template/imap_bulk_send for templated sends.
719
+
720
+ ### Tasks
721
+ task_list() / task_add(text, dueDate?) / task_done(index) / task_edit(index, newText) / task_move(fromIndex, toIndex) / task_delete(index) / task_clear()
722
+ gtask_list() / gtask_add(text) / gtask_complete(taskId) — Google Tasks.
723
+
724
+ ### Contacts
725
+ contact_search(query) / contact_add(name, email?, phone?) / contact_update(id, fields) / contact_delete(id)
726
+
727
+ ### Web + research
728
+ web_search(query) — Search the web (returns title/URL/snippet for top results).
729
+ fetch_url(url) — Fetch a page; returns title, meta description, OG tags, JSON-LD, main content. Use the full URL with https://. For naked domains (www.x.com), prepend https://.
730
+ get_weather(location) — Returns current conditions + 3-day forecast.
731
+ maps_directions(origin, destination)
732
+
733
+ ### Browser automation (for JS-rendered pages or interaction)
734
+ browser_open(url) → returns sessionId. browser_screenshot(sessionId) · browser_click(sessionId, selector) · browser_type(sessionId, selector, text) · browser_extract(sessionId, selector) · browser_js(sessionId, code) · browser_wait(sessionId, ms) · browser_scroll(sessionId, deltaY) · browser_key(sessionId, key) · browser_close(sessionId)
735
+
736
+ ### Notes / GitHub / Notion / Slack
737
+ note_add(text) · note_list()
738
+ github_issues(repo) · github_prs(repo) · github_notifications() · github_create_issue(repo, title, body)
739
+ notion_search(query) · notion_page(pageId)
740
+ slack_channels() · slack_messages(channel) · slack_send(channel, text)
741
+
742
+ ### Files / Drive
743
+ file_list(dir?) · file_read(path) · file_write(path, content) · file_info(path) · file_search(query)
744
+ drive_list(folderId?) · drive_read(fileId) · drive_upload(name, content, folderId?) · drive_update(fileId, content) · drive_delete(fileId) · drive_info(fileId) · drive_folder(name, parentId?) · drive_download(fileId, savePath)
745
+
746
+ ### Reminders / automation
747
+ notify_remind(text, when) — Notify the user at a future time.
748
+ birthdays_upcoming(days?) · birthday_add(name, date)
749
+ cron_add(schedule, prompt) · cron_list() · cron_remove(index)
750
+
751
+ ### Screen / canvas / collab
752
+ screen_capture() / screen_analyze(question?)
753
+ canvas_render(html, title?) — Render an HTML report/dashboard in the canvas panel.
754
+ collab_send(channel, text) · collab_read(channel)
755
+
756
+ ### Finance
757
+ market_price(symbol) · market_chart(symbol, period?) · market_indicators(symbol) · macro_data(metric, country?) · crypto_data(coin) · market_news(symbol?)
758
+ FINANCE FLOW: always call real data tools first (market_price → market_chart → market_indicators OR crypto_data + macro_data), then canvas_render with a Chart.js HTML dashboard (dark theme: bg #070b0f, green #00ff41, amber #f59e0b, red #ff4444, blue #00e5ff). Never invent prices.
759
+
760
+ ### Code
761
+ execute_code(language, code) — Run Python/JS/shell in a sandbox.
762
+
763
+ ## CONFIRMATIONS
764
+
765
+ Write operations that change state (gmail_send/reply/delete, calendar_create/update/move/delete, contact_delete, task_done/delete, notify_remind, file_write, drive_upload/update/delete) — describe what you're about to do in natural language first, then emit the JSON block. The system shows the user what's pending and acts on confirmation.
766
+
767
+ If the user already confirmed in the previous turn ("procedi", "sì", "fallo"), execute the pending action directly using the parameters from that prior turn — do NOT ask again or restart from scratch.`.trim();
683
768
 
684
769
  // ── Action Parser ────────────────────────────────────────────────────────────
685
770
 
@@ -1309,14 +1394,43 @@ export async function executeTool(action, params, config) {
1309
1394
  }
1310
1395
 
1311
1396
  case 'calendar_create': {
1312
- await createEvent(config, {
1313
- summary: params.summary,
1397
+ // Robustness: accept common param-name aliases that the model often
1398
+ // emits instead of `summary` (Google Calendar's official field name
1399
+ // for the event title — but LLMs trained on natural language confuse
1400
+ // "summary" with "description/note", so they sometimes put the actual
1401
+ // title under `title`/`name`/`subject` or even leave summary empty
1402
+ // and put the title text in `description`).
1403
+ let summary = params.summary || params.title || params.name || params.subject || '';
1404
+ let description = params.description || params.notes || '';
1405
+
1406
+ // Last-resort fallback: if summary is still empty but description
1407
+ // looks like a title (one line, < 120 chars, no period), promote it.
1408
+ if (!summary.trim() && description.trim()) {
1409
+ const firstLine = description.split('\n')[0].trim();
1410
+ if (firstLine.length > 0 && firstLine.length < 120) {
1411
+ summary = firstLine;
1412
+ // If description was JUST the title, clear it — otherwise keep the rest
1413
+ const rest = description.split('\n').slice(1).join('\n').trim();
1414
+ description = rest;
1415
+ }
1416
+ }
1417
+
1418
+ if (!summary.trim()) {
1419
+ return 'Error: event title (summary) is required. Please specify what the event is about.';
1420
+ }
1421
+
1422
+ const created = await createEvent(config, {
1423
+ summary,
1314
1424
  start: params.start,
1315
1425
  end: params.end,
1316
- description: params.description || '',
1426
+ description,
1317
1427
  attendees: params.attendees || [],
1318
1428
  });
1319
- return `Event "${params.summary}" created for ${formatTime(params.start)} - ${formatTime(params.end)}.`;
1429
+ // Surface the eventId so the model/Telegram responder can use it for
1430
+ // subsequent calendar_update or calendar_delete in the same turn.
1431
+ const eventId = created?.id || created?.eventId || '';
1432
+ const idHint = eventId ? ` (eventId: ${eventId})` : '';
1433
+ return `Event "${summary}" created for ${formatTime(params.start)} - ${formatTime(params.end)}.${idHint}`;
1320
1434
  }
1321
1435
 
1322
1436
  case 'calendar_move': {
@@ -1432,10 +1546,15 @@ export async function executeTool(action, params, config) {
1432
1546
  }
1433
1547
  }
1434
1548
 
1549
+ // Accept same param-name aliases as calendar_create so the model can
1550
+ // be sloppy about title vs summary without breaking things.
1551
+ const newSummary = params.summary || params.title || params.name || params.subject;
1552
+ const newDescription = params.description ?? params.notes;
1553
+
1435
1554
  const patch = {};
1436
- if (params.summary) patch.summary = params.summary;
1555
+ if (newSummary) patch.summary = newSummary;
1437
1556
  if (params.location) patch.location = params.location;
1438
- if (params.description) patch.description = params.description;
1557
+ if (newDescription !== undefined) patch.description = newDescription;
1439
1558
  if (params.start) {
1440
1559
  const tz = Intl.DateTimeFormat().resolvedOptions().timeZone;
1441
1560
  patch.start = { dateTime: new Date(params.start).toISOString(), timeZone: tz };
@@ -1444,9 +1563,12 @@ export async function executeTool(action, params, config) {
1444
1563
  const tz = Intl.DateTimeFormat().resolvedOptions().timeZone;
1445
1564
  patch.end = { dateTime: new Date(params.end).toISOString(), timeZone: tz };
1446
1565
  }
1566
+ if (Object.keys(patch).length === 0) {
1567
+ return 'No fields to update. Specify at least one of: summary (title), location, description, start, end.';
1568
+ }
1447
1569
  await updateEvent(config, 'primary', eventId, patch);
1448
1570
  const changes = Object.keys(patch).join(', ');
1449
- return `Event updated successfully (${changes}). ${params.location ? `New location: ${params.location}` : ''}`;
1571
+ return `Event updated successfully (changed: ${changes})${newSummary ? `. New title: "${newSummary}"` : ''}${params.location ? `. New location: ${params.location}` : ''}`;
1450
1572
  }
1451
1573
 
1452
1574
  case 'calendar_delete': {