nothumanallowed 6.0.0 → 6.1.0
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 +1 -1
- package/src/commands/chat.mjs +69 -0
- package/src/commands/ui.mjs +66 -0
- package/src/commands/voice.mjs +28 -0
- package/src/constants.mjs +1 -1
- package/src/services/ops-daemon.mjs +1 -2
- package/src/services/smart-scheduler.mjs +409 -0
- package/src/services/tool-executor.mjs +76 -0
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "nothumanallowed",
|
|
3
|
-
"version": "6.
|
|
3
|
+
"version": "6.1.0",
|
|
4
4
|
"description": "NotHumanAllowed — 38 AI agents for security, code, DevOps, data & daily ops. Per-agent memory, Telegram + Discord auto-responder, proactive intelligence daemon, voice chat, plugin system.",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"bin": {
|
package/src/commands/chat.mjs
CHANGED
|
@@ -115,12 +115,24 @@ TOOLS:
|
|
|
115
115
|
15. notify_remind(message: string, atTime: string)
|
|
116
116
|
Set a desktop reminder. atTime is ISO 8601 or relative like "in 30 minutes".
|
|
117
117
|
|
|
118
|
+
16. calendar_week(startDate?: string)
|
|
119
|
+
List all events for a full week starting from startDate (YYYY-MM-DD). Defaults to current week.
|
|
120
|
+
|
|
121
|
+
17. schedule_meeting(clientName: string, subject: string, location: string, durationMinutes: number, dateFrom: string, dateTo: string)
|
|
122
|
+
Find optimal meeting slots considering existing calendar, locations, and travel time between appointments.
|
|
123
|
+
Returns ranked slots with travel estimates. Use this when the user wants to schedule a new meeting.
|
|
124
|
+
|
|
125
|
+
18. schedule_draft_email(clientName: string, subject: string, location: string, durationMinutes: number, dateFrom: string, dateTo: string)
|
|
126
|
+
Same as schedule_meeting but also generates a professional email proposing the top 3 slots to the client.
|
|
127
|
+
|
|
118
128
|
RULES:
|
|
119
129
|
- For search/read operations, execute immediately and present results conversationally.
|
|
120
130
|
- For write/send/delete operations (gmail_send, gmail_reply, calendar_create, calendar_move, task_done, notify_remind), DESCRIBE what you're about to do and include the JSON block so the system can ask the user for confirmation.
|
|
131
|
+
- For schedule_meeting and schedule_draft_email, execute immediately — these are read operations that suggest slots.
|
|
121
132
|
- When presenting email results, show From, Subject, Date, and a brief snippet. Never dump raw JSON.
|
|
122
133
|
- When presenting calendar events, show Time, Title, Location/Link. Format times in a human-readable way.
|
|
123
134
|
- When presenting tasks, show ID, Description, Priority, Status.
|
|
135
|
+
- When presenting slot proposals, show day, date, time range, and travel info clearly.
|
|
124
136
|
- If you need multiple actions in sequence (e.g., read an email then reply), do them ONE AT A TIME — wait for the result of each before proceeding.
|
|
125
137
|
- Dates: today is {{TODAY}}. Infer relative dates from this.
|
|
126
138
|
- The user's timezone is {{TIMEZONE}}.
|
|
@@ -282,6 +294,63 @@ async function executeTool(action, params, config) {
|
|
|
282
294
|
return `Reminder set for ${formatTime(atTime.toISOString())} (in ~${minutes} min): "${params.message}"`;
|
|
283
295
|
}
|
|
284
296
|
|
|
297
|
+
// ── Smart Scheduling ──────────────────────────────────────────────────
|
|
298
|
+
case 'calendar_week': {
|
|
299
|
+
const { listEvents: listEventsRouter } = await import('../services/mail-router.mjs');
|
|
300
|
+
const startDate = params.startDate || new Date().toISOString().split('T')[0];
|
|
301
|
+
const from = new Date(startDate + 'T00:00:00');
|
|
302
|
+
const to = new Date(from.getTime() + 7 * 86400000);
|
|
303
|
+
const events = await listEventsRouter(config, 'primary', from, to);
|
|
304
|
+
if (events.length === 0) return `No events for the week starting ${startDate}.`;
|
|
305
|
+
const byDay = new Map();
|
|
306
|
+
for (const e of events) {
|
|
307
|
+
const day = e.start.split('T')[0];
|
|
308
|
+
if (!byDay.has(day)) byDay.set(day, []);
|
|
309
|
+
byDay.get(day).push(e);
|
|
310
|
+
}
|
|
311
|
+
const lines = [];
|
|
312
|
+
for (const [day, dayEvents] of [...byDay.entries()].sort()) {
|
|
313
|
+
const dayName = new Date(day).toLocaleDateString('en-US', { weekday: 'long' });
|
|
314
|
+
lines.push(`\n${dayName} ${day} (${dayEvents.length} events):`);
|
|
315
|
+
for (const e of dayEvents) {
|
|
316
|
+
const time = e.isAllDay ? 'All day' : `${formatTime(e.start)} - ${formatTime(e.end)}`;
|
|
317
|
+
const loc = e.location ? ` @ ${e.location}` : '';
|
|
318
|
+
lines.push(` ${time} — ${e.summary}${loc}`);
|
|
319
|
+
}
|
|
320
|
+
}
|
|
321
|
+
return lines.join('\n');
|
|
322
|
+
}
|
|
323
|
+
|
|
324
|
+
case 'schedule_meeting': {
|
|
325
|
+
const { findAvailableSlots, formatSlotProposal } = await import('../services/smart-scheduler.mjs');
|
|
326
|
+
const slots = await findAvailableSlots(config, {
|
|
327
|
+
meetingLocation: params.location || '',
|
|
328
|
+
durationMinutes: params.durationMinutes || 60,
|
|
329
|
+
dateFrom: params.dateFrom,
|
|
330
|
+
dateTo: params.dateTo,
|
|
331
|
+
workdayStart: params.workdayStart || 9,
|
|
332
|
+
workdayEnd: params.workdayEnd || 18,
|
|
333
|
+
maxSlots: 5,
|
|
334
|
+
});
|
|
335
|
+
return formatSlotProposal(slots, params.clientName || 'the client', params.subject || 'meeting');
|
|
336
|
+
}
|
|
337
|
+
|
|
338
|
+
case 'schedule_draft_email': {
|
|
339
|
+
const { findAvailableSlots: findSlots, formatSlotProposal: fmtProposal, generateSlotMessage } = await import('../services/smart-scheduler.mjs');
|
|
340
|
+
const slots = await findSlots(config, {
|
|
341
|
+
meetingLocation: params.location || '',
|
|
342
|
+
durationMinutes: params.durationMinutes || 60,
|
|
343
|
+
dateFrom: params.dateFrom,
|
|
344
|
+
dateTo: params.dateTo,
|
|
345
|
+
workdayStart: 9,
|
|
346
|
+
workdayEnd: 18,
|
|
347
|
+
maxSlots: 5,
|
|
348
|
+
});
|
|
349
|
+
const proposal = fmtProposal(slots, params.clientName || 'the client', params.subject || 'meeting');
|
|
350
|
+
const email = generateSlotMessage(slots, params.clientName || 'the client', params.subject || 'meeting');
|
|
351
|
+
return `${proposal}\n\n--- DRAFT EMAIL ---\n\n${email}`;
|
|
352
|
+
}
|
|
353
|
+
|
|
285
354
|
default:
|
|
286
355
|
return `Unknown action: ${action}`;
|
|
287
356
|
}
|
package/src/commands/ui.mjs
CHANGED
|
@@ -81,6 +81,15 @@ TOOLS:
|
|
|
81
81
|
11. task_done(id: number)
|
|
82
82
|
Mark a task as completed.
|
|
83
83
|
|
|
84
|
+
12. calendar_week(startDate?: string)
|
|
85
|
+
List all events for a full week starting from startDate (YYYY-MM-DD). Defaults to current week.
|
|
86
|
+
|
|
87
|
+
13. schedule_meeting(clientName: string, subject: string, location: string, durationMinutes: number, dateFrom: string, dateTo: string)
|
|
88
|
+
Find optimal meeting slots considering existing calendar, locations, and travel time between appointments.
|
|
89
|
+
|
|
90
|
+
14. schedule_draft_email(clientName: string, subject: string, location: string, durationMinutes: number, dateFrom: string, dateTo: string)
|
|
91
|
+
Same as schedule_meeting but also generates a professional email proposing the top 3 slots.
|
|
92
|
+
|
|
84
93
|
RULES:
|
|
85
94
|
- For search/read operations, execute immediately and present results conversationally.
|
|
86
95
|
- For write/send/delete operations, describe what you're about to do and include the JSON block.
|
|
@@ -233,6 +242,63 @@ async function executeTool(action, params, config) {
|
|
|
233
242
|
const success = completeTask(params.id);
|
|
234
243
|
return success ? `Task #${params.id} marked as done.` : `Task #${params.id} not found.`;
|
|
235
244
|
}
|
|
245
|
+
|
|
246
|
+
case 'calendar_week': {
|
|
247
|
+
const { listEvents: listEventsRouter } = await import('../services/mail-router.mjs');
|
|
248
|
+
const startDate = params.startDate || new Date().toISOString().split('T')[0];
|
|
249
|
+
const from = new Date(startDate + 'T00:00:00');
|
|
250
|
+
const to = new Date(from.getTime() + 7 * 86400000);
|
|
251
|
+
const events = await listEventsRouter(config, 'primary', from, to);
|
|
252
|
+
if (events.length === 0) return `No events for the week starting ${startDate}.`;
|
|
253
|
+
const byDay = new Map();
|
|
254
|
+
for (const e of events) {
|
|
255
|
+
const day = e.start.split('T')[0];
|
|
256
|
+
if (!byDay.has(day)) byDay.set(day, []);
|
|
257
|
+
byDay.get(day).push(e);
|
|
258
|
+
}
|
|
259
|
+
const lines = [];
|
|
260
|
+
for (const [day, dayEvents] of [...byDay.entries()].sort()) {
|
|
261
|
+
const dayName = new Date(day).toLocaleDateString('en-US', { weekday: 'long' });
|
|
262
|
+
lines.push(`\n${dayName} ${day} (${dayEvents.length} events):`);
|
|
263
|
+
for (const e of dayEvents) {
|
|
264
|
+
const time = e.isAllDay ? 'All day' : `${fmtTime(e.start)} - ${fmtTime(e.end)}`;
|
|
265
|
+
const loc = e.location ? ` @ ${e.location}` : '';
|
|
266
|
+
lines.push(` ${time} — ${e.summary}${loc}`);
|
|
267
|
+
}
|
|
268
|
+
}
|
|
269
|
+
return lines.join('\n');
|
|
270
|
+
}
|
|
271
|
+
|
|
272
|
+
case 'schedule_meeting': {
|
|
273
|
+
const { findAvailableSlots, formatSlotProposal } = await import('../services/smart-scheduler.mjs');
|
|
274
|
+
const slots = await findAvailableSlots(config, {
|
|
275
|
+
meetingLocation: params.location || '',
|
|
276
|
+
durationMinutes: params.durationMinutes || 60,
|
|
277
|
+
dateFrom: params.dateFrom,
|
|
278
|
+
dateTo: params.dateTo,
|
|
279
|
+
workdayStart: params.workdayStart || 9,
|
|
280
|
+
workdayEnd: params.workdayEnd || 18,
|
|
281
|
+
maxSlots: 5,
|
|
282
|
+
});
|
|
283
|
+
return formatSlotProposal(slots, params.clientName || 'the client', params.subject || 'meeting');
|
|
284
|
+
}
|
|
285
|
+
|
|
286
|
+
case 'schedule_draft_email': {
|
|
287
|
+
const { findAvailableSlots: findSlots, formatSlotProposal: fmtProposal, generateSlotMessage } = await import('../services/smart-scheduler.mjs');
|
|
288
|
+
const slots = await findSlots(config, {
|
|
289
|
+
meetingLocation: params.location || '',
|
|
290
|
+
durationMinutes: params.durationMinutes || 60,
|
|
291
|
+
dateFrom: params.dateFrom,
|
|
292
|
+
dateTo: params.dateTo,
|
|
293
|
+
workdayStart: 9,
|
|
294
|
+
workdayEnd: 18,
|
|
295
|
+
maxSlots: 5,
|
|
296
|
+
});
|
|
297
|
+
const proposal = fmtProposal(slots, params.clientName || 'the client', params.subject || 'meeting');
|
|
298
|
+
const email = generateSlotMessage(slots, params.clientName || 'the client', params.subject || 'meeting');
|
|
299
|
+
return `${proposal}\n\n--- DRAFT EMAIL ---\n\n${email}`;
|
|
300
|
+
}
|
|
301
|
+
|
|
236
302
|
default:
|
|
237
303
|
return `Unknown action: ${action}`;
|
|
238
304
|
}
|
package/src/commands/voice.mjs
CHANGED
|
@@ -71,6 +71,12 @@ TOOLS:
|
|
|
71
71
|
10. task_done(id: number)
|
|
72
72
|
Mark a task as completed.
|
|
73
73
|
|
|
74
|
+
11. calendar_week(startDate?: string)
|
|
75
|
+
List all events for a full week. Defaults to current week.
|
|
76
|
+
|
|
77
|
+
12. schedule_meeting(clientName: string, subject: string, location: string, durationMinutes: number, dateFrom: string, dateTo: string)
|
|
78
|
+
Find optimal meeting slots considering calendar, locations, and travel time.
|
|
79
|
+
|
|
74
80
|
RULES:
|
|
75
81
|
- For search/read operations, execute immediately and present results conversationally.
|
|
76
82
|
- For write/send/delete operations, describe what you're about to do and include the JSON block.
|
|
@@ -184,6 +190,28 @@ async function executeTool(action, params, config) {
|
|
|
184
190
|
const success = completeTask(params.id);
|
|
185
191
|
return success ? `Task ${params.id} marked as done.` : `Task ${params.id} not found.`;
|
|
186
192
|
}
|
|
193
|
+
case 'calendar_week': {
|
|
194
|
+
const { listEvents: lr } = await import('../services/mail-router.mjs');
|
|
195
|
+
const startDate = params.startDate || new Date().toISOString().split('T')[0];
|
|
196
|
+
const from = new Date(startDate + 'T00:00:00');
|
|
197
|
+
const to = new Date(from.getTime() + 7 * 86400000);
|
|
198
|
+
const events = await lr(config, 'primary', from, to);
|
|
199
|
+
if (events.length === 0) return `No events for the week starting ${startDate}.`;
|
|
200
|
+
return events.map((e, i) => `${i + 1}. ${e.start.split('T')[0]} ${fmtTime(e.start)}, ${e.summary}${e.location ? ' at ' + e.location : ''}`).join('\n');
|
|
201
|
+
}
|
|
202
|
+
case 'schedule_meeting': {
|
|
203
|
+
const { findAvailableSlots, formatSlotProposal } = await import('../services/smart-scheduler.mjs');
|
|
204
|
+
const slots = await findAvailableSlots(config, {
|
|
205
|
+
meetingLocation: params.location || '',
|
|
206
|
+
durationMinutes: params.durationMinutes || 60,
|
|
207
|
+
dateFrom: params.dateFrom,
|
|
208
|
+
dateTo: params.dateTo,
|
|
209
|
+
workdayStart: 9,
|
|
210
|
+
workdayEnd: 18,
|
|
211
|
+
maxSlots: 3,
|
|
212
|
+
});
|
|
213
|
+
return formatSlotProposal(slots, params.clientName || 'the client', params.subject || 'meeting');
|
|
214
|
+
}
|
|
187
215
|
default:
|
|
188
216
|
return `Unknown action: ${action}`;
|
|
189
217
|
}
|
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 = '6.
|
|
8
|
+
export const VERSION = '6.1.0';
|
|
9
9
|
export const BASE_URL = 'https://nothumanallowed.com/cli';
|
|
10
10
|
export const API_BASE = 'https://nothumanallowed.com/api/v1';
|
|
11
11
|
|
|
@@ -21,7 +21,7 @@ import path from 'path';
|
|
|
21
21
|
import http from 'http';
|
|
22
22
|
import crypto from 'crypto';
|
|
23
23
|
import { spawn } from 'child_process';
|
|
24
|
-
import { NHA_DIR } from '../constants.mjs';
|
|
24
|
+
import { NHA_DIR, DAEMON_SCRIPT } from '../constants.mjs';
|
|
25
25
|
import { loadConfig } from '../config.mjs';
|
|
26
26
|
import { hasMailProvider, getUnreadImportant, getUpcomingEvents, getTodayEmails, listEvents } from './mail-router.mjs';
|
|
27
27
|
import { notify } from './notification.mjs';
|
|
@@ -68,7 +68,6 @@ export function startDaemon() {
|
|
|
68
68
|
|
|
69
69
|
// The daemon runs this same file with --daemon-loop flag
|
|
70
70
|
const logFd = fs.openSync(LOG_FILE, 'a');
|
|
71
|
-
const { DAEMON_SCRIPT } = await import('../constants.mjs');
|
|
72
71
|
const child = spawn(process.execPath, [
|
|
73
72
|
DAEMON_SCRIPT,
|
|
74
73
|
'--daemon-loop',
|
|
@@ -0,0 +1,409 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Smart Scheduler — finds optimal meeting slots considering existing calendar
|
|
3
|
+
* events, locations, and travel time estimates.
|
|
4
|
+
*
|
|
5
|
+
* Zero dependencies. Uses Google Calendar / Outlook data already fetched
|
|
6
|
+
* via mail-router.mjs.
|
|
7
|
+
*
|
|
8
|
+
* Travel time estimation:
|
|
9
|
+
* - Same city: 30 min buffer
|
|
10
|
+
* - Different city (< 200km): 90 min buffer
|
|
11
|
+
* - Different city (> 200km): 180 min buffer
|
|
12
|
+
* - Remote/virtual (no location): 15 min buffer
|
|
13
|
+
* - No location on both: 0 min buffer
|
|
14
|
+
*/
|
|
15
|
+
|
|
16
|
+
import { listEvents } from './mail-router.mjs';
|
|
17
|
+
|
|
18
|
+
// ── Travel Time Estimation ──────────────────────────────────────────────────
|
|
19
|
+
|
|
20
|
+
/**
|
|
21
|
+
* Well-known Italian cities with approximate lat/lon for distance estimation.
|
|
22
|
+
* Covers the major cities. Falls back to "different city" for unknown locations.
|
|
23
|
+
*/
|
|
24
|
+
const CITY_COORDS = {
|
|
25
|
+
'milano': { lat: 45.46, lon: 9.19 },
|
|
26
|
+
'milan': { lat: 45.46, lon: 9.19 },
|
|
27
|
+
'roma': { lat: 41.90, lon: 12.50 },
|
|
28
|
+
'rome': { lat: 41.90, lon: 12.50 },
|
|
29
|
+
'napoli': { lat: 40.85, lon: 14.27 },
|
|
30
|
+
'naples': { lat: 40.85, lon: 14.27 },
|
|
31
|
+
'torino': { lat: 45.07, lon: 7.69 },
|
|
32
|
+
'turin': { lat: 45.07, lon: 7.69 },
|
|
33
|
+
'firenze': { lat: 43.77, lon: 11.25 },
|
|
34
|
+
'florence': { lat: 43.77, lon: 11.25 },
|
|
35
|
+
'bologna': { lat: 44.49, lon: 11.34 },
|
|
36
|
+
'genova': { lat: 44.41, lon: 8.93 },
|
|
37
|
+
'genoa': { lat: 44.41, lon: 8.93 },
|
|
38
|
+
'venezia': { lat: 45.44, lon: 12.32 },
|
|
39
|
+
'venice': { lat: 45.44, lon: 12.32 },
|
|
40
|
+
'verona': { lat: 45.44, lon: 10.99 },
|
|
41
|
+
'padova': { lat: 45.41, lon: 11.88 },
|
|
42
|
+
'trieste': { lat: 45.65, lon: 13.78 },
|
|
43
|
+
'brescia': { lat: 45.54, lon: 10.21 },
|
|
44
|
+
'parma': { lat: 44.80, lon: 10.33 },
|
|
45
|
+
'modena': { lat: 44.65, lon: 10.93 },
|
|
46
|
+
'reggio emilia': { lat: 44.70, lon: 10.63 },
|
|
47
|
+
'reggio': { lat: 44.70, lon: 10.63 },
|
|
48
|
+
'piacenza': { lat: 45.05, lon: 9.69 },
|
|
49
|
+
'ferrara': { lat: 44.84, lon: 11.62 },
|
|
50
|
+
'ravenna': { lat: 44.42, lon: 12.20 },
|
|
51
|
+
'rimini': { lat: 44.06, lon: 12.57 },
|
|
52
|
+
'perugia': { lat: 43.11, lon: 12.39 },
|
|
53
|
+
'bari': { lat: 41.13, lon: 16.87 },
|
|
54
|
+
'catania': { lat: 37.50, lon: 15.09 },
|
|
55
|
+
'palermo': { lat: 38.12, lon: 13.36 },
|
|
56
|
+
'cagliari': { lat: 39.22, lon: 9.12 },
|
|
57
|
+
'bergamo': { lat: 45.69, lon: 9.67 },
|
|
58
|
+
'como': { lat: 45.81, lon: 9.08 },
|
|
59
|
+
'monza': { lat: 45.58, lon: 9.27 },
|
|
60
|
+
'vicenza': { lat: 45.55, lon: 11.55 },
|
|
61
|
+
'treviso': { lat: 45.67, lon: 12.24 },
|
|
62
|
+
'udine': { lat: 46.07, lon: 13.24 },
|
|
63
|
+
'ancona': { lat: 43.62, lon: 13.52 },
|
|
64
|
+
'pescara': { lat: 42.46, lon: 14.21 },
|
|
65
|
+
'lecce': { lat: 40.35, lon: 18.17 },
|
|
66
|
+
'salerno': { lat: 40.68, lon: 14.77 },
|
|
67
|
+
'sassari': { lat: 40.73, lon: 8.56 },
|
|
68
|
+
'trento': { lat: 46.07, lon: 11.12 },
|
|
69
|
+
'bolzano': { lat: 46.50, lon: 11.35 },
|
|
70
|
+
'aosta': { lat: 45.74, lon: 7.32 },
|
|
71
|
+
'london': { lat: 51.51, lon: -0.13 },
|
|
72
|
+
'paris': { lat: 48.86, lon: 2.35 },
|
|
73
|
+
'berlin': { lat: 52.52, lon: 13.41 },
|
|
74
|
+
'amsterdam': { lat: 52.37, lon: 4.90 },
|
|
75
|
+
'zurich': { lat: 47.38, lon: 8.54 },
|
|
76
|
+
'munich': { lat: 48.14, lon: 11.58 },
|
|
77
|
+
'vienna': { lat: 48.21, lon: 16.37 },
|
|
78
|
+
'barcelona': { lat: 41.39, lon: 2.17 },
|
|
79
|
+
'madrid': { lat: 40.42, lon: -3.70 },
|
|
80
|
+
'lisbon': { lat: 38.72, lon: -9.14 },
|
|
81
|
+
'new york': { lat: 40.71, lon: -74.01 },
|
|
82
|
+
'san francisco': { lat: 37.77, lon: -122.42 },
|
|
83
|
+
};
|
|
84
|
+
|
|
85
|
+
const VIRTUAL_KEYWORDS = [
|
|
86
|
+
'zoom', 'meet', 'teams', 'webex', 'hangout', 'remote', 'online',
|
|
87
|
+
'virtual', 'call', 'video', 'teleconferenza', 'videocall',
|
|
88
|
+
'google meet', 'skype', 'slack huddle',
|
|
89
|
+
];
|
|
90
|
+
|
|
91
|
+
/**
|
|
92
|
+
* Extract city name from a location string.
|
|
93
|
+
* @param {string} location
|
|
94
|
+
* @returns {string|null}
|
|
95
|
+
*/
|
|
96
|
+
function extractCity(location) {
|
|
97
|
+
if (!location) return null;
|
|
98
|
+
const lower = location.toLowerCase();
|
|
99
|
+
|
|
100
|
+
// Check for virtual meeting
|
|
101
|
+
for (const kw of VIRTUAL_KEYWORDS) {
|
|
102
|
+
if (lower.includes(kw)) return '__virtual__';
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
// Try to find a known city in the location string
|
|
106
|
+
for (const [city] of Object.entries(CITY_COORDS)) {
|
|
107
|
+
if (lower.includes(city)) return city;
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
// Try comma-separated parts (e.g., "Via Roma 123, Milano, MI")
|
|
111
|
+
const parts = location.split(',').map(p => p.trim().toLowerCase());
|
|
112
|
+
for (const part of parts) {
|
|
113
|
+
for (const [city] of Object.entries(CITY_COORDS)) {
|
|
114
|
+
if (part === city || part.startsWith(city + ' ') || part.endsWith(' ' + city)) {
|
|
115
|
+
return city;
|
|
116
|
+
}
|
|
117
|
+
}
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
// Unknown location — treat as physical but unknown city
|
|
121
|
+
return '__unknown__';
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
/**
|
|
125
|
+
* Haversine distance between two lat/lon points in km.
|
|
126
|
+
*/
|
|
127
|
+
function haversineKm(lat1, lon1, lat2, lon2) {
|
|
128
|
+
const R = 6371;
|
|
129
|
+
const dLat = (lat2 - lat1) * Math.PI / 180;
|
|
130
|
+
const dLon = (lon2 - lon1) * Math.PI / 180;
|
|
131
|
+
const a = Math.sin(dLat / 2) ** 2 +
|
|
132
|
+
Math.cos(lat1 * Math.PI / 180) * Math.cos(lat2 * Math.PI / 180) *
|
|
133
|
+
Math.sin(dLon / 2) ** 2;
|
|
134
|
+
return R * 2 * Math.atan2(Math.sqrt(a), Math.sqrt(1 - a));
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
/**
|
|
138
|
+
* Estimate travel time in minutes between two locations.
|
|
139
|
+
* @param {string} fromLocation
|
|
140
|
+
* @param {string} toLocation
|
|
141
|
+
* @returns {{ minutes: number, label: string }}
|
|
142
|
+
*/
|
|
143
|
+
export function estimateTravelTime(fromLocation, toLocation) {
|
|
144
|
+
const fromCity = extractCity(fromLocation);
|
|
145
|
+
const toCity = extractCity(toLocation);
|
|
146
|
+
|
|
147
|
+
// No location on either side — no buffer needed
|
|
148
|
+
if (!fromCity && !toCity) return { minutes: 0, label: 'no travel' };
|
|
149
|
+
|
|
150
|
+
// Virtual meetings — minimal buffer
|
|
151
|
+
if (fromCity === '__virtual__' || toCity === '__virtual__') {
|
|
152
|
+
return { minutes: 15, label: '15 min buffer (virtual)' };
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
// One side has no location — assume 30 min buffer
|
|
156
|
+
if (!fromCity || !toCity || fromCity === '__unknown__' || toCity === '__unknown__') {
|
|
157
|
+
return { minutes: 30, label: '30 min buffer (location unknown)' };
|
|
158
|
+
}
|
|
159
|
+
|
|
160
|
+
// Same city
|
|
161
|
+
if (fromCity === toCity) {
|
|
162
|
+
return { minutes: 30, label: `30 min (same city: ${fromCity})` };
|
|
163
|
+
}
|
|
164
|
+
|
|
165
|
+
// Different known cities — calculate distance
|
|
166
|
+
const fromCoords = CITY_COORDS[fromCity];
|
|
167
|
+
const toCoords = CITY_COORDS[toCity];
|
|
168
|
+
|
|
169
|
+
if (fromCoords && toCoords) {
|
|
170
|
+
const km = haversineKm(fromCoords.lat, fromCoords.lon, toCoords.lat, toCoords.lon);
|
|
171
|
+
|
|
172
|
+
if (km < 50) return { minutes: 45, label: `45 min (~${Math.round(km)}km: ${fromCity} → ${toCity})` };
|
|
173
|
+
if (km < 150) return { minutes: 90, label: `90 min (~${Math.round(km)}km: ${fromCity} → ${toCity})` };
|
|
174
|
+
if (km < 400) return { minutes: 150, label: `2.5h (~${Math.round(km)}km: ${fromCity} → ${toCity})` };
|
|
175
|
+
return { minutes: 240, label: `4h+ (~${Math.round(km)}km: ${fromCity} → ${toCity}) — consider video call` };
|
|
176
|
+
}
|
|
177
|
+
|
|
178
|
+
// Fallback: different city, unknown distance
|
|
179
|
+
return { minutes: 90, label: `90 min (${fromCity} → ${toCity})` };
|
|
180
|
+
}
|
|
181
|
+
|
|
182
|
+
// ── Slot Finder ─────────────────────────────────────────────────────────────
|
|
183
|
+
|
|
184
|
+
/**
|
|
185
|
+
* Find available meeting slots in a date range, considering travel time.
|
|
186
|
+
*
|
|
187
|
+
* @param {object} config — NHA config (for calendar API access)
|
|
188
|
+
* @param {object} params
|
|
189
|
+
* @param {string} params.meetingLocation — where the new meeting will be
|
|
190
|
+
* @param {number} params.durationMinutes — meeting duration
|
|
191
|
+
* @param {string} params.dateFrom — YYYY-MM-DD start of search range
|
|
192
|
+
* @param {string} params.dateTo — YYYY-MM-DD end of search range
|
|
193
|
+
* @param {number} [params.workdayStart=9] — earliest hour (0-23)
|
|
194
|
+
* @param {number} [params.workdayEnd=18] — latest hour (0-23)
|
|
195
|
+
* @param {number} [params.maxSlots=5] — max slots to return
|
|
196
|
+
* @returns {Promise<Array<{start: string, end: string, date: string, day: string, travelBefore: object, travelAfter: object, score: number}>>}
|
|
197
|
+
*/
|
|
198
|
+
export async function findAvailableSlots(config, params) {
|
|
199
|
+
const {
|
|
200
|
+
meetingLocation = '',
|
|
201
|
+
durationMinutes = 60,
|
|
202
|
+
dateFrom,
|
|
203
|
+
dateTo,
|
|
204
|
+
workdayStart = 9,
|
|
205
|
+
workdayEnd = 18,
|
|
206
|
+
maxSlots = 5,
|
|
207
|
+
} = params;
|
|
208
|
+
|
|
209
|
+
// Fetch all events in the date range
|
|
210
|
+
const from = new Date(dateFrom + 'T00:00:00');
|
|
211
|
+
const to = new Date(dateTo + 'T23:59:59');
|
|
212
|
+
const events = await listEvents(config, 'primary', from, to);
|
|
213
|
+
|
|
214
|
+
// Group events by date
|
|
215
|
+
const eventsByDate = new Map();
|
|
216
|
+
for (const event of events) {
|
|
217
|
+
if (event.isAllDay) continue; // skip all-day events
|
|
218
|
+
const dateKey = event.start.split('T')[0];
|
|
219
|
+
if (!eventsByDate.has(dateKey)) eventsByDate.set(dateKey, []);
|
|
220
|
+
eventsByDate.get(dateKey).push(event);
|
|
221
|
+
}
|
|
222
|
+
|
|
223
|
+
const slots = [];
|
|
224
|
+
const durationMs = durationMinutes * 60000;
|
|
225
|
+
|
|
226
|
+
// Iterate each day in range
|
|
227
|
+
const current = new Date(from);
|
|
228
|
+
while (current <= to && slots.length < maxSlots * 2) {
|
|
229
|
+
const dayOfWeek = current.getDay();
|
|
230
|
+
// Skip weekends
|
|
231
|
+
if (dayOfWeek === 0 || dayOfWeek === 6) {
|
|
232
|
+
current.setDate(current.getDate() + 1);
|
|
233
|
+
continue;
|
|
234
|
+
}
|
|
235
|
+
|
|
236
|
+
const dateKey = current.toISOString().split('T')[0];
|
|
237
|
+
const dayName = current.toLocaleDateString('en-US', { weekday: 'long' });
|
|
238
|
+
const dayEvents = (eventsByDate.get(dateKey) || [])
|
|
239
|
+
.sort((a, b) => new Date(a.start).getTime() - new Date(b.start).getTime());
|
|
240
|
+
|
|
241
|
+
// Build busy blocks with travel buffers
|
|
242
|
+
const busyBlocks = [];
|
|
243
|
+
for (const event of dayEvents) {
|
|
244
|
+
const eventStart = new Date(event.start).getTime();
|
|
245
|
+
const eventEnd = new Date(event.end).getTime();
|
|
246
|
+
|
|
247
|
+
// Travel time FROM previous location TO this event
|
|
248
|
+
const travelTo = estimateTravelTime(meetingLocation, event.location);
|
|
249
|
+
// Travel time FROM this event TO meeting location
|
|
250
|
+
const travelFrom = estimateTravelTime(event.location, meetingLocation);
|
|
251
|
+
|
|
252
|
+
busyBlocks.push({
|
|
253
|
+
start: eventStart - travelTo.minutes * 60000, // need to arrive before
|
|
254
|
+
end: eventEnd + travelFrom.minutes * 60000, // need travel time after
|
|
255
|
+
eventStart,
|
|
256
|
+
eventEnd,
|
|
257
|
+
summary: event.summary,
|
|
258
|
+
location: event.location,
|
|
259
|
+
travelTo,
|
|
260
|
+
travelFrom,
|
|
261
|
+
});
|
|
262
|
+
}
|
|
263
|
+
|
|
264
|
+
// Find free windows in the workday
|
|
265
|
+
const workStart = new Date(dateKey + `T${String(workdayStart).padStart(2, '0')}:00:00`).getTime();
|
|
266
|
+
const workEnd = new Date(dateKey + `T${String(workdayEnd).padStart(2, '0')}:00:00`).getTime();
|
|
267
|
+
|
|
268
|
+
// Merge overlapping busy blocks
|
|
269
|
+
const merged = [];
|
|
270
|
+
for (const block of busyBlocks.sort((a, b) => a.start - b.start)) {
|
|
271
|
+
if (merged.length > 0 && block.start <= merged[merged.length - 1].end) {
|
|
272
|
+
merged[merged.length - 1].end = Math.max(merged[merged.length - 1].end, block.end);
|
|
273
|
+
} else {
|
|
274
|
+
merged.push({ ...block });
|
|
275
|
+
}
|
|
276
|
+
}
|
|
277
|
+
|
|
278
|
+
// Find gaps
|
|
279
|
+
let cursor = workStart;
|
|
280
|
+
for (const block of merged) {
|
|
281
|
+
if (block.start > cursor) {
|
|
282
|
+
const gapDuration = block.start - cursor;
|
|
283
|
+
if (gapDuration >= durationMs) {
|
|
284
|
+
// Find the event BEFORE this gap (for travel info)
|
|
285
|
+
const prevEvent = dayEvents.find(e => new Date(e.end).getTime() <= cursor + 60000);
|
|
286
|
+
const nextEvent = dayEvents.find(e => new Date(e.start).getTime() >= block.eventStart - 60000);
|
|
287
|
+
|
|
288
|
+
const travelBefore = prevEvent
|
|
289
|
+
? estimateTravelTime(prevEvent.location, meetingLocation)
|
|
290
|
+
: { minutes: 0, label: 'no previous event' };
|
|
291
|
+
const travelAfter = nextEvent
|
|
292
|
+
? estimateTravelTime(meetingLocation, nextEvent.location)
|
|
293
|
+
: { minutes: 0, label: 'no next event' };
|
|
294
|
+
|
|
295
|
+
const slotStart = new Date(cursor + travelBefore.minutes * 60000);
|
|
296
|
+
const slotEnd = new Date(slotStart.getTime() + durationMs);
|
|
297
|
+
|
|
298
|
+
// Make sure slot fits before the next busy block
|
|
299
|
+
if (slotEnd.getTime() + travelAfter.minutes * 60000 <= block.start) {
|
|
300
|
+
// Score: prefer morning, prefer no travel, prefer earlier in the week
|
|
301
|
+
const hour = slotStart.getHours();
|
|
302
|
+
const hourScore = hour >= 9 && hour <= 11 ? 10 : hour >= 14 && hour <= 16 ? 8 : 5;
|
|
303
|
+
const travelScore = Math.max(0, 10 - (travelBefore.minutes + travelAfter.minutes) / 15);
|
|
304
|
+
const score = hourScore + travelScore;
|
|
305
|
+
|
|
306
|
+
slots.push({
|
|
307
|
+
start: slotStart.toISOString(),
|
|
308
|
+
end: slotEnd.toISOString(),
|
|
309
|
+
date: dateKey,
|
|
310
|
+
day: dayName,
|
|
311
|
+
startTime: slotStart.toLocaleTimeString('it-IT', { hour: '2-digit', minute: '2-digit' }),
|
|
312
|
+
endTime: slotEnd.toLocaleTimeString('it-IT', { hour: '2-digit', minute: '2-digit' }),
|
|
313
|
+
travelBefore,
|
|
314
|
+
travelAfter,
|
|
315
|
+
score,
|
|
316
|
+
});
|
|
317
|
+
}
|
|
318
|
+
}
|
|
319
|
+
}
|
|
320
|
+
cursor = Math.max(cursor, block.end);
|
|
321
|
+
}
|
|
322
|
+
|
|
323
|
+
// Check gap after last event
|
|
324
|
+
if (cursor < workEnd) {
|
|
325
|
+
const gapDuration = workEnd - cursor;
|
|
326
|
+
if (gapDuration >= durationMs) {
|
|
327
|
+
const lastEvent = dayEvents[dayEvents.length - 1];
|
|
328
|
+
const travelBefore = lastEvent
|
|
329
|
+
? estimateTravelTime(lastEvent.location, meetingLocation)
|
|
330
|
+
: { minutes: 0, label: 'no previous event' };
|
|
331
|
+
|
|
332
|
+
const slotStart = new Date(cursor + travelBefore.minutes * 60000);
|
|
333
|
+
const slotEnd = new Date(slotStart.getTime() + durationMs);
|
|
334
|
+
|
|
335
|
+
if (slotEnd.getTime() <= workEnd) {
|
|
336
|
+
const hour = slotStart.getHours();
|
|
337
|
+
const hourScore = hour >= 9 && hour <= 11 ? 10 : hour >= 14 && hour <= 16 ? 8 : 5;
|
|
338
|
+
const travelScore = Math.max(0, 10 - travelBefore.minutes / 15);
|
|
339
|
+
|
|
340
|
+
slots.push({
|
|
341
|
+
start: slotStart.toISOString(),
|
|
342
|
+
end: slotEnd.toISOString(),
|
|
343
|
+
date: dateKey,
|
|
344
|
+
day: dayName,
|
|
345
|
+
startTime: slotStart.toLocaleTimeString('it-IT', { hour: '2-digit', minute: '2-digit' }),
|
|
346
|
+
endTime: slotEnd.toLocaleTimeString('it-IT', { hour: '2-digit', minute: '2-digit' }),
|
|
347
|
+
travelBefore,
|
|
348
|
+
travelAfter: { minutes: 0, label: 'end of day' },
|
|
349
|
+
score: hourScore + travelScore,
|
|
350
|
+
});
|
|
351
|
+
}
|
|
352
|
+
}
|
|
353
|
+
}
|
|
354
|
+
|
|
355
|
+
current.setDate(current.getDate() + 1);
|
|
356
|
+
}
|
|
357
|
+
|
|
358
|
+
// Sort by score (highest first), take top N
|
|
359
|
+
slots.sort((a, b) => b.score - a.score);
|
|
360
|
+
return slots.slice(0, maxSlots);
|
|
361
|
+
}
|
|
362
|
+
|
|
363
|
+
/**
|
|
364
|
+
* Format slots into a human-readable proposal string.
|
|
365
|
+
*/
|
|
366
|
+
export function formatSlotProposal(slots, clientName, meetingSubject) {
|
|
367
|
+
if (slots.length === 0) {
|
|
368
|
+
return 'No available slots found in the requested date range. Try expanding the range or shortening the meeting duration.';
|
|
369
|
+
}
|
|
370
|
+
|
|
371
|
+
const lines = [`Found ${slots.length} optimal slot${slots.length > 1 ? 's' : ''} for "${meetingSubject}" with ${clientName}:\n`];
|
|
372
|
+
|
|
373
|
+
for (let i = 0; i < slots.length; i++) {
|
|
374
|
+
const s = slots[i];
|
|
375
|
+
lines.push(`${i + 1}. ${s.day} ${s.date} — ${s.startTime} to ${s.endTime}`);
|
|
376
|
+
if (s.travelBefore.minutes > 0) {
|
|
377
|
+
lines.push(` Travel before: ${s.travelBefore.label}`);
|
|
378
|
+
}
|
|
379
|
+
if (s.travelAfter.minutes > 0) {
|
|
380
|
+
lines.push(` Travel after: ${s.travelAfter.label}`);
|
|
381
|
+
}
|
|
382
|
+
}
|
|
383
|
+
|
|
384
|
+
return lines.join('\n');
|
|
385
|
+
}
|
|
386
|
+
|
|
387
|
+
/**
|
|
388
|
+
* Generate a professional email/message proposing slots to a client.
|
|
389
|
+
*/
|
|
390
|
+
export function generateSlotMessage(slots, clientName, meetingSubject, senderName) {
|
|
391
|
+
if (slots.length === 0) return '';
|
|
392
|
+
|
|
393
|
+
const slotLines = slots.slice(0, 3).map((s, i) => {
|
|
394
|
+
return ` ${i + 1}. ${s.day} ${s.date}, ${s.startTime} - ${s.endTime}`;
|
|
395
|
+
});
|
|
396
|
+
|
|
397
|
+
return `Gentile ${clientName},
|
|
398
|
+
|
|
399
|
+
Le scrivo per fissare un incontro riguardo "${meetingSubject}".
|
|
400
|
+
|
|
401
|
+
In base alla mia disponibilità, Le propongo le seguenti opzioni:
|
|
402
|
+
|
|
403
|
+
${slotLines.join('\n')}
|
|
404
|
+
|
|
405
|
+
Mi faccia sapere quale opzione Le è più comoda, o se preferisce suggerire un orario alternativo.
|
|
406
|
+
|
|
407
|
+
Cordiali saluti,
|
|
408
|
+
${senderName || 'Il team'}`;
|
|
409
|
+
}
|
|
@@ -18,8 +18,15 @@ import {
|
|
|
18
18
|
getEventsForDate,
|
|
19
19
|
createEvent,
|
|
20
20
|
updateEvent,
|
|
21
|
+
listEvents,
|
|
21
22
|
} from './mail-router.mjs';
|
|
22
23
|
|
|
24
|
+
import {
|
|
25
|
+
findAvailableSlots,
|
|
26
|
+
formatSlotProposal,
|
|
27
|
+
generateSlotMessage,
|
|
28
|
+
} from './smart-scheduler.mjs';
|
|
29
|
+
|
|
23
30
|
import {
|
|
24
31
|
getTasks,
|
|
25
32
|
addTask,
|
|
@@ -107,6 +114,15 @@ TOOLS:
|
|
|
107
114
|
15. notify_remind(message: string, atTime: string)
|
|
108
115
|
Set a desktop reminder. atTime is ISO 8601 or relative like "in 30 minutes".
|
|
109
116
|
|
|
117
|
+
16. calendar_week(startDate?: string)
|
|
118
|
+
List all events for a full week starting from startDate (YYYY-MM-DD). Defaults to current week.
|
|
119
|
+
|
|
120
|
+
17. schedule_meeting(clientName: string, subject: string, location: string, durationMinutes: number, dateFrom: string, dateTo: string, workdayStart?: number, workdayEnd?: number)
|
|
121
|
+
Find optimal meeting slots considering existing calendar events, locations, and estimated travel time between appointments. Returns ranked slots with travel info. dateFrom and dateTo are YYYY-MM-DD.
|
|
122
|
+
|
|
123
|
+
18. schedule_draft_email(clientName: string, subject: string, location: string, durationMinutes: number, dateFrom: string, dateTo: string)
|
|
124
|
+
Same as schedule_meeting, but also generates a professional email proposing the top 3 slots to the client. Returns both the slots and a ready-to-send email draft.
|
|
125
|
+
|
|
110
126
|
RULES:
|
|
111
127
|
- For search/read operations, execute immediately and present results conversationally.
|
|
112
128
|
- For write/send/delete operations (gmail_send, gmail_reply, calendar_create, calendar_move, task_done, notify_remind), DESCRIBE what you're about to do and include the JSON block so the system can ask the user for confirmation.
|
|
@@ -386,6 +402,66 @@ export async function executeTool(action, params, config) {
|
|
|
386
402
|
return `Reminder set for ${formatTime(atTime.toISOString())} (in ~${minutes} min): "${params.message}"`;
|
|
387
403
|
}
|
|
388
404
|
|
|
405
|
+
// ── Calendar Week ───────────────────────────────────────────────────
|
|
406
|
+
case 'calendar_week': {
|
|
407
|
+
const startDate = params.startDate || new Date().toISOString().split('T')[0];
|
|
408
|
+
const from = new Date(startDate + 'T00:00:00');
|
|
409
|
+
const to = new Date(from.getTime() + 7 * 86400000);
|
|
410
|
+
const events = await listEvents(config, 'primary', from, to);
|
|
411
|
+
if (events.length === 0) return `No events found for the week starting ${startDate}.`;
|
|
412
|
+
|
|
413
|
+
// Group by day
|
|
414
|
+
const byDay = new Map();
|
|
415
|
+
for (const e of events) {
|
|
416
|
+
const day = e.start.split('T')[0];
|
|
417
|
+
if (!byDay.has(day)) byDay.set(day, []);
|
|
418
|
+
byDay.get(day).push(e);
|
|
419
|
+
}
|
|
420
|
+
|
|
421
|
+
const lines = [];
|
|
422
|
+
for (const [day, dayEvents] of [...byDay.entries()].sort()) {
|
|
423
|
+
const dayName = new Date(day).toLocaleDateString('en-US', { weekday: 'long' });
|
|
424
|
+
lines.push(`\n${dayName} ${day} (${dayEvents.length} events):`);
|
|
425
|
+
for (const e of dayEvents) {
|
|
426
|
+
const time = e.isAllDay ? 'All day' : `${formatTime(e.start)} - ${formatTime(e.end)}`;
|
|
427
|
+
const loc = e.location ? ` @ ${e.location}` : '';
|
|
428
|
+
lines.push(` ${time} — ${e.summary}${loc}`);
|
|
429
|
+
}
|
|
430
|
+
}
|
|
431
|
+
return lines.join('\n');
|
|
432
|
+
}
|
|
433
|
+
|
|
434
|
+
// ── Smart Scheduling ──────────────────────────────────────────────────
|
|
435
|
+
case 'schedule_meeting': {
|
|
436
|
+
const slots = await findAvailableSlots(config, {
|
|
437
|
+
meetingLocation: params.location || '',
|
|
438
|
+
durationMinutes: params.durationMinutes || 60,
|
|
439
|
+
dateFrom: params.dateFrom,
|
|
440
|
+
dateTo: params.dateTo,
|
|
441
|
+
workdayStart: params.workdayStart || 9,
|
|
442
|
+
workdayEnd: params.workdayEnd || 18,
|
|
443
|
+
maxSlots: 5,
|
|
444
|
+
});
|
|
445
|
+
return formatSlotProposal(slots, params.clientName || 'the client', params.subject || 'meeting');
|
|
446
|
+
}
|
|
447
|
+
|
|
448
|
+
case 'schedule_draft_email': {
|
|
449
|
+
const slots = await findAvailableSlots(config, {
|
|
450
|
+
meetingLocation: params.location || '',
|
|
451
|
+
durationMinutes: params.durationMinutes || 60,
|
|
452
|
+
dateFrom: params.dateFrom,
|
|
453
|
+
dateTo: params.dateTo,
|
|
454
|
+
workdayStart: 9,
|
|
455
|
+
workdayEnd: 18,
|
|
456
|
+
maxSlots: 5,
|
|
457
|
+
});
|
|
458
|
+
|
|
459
|
+
const proposal = formatSlotProposal(slots, params.clientName || 'the client', params.subject || 'meeting');
|
|
460
|
+
const email = generateSlotMessage(slots, params.clientName || 'the client', params.subject || 'meeting');
|
|
461
|
+
|
|
462
|
+
return `${proposal}\n\n--- DRAFT EMAIL ---\n\n${email}`;
|
|
463
|
+
}
|
|
464
|
+
|
|
389
465
|
default:
|
|
390
466
|
return `Unknown action: ${action}`;
|
|
391
467
|
}
|