nothumanallowed 6.0.1 → 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 CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "nothumanallowed",
3
- "version": "6.0.1",
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": {
@@ -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
  }
@@ -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
  }
@@ -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.0.1';
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
 
@@ -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
  }