iris-chatbot 0.2.5 → 1.0.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.
@@ -14,6 +14,32 @@ type CalendarListInput = {
14
14
  from?: string;
15
15
  to?: string;
16
16
  limit?: number;
17
+ /** Omit or empty = primary calendar only (fast). "all" = all calendars (slower, longer timeout). Or a calendar name e.g. "Work". */
18
+ calendar?: string;
19
+ };
20
+
21
+ type CalendarUpdateInput = {
22
+ eventUid: string;
23
+ calendar?: string;
24
+ title?: string;
25
+ start?: string;
26
+ end?: string;
27
+ location?: string;
28
+ notes?: string;
29
+ };
30
+
31
+ type CalendarDeleteInput = {
32
+ eventUid: string;
33
+ calendar?: string;
34
+ };
35
+
36
+ type CalendarMoveInput = {
37
+ eventUid: string;
38
+ /** New start date/time (required for move). */
39
+ start: string;
40
+ /** New end date/time (optional; defaults to start + 1 hour). */
41
+ end?: string;
42
+ calendar?: string;
17
43
  };
18
44
 
19
45
  type ReminderCreateInput = {
@@ -109,6 +135,45 @@ function resolveRelativeDate(input: string, now: Date): Date | null {
109
135
  return base;
110
136
  }
111
137
 
138
+ // "next week" → Monday of next calendar week; "end of next week" → Sunday
139
+ const nextWeekMatch = lower.match(/\b(?:end\s+of\s+)?next\s+week\b/);
140
+ if (nextWeekMatch) {
141
+ const isEnd = /end\s+of\s+next\s+week/.test(lower);
142
+ const daysUntilMonday = (1 - base.getDay() + 7) % 7;
143
+ const nextMonday = new Date(base);
144
+ nextMonday.setDate(base.getDate() + (daysUntilMonday === 0 ? 7 : daysUntilMonday));
145
+ if (isEnd) {
146
+ nextMonday.setDate(nextMonday.getDate() + 6);
147
+ }
148
+ return nextMonday;
149
+ }
150
+
151
+ // "next month" → first day of next calendar month; "end of next month" → last day of next month
152
+ const nextMonthMatch = lower.match(/\b(?:end\s+of\s+)?next\s+month\b/);
153
+ if (nextMonthMatch) {
154
+ const isEnd = /end\s+of\s+next\s+month/.test(lower);
155
+ const d = new Date(base.getFullYear(), base.getMonth() + 1, 1); // first day of next month
156
+ if (isEnd) {
157
+ d.setMonth(d.getMonth() + 1);
158
+ d.setDate(0); // last day of previous (= last day of next month)
159
+ d.setHours(23, 59, 0, 0);
160
+ }
161
+ return d;
162
+ }
163
+
164
+ // "this month" → first day of current month; "end of this month" → last day of current month
165
+ const thisMonthMatch = lower.match(/\b(?:end\s+of\s+)?this\s+month\b/);
166
+ if (thisMonthMatch) {
167
+ const isEnd = /end\s+of\s+this\s+month/.test(lower);
168
+ const y = base.getFullYear();
169
+ const m = base.getMonth();
170
+ const d = isEnd ? new Date(y, m + 1, 0) : new Date(y, m, 1); // last day of current month vs first day
171
+ if (isEnd) {
172
+ d.setHours(23, 59, 0, 0);
173
+ }
174
+ return d;
175
+ }
176
+
112
177
  const weekdayMatch = lower.match(
113
178
  /\b(?:(this|next|upcoming)\s+)?(sunday|monday|tuesday|wednesday|thursday|friday|saturday)\b/,
114
179
  );
@@ -228,16 +293,310 @@ function addMinutes(parts: DateTimeParts, minutesToAdd: number): DateTimeParts {
228
293
  return toDueParts(value);
229
294
  }
230
295
 
231
- async function runAppleScript(script: string, args: string[] = [], signal?: AbortSignal) {
296
+ /** Format date for AppleScript "date" coercion (local time). */
297
+ function formatDateForAppleScript(parts: DateTimeParts): string {
298
+ return `${parts.year}-${String(parts.month).padStart(2, "0")}-${String(parts.day).padStart(2, "0")} ${String(parts.hour).padStart(2, "0")}:${String(parts.minute).padStart(2, "0")}:00`;
299
+ }
300
+
301
+ const WEEKDAY_NAMES = ["Sunday", "Monday", "Tuesday", "Wednesday", "Thursday", "Friday", "Saturday"];
302
+ const MONTH_NAMES = [
303
+ "January", "February", "March", "April", "May", "June",
304
+ "July", "August", "September", "October", "November", "December",
305
+ ];
306
+
307
+ /** Human-readable queried range so the assistant and UI show the exact day we checked. */
308
+ function formatQueriedRange(fromParts: DateTimeParts, toParts: DateTimeParts): string {
309
+ const fromDate = new Date(fromParts.year, fromParts.month - 1, fromParts.day, fromParts.hour, fromParts.minute, 0, 0);
310
+ const toDate = new Date(toParts.year, toParts.month - 1, toParts.day, toParts.hour, toParts.minute, 0, 0);
311
+ const sameDay =
312
+ fromParts.year === toParts.year &&
313
+ fromParts.month === toParts.month &&
314
+ fromParts.day === toParts.day;
315
+ const weekday = WEEKDAY_NAMES[fromDate.getDay()];
316
+ const month = MONTH_NAMES[fromDate.getMonth()];
317
+ const day = fromDate.getDate();
318
+ const year = fromDate.getFullYear();
319
+ const fromTime = fromDate.toLocaleTimeString("en-US", { hour: "numeric", minute: "2-digit", hour12: true });
320
+ const toTime = toDate.toLocaleTimeString("en-US", { hour: "numeric", minute: "2-digit", hour12: true });
321
+ if (sameDay) {
322
+ return `${weekday}, ${month} ${day}, ${year}, ${fromTime} – ${toTime}`;
323
+ }
324
+ return `${weekday}, ${month} ${day}, ${year}, ${fromTime} – ${toDate.getDate()} ${MONTH_NAMES[toDate.getMonth()]} ${toDate.getFullYear()}, ${toTime}`;
325
+ }
326
+
327
+ /**
328
+ * Resolve calendar list range to explicit date parts. We pass components to
329
+ * AppleScript and build dates there—AppleScript's "date fromText" is
330
+ * locale-dependent and fails on ISO-style strings (e.g. "2026-02-09 00:00:00").
331
+ */
332
+ function resolveCalendarRange(
333
+ fromInput: string,
334
+ toInput: string,
335
+ now: Date,
336
+ ): { fromParts: DateTimeParts; toParts: DateTimeParts; fromLabel: string; toLabel: string } {
337
+ const fromNorm = fromInput.trim().toLowerCase() || "today";
338
+ const toNorm = toInput.trim().toLowerCase() || "tomorrow";
339
+ const fromParts =
340
+ parseDateTimeParts(fromInput, { defaultHour: 0, defaultMinute: 0 }) ??
341
+ toDueParts(now);
342
+ if (fromNorm === "today") {
343
+ fromParts.hour = 0;
344
+ fromParts.minute = 0;
345
+ }
346
+ let toParts =
347
+ parseDateTimeParts(toInput, { defaultHour: 23, defaultMinute: 59 }) ?? (() => {
348
+ const end = new Date(now);
349
+ end.setDate(end.getDate() + 1);
350
+ const p = toDueParts(end);
351
+ p.hour = 23;
352
+ p.minute = 59;
353
+ return p;
354
+ })();
355
+ if (toNorm === "today") {
356
+ const p = toDueParts(now);
357
+ p.hour = 23;
358
+ p.minute = 59;
359
+ toParts = p;
360
+ } else if (toNorm === "tomorrow") {
361
+ const d = new Date(now);
362
+ d.setDate(d.getDate() + 1);
363
+ const p = toDueParts(d);
364
+ p.hour = 23;
365
+ p.minute = 59;
366
+ toParts = p;
367
+ } else {
368
+ toParts.hour = 23;
369
+ toParts.minute = 59;
370
+ }
371
+
372
+ const partsToTime = (p: DateTimeParts) =>
373
+ new Date(p.year, p.month - 1, p.day, p.hour, p.minute, 0, 0).getTime();
374
+ const fromTime = partsToTime(fromParts);
375
+ const toTime = partsToTime(toParts);
376
+ const startOfToday = new Date(now.getFullYear(), now.getMonth(), now.getDate(), 0, 0, 0, 0).getTime();
377
+
378
+ let finalFrom = fromParts;
379
+ let finalTo = toParts;
380
+ if (fromTime > toTime) {
381
+ [finalFrom, finalTo] = [toParts, fromParts];
382
+ }
383
+ if (partsToTime(finalTo) < startOfToday) {
384
+ finalFrom = toDueParts(now);
385
+ finalFrom.hour = 0;
386
+ finalFrom.minute = 0;
387
+ const tomorrow = new Date(now);
388
+ tomorrow.setDate(tomorrow.getDate() + 1);
389
+ finalTo = toDueParts(tomorrow);
390
+ finalTo.hour = 23;
391
+ finalTo.minute = 59;
392
+ }
393
+
394
+ const sameDay =
395
+ finalFrom.year === finalTo.year &&
396
+ finalFrom.month === finalTo.month &&
397
+ finalFrom.day === finalTo.day;
398
+ if (sameDay) {
399
+ finalFrom.hour = 0;
400
+ finalFrom.minute = 0;
401
+ finalTo.hour = 23;
402
+ finalTo.minute = 59;
403
+ }
404
+
405
+ return {
406
+ fromParts: finalFrom,
407
+ toParts: finalTo,
408
+ fromLabel: formatDateForAppleScript(finalFrom),
409
+ toLabel: formatDateForAppleScript(finalTo),
410
+ };
411
+ }
412
+
413
+ const CALENDAR_LIST_SINGLE_TIMEOUT_MS = 25_000;
414
+ const CALENDAR_LIST_PARALLEL_TIMEOUT_MS = 35_000;
415
+ const CALENDAR_LIST_FALLBACK_TIMEOUT_MS = 40_000;
416
+ const GET_CALENDAR_NAMES_TIMEOUT_MS = 5_000;
417
+
418
+ async function runAppleScript(
419
+ script: string,
420
+ args: string[] = [],
421
+ signal?: AbortSignal,
422
+ timeoutMs?: number,
423
+ ) {
232
424
  const { stdout } = await runCommandSafe({
233
425
  command: "osascript",
234
426
  args: ["-e", script, ...args],
235
427
  signal,
236
- timeoutMs: APPLESCRIPT_TIMEOUT_MS,
428
+ timeoutMs: timeoutMs ?? APPLESCRIPT_TIMEOUT_MS,
237
429
  });
238
430
  return stdout;
239
431
  }
240
432
 
433
+ /**
434
+ * List events from Apple Calendar. Does NOT use "whose start date" (that clause
435
+ * often returns empty with iCloud/Calendar). We get every event of each calendar,
436
+ * build fromDate/toDate in the script from argv, and filter with native date
437
+ * comparison (eventStart >= fromDate and eventStart <= toDate) so we avoid
438
+ * "month of eventStart as integer" which can throw when Calendar returns month
439
+ * as a constant. Stops after maxCount events, or after 8000 events per calendar,
440
+ * or after 600 consecutive events past toDate.
441
+ */
442
+ const CALENDAR_LIST_EVENTS_SCRIPT =
443
+ 'on run argv\n' +
444
+ 'set calendarFilter to item 1 of argv\n' +
445
+ 'set yr1 to (item 2 of argv) as integer\n' +
446
+ 'set mo1 to (item 3 of argv) as integer\n' +
447
+ 'set day1 to (item 4 of argv) as integer\n' +
448
+ 'set hr1 to (item 5 of argv) as integer\n' +
449
+ 'set min1 to (item 6 of argv) as integer\n' +
450
+ 'set yr2 to (item 7 of argv) as integer\n' +
451
+ 'set mo2 to (item 8 of argv) as integer\n' +
452
+ 'set day2 to (item 9 of argv) as integer\n' +
453
+ 'set hr2 to (item 10 of argv) as integer\n' +
454
+ 'set min2 to (item 11 of argv) as integer\n' +
455
+ 'set maxCount to (item 12 of argv) as integer\n' +
456
+ 'set bStart to current date\n' +
457
+ 'set year of bStart to yr1\n' +
458
+ 'set month of bStart to mo1\n' +
459
+ 'set day of bStart to day1\n' +
460
+ 'set hours of bStart to hr1\n' +
461
+ 'set minutes of bStart to min1\n' +
462
+ 'set seconds of bStart to 0\n' +
463
+ 'set b2 to current date\n' +
464
+ 'set year of b2 to yr2\n' +
465
+ 'set month of b2 to mo2\n' +
466
+ 'set day of b2 to day2\n' +
467
+ 'set hours of b2 to hr2\n' +
468
+ 'set minutes of b2 to min2\n' +
469
+ 'set seconds of b2 to 59\n' +
470
+ "set outText to \"\"\n" +
471
+ "set countItems to 0\n" +
472
+ 'set maxScan to 5000\n' +
473
+ 'set maxPast to 150\n' +
474
+ 'tell application "Calendar"\n' +
475
+ 'repeat with c in calendars\n' +
476
+ 'if countItems is greater than or equal to maxCount then exit repeat\n' +
477
+ 'set calName to (name of c as text)\n' +
478
+ 'set useCal to false\n' +
479
+ 'if calendarFilter is "" then\n' +
480
+ 'set useCal to true\n' +
481
+ 'else if calendarFilter is "all" then\n' +
482
+ 'set useCal to true\n' +
483
+ 'else\n' +
484
+ 'ignoring case\n' +
485
+ 'if calName is equal to calendarFilter then set useCal to true\n' +
486
+ 'end ignoring\n' +
487
+ 'end if\n' +
488
+ 'if useCal then\n' +
489
+ 'try\n' +
490
+ 'set eventList to every event of c\n' +
491
+ 'set scanned to 0\n' +
492
+ 'set outsideRangeCount to 0\n' +
493
+ 'repeat with e in eventList\n' +
494
+ 'if countItems is greater than or equal to maxCount then exit repeat\n' +
495
+ 'set scanned to scanned + 1\n' +
496
+ 'if scanned is greater than maxScan then exit repeat\n' +
497
+ 'try\n' +
498
+ 'set evtStart to (get start date of e)\n' +
499
+ 'if evtStart is greater than or equal to bStart and evtStart is less than or equal to b2 then\n' +
500
+ 'set outsideRangeCount to 0\n' +
501
+ 'set outText to outText & (summary of e as text) & tab & (evtStart as text) & tab & calName & tab & (uid of e as text) & linefeed\n' +
502
+ 'set countItems to countItems + 1\n' +
503
+ 'else\n' +
504
+ 'set outsideRangeCount to outsideRangeCount + 1\n' +
505
+ 'if outsideRangeCount is greater than maxPast then exit repeat\n' +
506
+ 'end if\n' +
507
+ 'end try\n' +
508
+ 'end repeat\n' +
509
+ 'end try\n' +
510
+ 'if calendarFilter is "" then exit repeat\n' +
511
+ 'end if\n' +
512
+ 'end repeat\n' +
513
+ "end tell\n" +
514
+ "return outText\n" +
515
+ "end run";
516
+
517
+ /**
518
+ * List events using "whose start date" filter. Fast when Calendar respects it;
519
+ * often returns empty with iCloud. Used as first attempt with short timeout;
520
+ * fall back to CALENDAR_LIST_EVENTS_SCRIPT on timeout or error.
521
+ */
522
+ const CALENDAR_LIST_EVENTS_SCRIPT_WHOSE =
523
+ 'on run argv\n' +
524
+ 'set calendarFilter to item 1 of argv\n' +
525
+ 'set yr1 to (item 2 of argv) as integer\n' +
526
+ 'set mo1 to (item 3 of argv) as integer\n' +
527
+ 'set day1 to (item 4 of argv) as integer\n' +
528
+ 'set hr1 to (item 5 of argv) as integer\n' +
529
+ 'set min1 to (item 6 of argv) as integer\n' +
530
+ 'set yr2 to (item 7 of argv) as integer\n' +
531
+ 'set mo2 to (item 8 of argv) as integer\n' +
532
+ 'set day2 to (item 9 of argv) as integer\n' +
533
+ 'set hr2 to (item 10 of argv) as integer\n' +
534
+ 'set min2 to (item 11 of argv) as integer\n' +
535
+ 'set maxCount to (item 12 of argv) as integer\n' +
536
+ 'set bStart to current date\n' +
537
+ 'set year of bStart to yr1\n' +
538
+ 'set month of bStart to mo1\n' +
539
+ 'set day of bStart to day1\n' +
540
+ 'set hours of bStart to hr1\n' +
541
+ 'set minutes of bStart to min1\n' +
542
+ 'set seconds of bStart to 0\n' +
543
+ 'set b2 to current date\n' +
544
+ 'set year of b2 to yr2\n' +
545
+ 'set month of b2 to mo2\n' +
546
+ 'set day of b2 to day2\n' +
547
+ 'set hours of b2 to hr2\n' +
548
+ 'set minutes of b2 to min2\n' +
549
+ 'set seconds of b2 to 59\n' +
550
+ "set outText to \"\"\n" +
551
+ "set countItems to 0\n" +
552
+ 'tell application "Calendar"\n' +
553
+ 'repeat with c in calendars\n' +
554
+ 'if countItems is greater than or equal to maxCount then exit repeat\n' +
555
+ 'set calName to (name of c as text)\n' +
556
+ 'set useCal to false\n' +
557
+ 'set skipCal to false\n' +
558
+ 'if calendarFilter is "all" then\n' +
559
+ 'if calName is "Birthdays" or calName is "Siri Suggestions" or calName is "US Holidays" or calName is "Scheduled Reminders" or calName is "Calendar" then set skipCal to true\n' +
560
+ 'end if\n' +
561
+ 'if not skipCal then\n' +
562
+ 'if calendarFilter is "" then\n' +
563
+ 'set useCal to true\n' +
564
+ 'else if calendarFilter is "all" then\n' +
565
+ 'set useCal to true\n' +
566
+ 'else\n' +
567
+ 'ignoring case\n' +
568
+ 'if calName is equal to calendarFilter then set useCal to true\n' +
569
+ 'end ignoring\n' +
570
+ 'end if\n' +
571
+ 'end if\n' +
572
+ 'if useCal then\n' +
573
+ 'try\n' +
574
+ 'set eventList to (every event of c whose start date >= bStart and start date <= b2)\n' +
575
+ 'repeat with e in eventList\n' +
576
+ 'if countItems is greater than or equal to maxCount then exit repeat\n' +
577
+ 'set outText to outText & (summary of e as text) & tab & (start date of e as text) & tab & calName & tab & (uid of e as text) & linefeed\n' +
578
+ 'set countItems to countItems + 1\n' +
579
+ 'end repeat\n' +
580
+ 'end try\n' +
581
+ 'if calendarFilter is "" then exit repeat\n' +
582
+ 'end if\n' +
583
+ 'end repeat\n' +
584
+ "end tell\n" +
585
+ "return outText\n" +
586
+ "end run";
587
+
588
+ const CALENDAR_LIST_FILTERED_TIMEOUT_MS = 25_000;
589
+
590
+ /** Returns tab-separated calendar names. Used only when listing all calendars in parallel. */
591
+ const GET_CALENDAR_NAMES_SCRIPT =
592
+ 'tell application "Calendar"\n' +
593
+ "set outText to \"\"\n" +
594
+ "repeat with cal in calendars\n" +
595
+ "set outText to outText & (name of cal as text) & tab\n" +
596
+ "end repeat\n" +
597
+ "return outText\n" +
598
+ "end tell";
599
+
241
600
  async function runCalendarCreateEvent(input: unknown, context: ToolExecutionContext) {
242
601
  ensureMacOS("Calendar automation");
243
602
  const payload = asObject(input) as CalendarCreateInput;
@@ -253,9 +612,9 @@ async function runCalendarCreateEvent(input: unknown, context: ToolExecutionCont
253
612
  }
254
613
  const endParts = endInput
255
614
  ? parseDateTimeParts(endInput, {
256
- defaultHour: startParts.hour,
257
- defaultMinute: startParts.minute,
258
- })
615
+ defaultHour: startParts.hour,
616
+ defaultMinute: startParts.minute,
617
+ })
259
618
  : addMinutes(startParts, 60);
260
619
  if (!endParts) {
261
620
  throw new Error(`Invalid end date/time: ${endInput}`);
@@ -358,47 +717,397 @@ async function runCalendarCreateEvent(input: unknown, context: ToolExecutionCont
358
717
  };
359
718
  }
360
719
 
720
+ function parseEventLine(line: string): { title: string; startAt: string; calendar: string; uid: string } {
721
+ const parts = line.split("\t");
722
+ if (parts.length < 4) {
723
+ return { title: parts[0] ?? "", startAt: "", calendar: "", uid: "" };
724
+ }
725
+ const uid = parts[parts.length - 1] ?? "";
726
+ const calendar = parts[parts.length - 2] ?? "";
727
+ const startAt = parts[parts.length - 3] ?? "";
728
+ const title = parts.slice(0, -3).join("\t").trim();
729
+ return { title, startAt, calendar, uid };
730
+ }
731
+
361
732
  async function runCalendarListEvents(input: unknown, context: ToolExecutionContext) {
362
733
  ensureMacOS("Calendar automation");
363
734
  const payload = asObject(input) as CalendarListInput;
364
- const from = typeof payload.from === "string" && payload.from.trim() ? payload.from.trim() : "today";
365
- const to = typeof payload.to === "string" && payload.to.trim() ? payload.to.trim() : "tomorrow";
735
+ const fromRaw = typeof payload.from === "string" && payload.from.trim() ? payload.from.trim() : "today";
736
+ const toRaw = typeof payload.to === "string" && payload.to.trim() ? payload.to.trim() : "tomorrow";
366
737
  const limit =
367
738
  typeof payload.limit === "number" && Number.isFinite(payload.limit)
368
- ? Math.max(1, Math.min(50, Math.floor(payload.limit)))
369
- : 10;
739
+ ? Math.max(1, Math.min(100, Math.floor(payload.limit)))
740
+ : 50;
741
+ const calendarRaw =
742
+ typeof payload.calendar === "string" && payload.calendar.trim() ? payload.calendar.trim() : "";
743
+ const calendarFilter = calendarRaw.toLowerCase() === "all" ? "all" : calendarRaw || "";
744
+ const now = new Date();
745
+ const { fromParts, toParts, fromLabel, toLabel } = resolveCalendarRange(fromRaw, toRaw, now);
746
+
747
+ const dateArgs = [
748
+ String(fromParts.year),
749
+ String(fromParts.month),
750
+ String(fromParts.day),
751
+ String(fromParts.hour),
752
+ String(fromParts.minute),
753
+ String(toParts.year),
754
+ String(toParts.month),
755
+ String(toParts.day),
756
+ String(toParts.hour),
757
+ String(toParts.minute),
758
+ String(limit),
759
+ ];
760
+
761
+ const runOneList = async (filter: string, timeoutMs: number): Promise<string> => {
762
+ try {
763
+ const filtered = await runAppleScript(
764
+ CALENDAR_LIST_EVENTS_SCRIPT_WHOSE,
765
+ [filter, ...dateArgs],
766
+ context.signal,
767
+ CALENDAR_LIST_FILTERED_TIMEOUT_MS,
768
+ );
769
+ const hasLines = filtered.split("\n").some((l) => l.trim().length > 0);
770
+ if (hasLines) return filtered;
771
+ } catch {
772
+ /* timeout or error: fall through to full scan */
773
+ }
774
+ return await runAppleScript(
775
+ CALENDAR_LIST_EVENTS_SCRIPT,
776
+ [filter, ...dateArgs],
777
+ context.signal,
778
+ timeoutMs,
779
+ );
780
+ };
781
+
782
+ const parseOutputToEntries = (output: string) =>
783
+ output
784
+ .split("\n")
785
+ .map((line) => line.trim())
786
+ .filter(Boolean)
787
+ .map(parseEventLine);
788
+
789
+ const buildResult = (
790
+ entries: Array<{ title: string; startAt: string; calendar: string; uid: string }>,
791
+ ) => ({
792
+ from: fromRaw,
793
+ to: toRaw,
794
+ fromResolved: fromLabel,
795
+ toResolved: toLabel,
796
+ queriedRange: formatQueriedRange(fromParts, toParts),
797
+ count: entries.length,
798
+ entries,
799
+ });
800
+
801
+ if (calendarFilter !== "all") {
802
+ const output = await runOneList(calendarFilter, CALENDAR_LIST_SINGLE_TIMEOUT_MS);
803
+ const entries = parseOutputToEntries(output);
804
+ return buildResult(entries);
805
+ }
806
+
807
+ let calendarNames: string[] = [];
808
+ try {
809
+ const namesOutput = await runAppleScript(
810
+ GET_CALENDAR_NAMES_SCRIPT,
811
+ [],
812
+ context.signal,
813
+ GET_CALENDAR_NAMES_TIMEOUT_MS,
814
+ );
815
+ calendarNames = namesOutput.split("\t").map((n) => n.trim()).filter(Boolean);
816
+ } catch {
817
+ /* fallback to single-script "all" below */
818
+ }
819
+
820
+ if (calendarNames.length > 0) {
821
+ // Skip slow/system calendars for faster queries
822
+ const slowCalendars = new Set(["Birthdays", "Siri Suggestions", "US Holidays", "Scheduled Reminders", "Calendar"]);
823
+ const fastCalendars = calendarNames.filter(name => !slowCalendars.has(name));
824
+
825
+ const results = await Promise.allSettled(
826
+ fastCalendars.map((name) => runOneList(name, CALENDAR_LIST_PARALLEL_TIMEOUT_MS)),
827
+ );
828
+ const allLines: string[] = [];
829
+ for (const result of results) {
830
+ if (result.status === "fulfilled" && result.value) {
831
+ const lines = result.value.split("\n").map((l) => l.trim()).filter(Boolean);
832
+ allLines.push(...lines);
833
+ }
834
+ }
835
+ const entries = allLines
836
+ .map(parseEventLine)
837
+ .sort((a, b) => (a.startAt < b.startAt ? -1 : a.startAt > b.startAt ? 1 : 0))
838
+ .slice(0, limit);
839
+ return buildResult(entries);
840
+ }
841
+
842
+ const output = await runOneList("all", CALENDAR_LIST_FALLBACK_TIMEOUT_MS);
843
+ const entries = parseOutputToEntries(output);
844
+ return buildResult(entries);
845
+ }
846
+
847
+ async function runCalendarUpdateEvent(input: unknown, context: ToolExecutionContext) {
848
+ ensureMacOS("Calendar automation");
849
+ const payload = asObject(input) as CalendarUpdateInput;
850
+ const eventUid = asString(payload.eventUid, "eventUid");
851
+ const calendarName =
852
+ typeof payload.calendar === "string" && payload.calendar.trim()
853
+ ? payload.calendar.trim()
854
+ : "";
855
+ const title = typeof payload.title === "string" && payload.title.trim() ? payload.title.trim() : null;
856
+ const startInput = typeof payload.start === "string" && payload.start.trim() ? payload.start.trim() : null;
857
+ const endInput = typeof payload.end === "string" && payload.end.trim() ? payload.end.trim() : null;
858
+ const location = typeof payload.location === "string" ? payload.location.trim() : null;
859
+ const notes = typeof payload.notes === "string" ? payload.notes.trim() : null;
860
+
861
+ const startParts = startInput ? parseDateTimeParts(startInput, { defaultHour: 9, defaultMinute: 0 }) : null;
862
+ const endParts = endInput
863
+ ? parseDateTimeParts(endInput, {
864
+ defaultHour: startParts?.hour ?? 9,
865
+ defaultMinute: startParts?.minute ?? 0,
866
+ })
867
+ : startParts
868
+ ? addMinutes(startParts, 60)
869
+ : null;
870
+
871
+ if (context.localTools.dryRun) {
872
+ return {
873
+ dryRun: true,
874
+ action: "calendar_update_event",
875
+ eventUid,
876
+ calendar: calendarName || null,
877
+ title,
878
+ start: startInput,
879
+ end: endInput,
880
+ startParts: startParts ?? null,
881
+ endParts: endParts ?? null,
882
+ location,
883
+ notes,
884
+ };
885
+ }
370
886
 
371
887
  const script =
372
888
  'on run argv\n' +
373
- 'set fromText to item 1 of argv\n' +
374
- 'set toText to item 2 of argv\n' +
375
- 'set maxCount to (item 3 of argv) as integer\n' +
376
- "set outText to \"\"\n" +
377
- "set countItems to 0\n" +
889
+ 'set eventUid to item 1 of argv\n' +
890
+ 'set newTitle to item 2 of argv\n' +
891
+ 'set startFlag to item 3 of argv\n' +
892
+ 'set startYear to (item 4 of argv) as integer\n' +
893
+ 'set startMonth to (item 5 of argv) as integer\n' +
894
+ 'set startDay to (item 6 of argv) as integer\n' +
895
+ 'set startHour to (item 7 of argv) as integer\n' +
896
+ 'set startMinute to (item 8 of argv) as integer\n' +
897
+ 'set endFlag to item 9 of argv\n' +
898
+ 'set endYear to (item 10 of argv) as integer\n' +
899
+ 'set endMonth to (item 11 of argv) as integer\n' +
900
+ 'set endDay to (item 12 of argv) as integer\n' +
901
+ 'set endHour to (item 13 of argv) as integer\n' +
902
+ 'set endMinute to (item 14 of argv) as integer\n' +
903
+ 'set newLocation to item 15 of argv\n' +
904
+ 'set newNotes to item 16 of argv\n' +
378
905
  'tell application "Calendar"\n' +
379
- "set fromDate to date fromText\n" +
380
- "set toDate to date toText\n" +
381
- "repeat with cal in calendars\n" +
382
- "repeat with e in (every event of cal whose start date is greater than or equal to fromDate and start date is less than or equal to toDate)\n" +
383
- 'set outText to outText & (summary of e as text) & tab & ((start date of e) as text) & tab & (name of cal as text) & linefeed\n' +
384
- "set countItems to countItems + 1\n" +
385
- "if countItems is greater than or equal to maxCount then return outText\n" +
386
- "end repeat\n" +
387
- "end repeat\n" +
906
+ 'set targetEvent to missing value\n' +
907
+ 'repeat with cal in calendars\n' +
908
+ 'try\n' +
909
+ 'set targetEvent to (first event of cal whose uid is eventUid)\n' +
910
+ 'if targetEvent is not missing value then exit repeat\n' +
911
+ 'end try\n' +
912
+ 'end repeat\n' +
913
+ 'if targetEvent is missing value then error "Event not found with uid: " & eventUid\n' +
914
+ 'if newTitle is not "" then set summary of targetEvent to newTitle\n' +
915
+ 'if startFlag is "1" then\n' +
916
+ 'set startDate to current date\n' +
917
+ 'set year of startDate to startYear\n' +
918
+ 'set month of startDate to startMonth\n' +
919
+ 'set day of startDate to startDay\n' +
920
+ 'set time of startDate to ((startHour * hours) + (startMinute * minutes))\n' +
921
+ 'set start date of targetEvent to startDate\n' +
922
+ 'end if\n' +
923
+ 'if endFlag is "1" then\n' +
924
+ 'set endDate to current date\n' +
925
+ 'set year of endDate to endYear\n' +
926
+ 'set month of endDate to endMonth\n' +
927
+ 'set day of endDate to endDay\n' +
928
+ 'set time of endDate to ((endHour * hours) + (endMinute * minutes))\n' +
929
+ 'set end date of targetEvent to endDate\n' +
930
+ 'end if\n' +
931
+ 'if newLocation is not "" then set location of targetEvent to newLocation\n' +
932
+ 'if newNotes is not "" then set description of targetEvent to newNotes\n' +
388
933
  "end tell\n" +
389
- "return outText\n" +
934
+ 'return "updated"\n' +
390
935
  "end run";
391
- const output = await runAppleScript(script, [from, to, String(limit)], context.signal);
392
- const entries = output
393
- .split("\n")
394
- .map((line) => line.trim())
395
- .filter(Boolean)
396
- .map((line) => {
397
- const [title, startAt, calendar] = line.split("\t");
398
- return { title, startAt, calendar };
399
- });
936
+ const args = [
937
+ eventUid,
938
+ title ?? "",
939
+ startParts ? "1" : "0",
940
+ String(startParts?.year ?? 0),
941
+ String(startParts?.month ?? 0),
942
+ String(startParts?.day ?? 0),
943
+ String(startParts?.hour ?? 0),
944
+ String(startParts?.minute ?? 0),
945
+ endParts ? "1" : "0",
946
+ String(endParts?.year ?? 0),
947
+ String(endParts?.month ?? 0),
948
+ String(endParts?.day ?? 0),
949
+ String(endParts?.hour ?? 0),
950
+ String(endParts?.minute ?? 0),
951
+ location ?? "",
952
+ notes ?? "",
953
+ ];
954
+ await runAppleScript(script, args, context.signal);
955
+ return {
956
+ updated: true,
957
+ eventUid,
958
+ title: title ?? undefined,
959
+ start: startInput ?? undefined,
960
+ end: endInput ?? undefined,
961
+ location: location ?? undefined,
962
+ notes: notes ?? undefined,
963
+ };
964
+ }
965
+
966
+ async function runCalendarDeleteEvent(input: unknown, context: ToolExecutionContext) {
967
+ ensureMacOS("Calendar automation");
968
+ const payload = asObject(input) as CalendarDeleteInput;
969
+ const eventUid = asString(payload.eventUid, "eventUid");
970
+ const calendarName =
971
+ typeof payload.calendar === "string" && payload.calendar.trim()
972
+ ? payload.calendar.trim()
973
+ : "";
400
974
 
401
- return { from, to, count: entries.length, entries };
975
+ if (context.localTools.dryRun) {
976
+ return { dryRun: true, action: "calendar_delete_event", eventUid, calendar: calendarName || null };
977
+ }
978
+
979
+ const script =
980
+ 'on run argv\n' +
981
+ 'set eventUid to item 1 of argv\n' +
982
+ 'set calName to item 2 of argv\n' +
983
+ 'tell application "Calendar"\n' +
984
+ 'set targetEvent to missing value\n' +
985
+ 'if calName is not "" then\n' +
986
+ 'try\n' +
987
+ 'set targetCal to calendar calName\n' +
988
+ 'set targetEvent to (first event of targetCal whose uid is eventUid)\n' +
989
+ 'end try\n' +
990
+ 'end if\n' +
991
+ 'if targetEvent is missing value then\n' +
992
+ 'repeat with cal in calendars\n' +
993
+ 'try\n' +
994
+ 'set targetEvent to (first event of cal whose uid is eventUid)\n' +
995
+ 'if targetEvent is not missing value then exit repeat\n' +
996
+ 'end try\n' +
997
+ 'end repeat\n' +
998
+ 'end if\n' +
999
+ 'if targetEvent is missing value then error "Event not found with uid: " & eventUid\n' +
1000
+ 'delete targetEvent\n' +
1001
+ "end tell\n" +
1002
+ 'return "deleted"\n' +
1003
+ "end run";
1004
+ await runAppleScript(script, [eventUid, calendarName], context.signal);
1005
+ return { deleted: true, eventUid };
1006
+ }
1007
+
1008
+ async function runCalendarMoveEvent(input: unknown, context: ToolExecutionContext) {
1009
+ ensureMacOS("Calendar automation");
1010
+ const payload = asObject(input) as CalendarMoveInput;
1011
+ const eventUid = asString(payload.eventUid, "eventUid");
1012
+ const startInput = asString(payload.start, "start");
1013
+ const endInput =
1014
+ typeof payload.end === "string" && payload.end.trim() ? payload.end.trim() : "";
1015
+ const startParts = parseDateTimeParts(startInput, { defaultHour: 9, defaultMinute: 0 });
1016
+ if (!startParts) {
1017
+ throw new Error(`Invalid start date/time: ${startInput}`);
1018
+ }
1019
+ const endParts = endInput
1020
+ ? parseDateTimeParts(endInput, {
1021
+ defaultHour: startParts.hour,
1022
+ defaultMinute: startParts.minute,
1023
+ })
1024
+ : addMinutes(startParts, 60);
1025
+ if (!endParts) {
1026
+ throw new Error(`Invalid end date/time: ${endInput}`);
1027
+ }
1028
+
1029
+ if (context.localTools.dryRun) {
1030
+ return {
1031
+ dryRun: true,
1032
+ action: "calendar_move_event",
1033
+ eventUid,
1034
+ start: startInput,
1035
+ end: endInput || undefined,
1036
+ startParts,
1037
+ endParts,
1038
+ };
1039
+ }
1040
+
1041
+ const script =
1042
+ 'on run argv\n' +
1043
+ 'set eventUid to item 1 of argv\n' +
1044
+ 'set startYear to (item 2 of argv) as integer\n' +
1045
+ 'set startMonth to (item 3 of argv) as integer\n' +
1046
+ 'set startDay to (item 4 of argv) as integer\n' +
1047
+ 'set startHour to (item 5 of argv) as integer\n' +
1048
+ 'set startMinute to (item 6 of argv) as integer\n' +
1049
+ 'set endYear to (item 7 of argv) as integer\n' +
1050
+ 'set endMonth to (item 8 of argv) as integer\n' +
1051
+ 'set endDay to (item 9 of argv) as integer\n' +
1052
+ 'set endHour to (item 10 of argv) as integer\n' +
1053
+ 'set endMinute to (item 11 of argv) as integer\n' +
1054
+ 'tell application "Calendar"\n' +
1055
+ 'set targetEvent to missing value\n' +
1056
+ 'set targetCal to missing value\n' +
1057
+ 'repeat with cal in calendars\n' +
1058
+ 'try\n' +
1059
+ 'set targetEvent to (first event of cal whose uid is eventUid)\n' +
1060
+ 'if targetEvent is not missing value then\n' +
1061
+ 'set targetCal to cal\n' +
1062
+ 'exit repeat\n' +
1063
+ 'end if\n' +
1064
+ 'end try\n' +
1065
+ 'end repeat\n' +
1066
+ 'if targetEvent is missing value then error "Event not found with uid: " & eventUid\n' +
1067
+ 'set eventSummary to (summary of targetEvent as text)\n' +
1068
+ 'set eventLocation to (location of targetEvent as text)\n' +
1069
+ 'set eventDesc to (description of targetEvent as text)\n' +
1070
+ 'set newStart to current date\n' +
1071
+ 'set year of newStart to startYear\n' +
1072
+ 'set month of newStart to startMonth\n' +
1073
+ 'set day of newStart to startDay\n' +
1074
+ 'set hours of newStart to startHour\n' +
1075
+ 'set minutes of newStart to startMinute\n' +
1076
+ 'set seconds of newStart to 0\n' +
1077
+ 'set newEnd to current date\n' +
1078
+ 'set year of newEnd to endYear\n' +
1079
+ 'set month of newEnd to endMonth\n' +
1080
+ 'set day of newEnd to endDay\n' +
1081
+ 'set hours of newEnd to endHour\n' +
1082
+ 'set minutes of newEnd to endMinute\n' +
1083
+ 'set seconds of newEnd to 0\n' +
1084
+ 'tell targetCal\n' +
1085
+ 'set newEvent to make new event with properties {summary:eventSummary, start date:newStart, end date:newEnd, location:eventLocation, description:eventDesc}\n' +
1086
+ 'end tell\n' +
1087
+ 'delete targetEvent\n' +
1088
+ "end tell\n" +
1089
+ 'return "moved"\n' +
1090
+ "end run";
1091
+ const args = [
1092
+ eventUid,
1093
+ String(startParts.year),
1094
+ String(startParts.month),
1095
+ String(startParts.day),
1096
+ String(startParts.hour),
1097
+ String(startParts.minute),
1098
+ String(endParts.year),
1099
+ String(endParts.month),
1100
+ String(endParts.day),
1101
+ String(endParts.hour),
1102
+ String(endParts.minute),
1103
+ ];
1104
+ await runAppleScript(script, args, context.signal);
1105
+ return {
1106
+ moved: true,
1107
+ eventUid,
1108
+ start: startInput,
1109
+ end: endInput || undefined,
1110
+ };
402
1111
  }
403
1112
 
404
1113
  async function runReminderCreate(input: unknown, context: ToolExecutionContext) {
@@ -512,15 +1221,36 @@ async function runReminderList(input: unknown, context: ToolExecutionContext) {
512
1221
  }
513
1222
 
514
1223
  export const scheduleTools: ToolDefinition[] = [
1224
+ {
1225
+ name: "calendar_list_events",
1226
+ description:
1227
+ "List events from the user's Apple Calendar (macOS Calendar app only). Call this first when the user wants to move, delete, or reschedule an event—you need the event's uid. For ranges use from/to: 'today', 'tomorrow', 'next week', 'end of next week', 'next month', 'end of next month', 'this month', 'end of this month', or YYYY-MM-DD. For 'upcoming events for next week/month' use e.g. from: 'today', to: 'end of next week' or to: 'end of next month'. When the user names a specific day (e.g. 'next Saturday', 'Feb 14'), pass explicit YYYY-MM-DD for both from and to. Pass calendar: 'all' to include every calendar in Apple Calendar. Returns title, startAt, calendar name, uid, and queriedRange. Report the exact queriedRange (or fromResolved–toResolved) from the result to the user.",
1228
+ inputSchema: {
1229
+ type: "object",
1230
+ properties: {
1231
+ from: { type: "string", description: "Start of range. YYYY-MM-DD, or 'today', 'tomorrow', 'next week', 'next month', 'this month', etc." },
1232
+ to: { type: "string", description: "End of range. Same as from for one day, or 'end of next week', 'end of next month', 'end of this month', etc." },
1233
+ limit: { type: "number", description: "Max events to return (default 50, max 100). Higher values may be slower." },
1234
+ calendar: {
1235
+ type: "string",
1236
+ description:
1237
+ "Optional. Omit for main calendar only (fast). Use 'all' to search all calendars (slower). Or a calendar name e.g. 'Work'.",
1238
+ },
1239
+ },
1240
+ additionalProperties: false,
1241
+ },
1242
+ risk: "read",
1243
+ execute: runCalendarListEvents,
1244
+ },
515
1245
  {
516
1246
  name: "calendar_create_event",
517
- description: "Create a Calendar event.",
1247
+ description: "Create a Calendar event. Use explicit dates/times (e.g. YYYY-MM-DD or 'Friday at 2pm') so the event is created on the correct day.",
518
1248
  inputSchema: {
519
1249
  type: "object",
520
1250
  required: ["title", "start"],
521
1251
  properties: {
522
1252
  title: { type: "string" },
523
- start: { type: "string" },
1253
+ start: { type: "string", description: "Start date/time: YYYY-MM-DD, YYYY-MM-DDTHH:mm, or natural like 'Friday at 2pm'" },
524
1254
  end: { type: "string" },
525
1255
  location: { type: "string" },
526
1256
  notes: { type: "string" },
@@ -531,19 +1261,59 @@ export const scheduleTools: ToolDefinition[] = [
531
1261
  execute: runCalendarCreateEvent,
532
1262
  },
533
1263
  {
534
- name: "calendar_list_events",
535
- description: "List calendar events in a time range.",
1264
+ name: "calendar_update_event",
1265
+ description:
1266
+ "Update an existing calendar event in place (reschedule, rename, change location/notes). Prefer this for rescheduling to a new time on the same calendar—it preserves recurrence and metadata. Get the event's uid from calendar_list_events first. Use when the user says reschedule, change time, or update an event. Match the user's wording to the listed event title.",
536
1267
  inputSchema: {
537
1268
  type: "object",
1269
+ required: ["eventUid"],
538
1270
  properties: {
539
- from: { type: "string" },
540
- to: { type: "string" },
541
- limit: { type: "number" },
1271
+ eventUid: { type: "string", description: "Event uid from calendar_list_events" },
1272
+ calendar: { type: "string", description: "Calendar name from list (optional; speeds up lookup)" },
1273
+ title: { type: "string" },
1274
+ start: { type: "string", description: "New start date/time" },
1275
+ end: { type: "string", description: "New end date/time" },
1276
+ location: { type: "string" },
1277
+ notes: { type: "string" },
542
1278
  },
543
1279
  additionalProperties: false,
544
1280
  },
545
- risk: "read",
546
- execute: runCalendarListEvents,
1281
+ risk: "write",
1282
+ execute: runCalendarUpdateEvent,
1283
+ },
1284
+ {
1285
+ name: "calendar_move_event",
1286
+ description:
1287
+ "Move an event by deleting the old one and creating a new event at the new start/end (same title/location/notes). Use when the user explicitly wants to 'move' and calendar_update_event is not sufficient, or when moving to another calendar. For rescheduling on the same calendar, prefer calendar_update_event (updates in place, preserves recurrence). Get the event uid from calendar_list_events first.",
1288
+ inputSchema: {
1289
+ type: "object",
1290
+ required: ["eventUid", "start"],
1291
+ properties: {
1292
+ eventUid: { type: "string", description: "Event uid from calendar_list_events" },
1293
+ start: { type: "string", description: "New start date/time (e.g. YYYY-MM-DD, 'Friday at 2pm')" },
1294
+ end: { type: "string", description: "New end date/time (optional)" },
1295
+ calendar: { type: "string", description: "Unused; event is found by uid" },
1296
+ },
1297
+ additionalProperties: false,
1298
+ },
1299
+ risk: "write",
1300
+ execute: runCalendarMoveEvent,
1301
+ },
1302
+ {
1303
+ name: "calendar_delete_event",
1304
+ description:
1305
+ "Delete a calendar event. Get the event's uid from calendar_list_events first. Match the user's request to the listed event (e.g. 'the midterm' or 'exam thing' may match 'studying for midterm').",
1306
+ inputSchema: {
1307
+ type: "object",
1308
+ required: ["eventUid"],
1309
+ properties: {
1310
+ eventUid: { type: "string", description: "Event uid from calendar_list_events" },
1311
+ calendar: { type: "string", description: "Calendar name from list (optional)" },
1312
+ },
1313
+ additionalProperties: false,
1314
+ },
1315
+ risk: "write",
1316
+ execute: runCalendarDeleteEvent,
547
1317
  },
548
1318
  {
549
1319
  name: "reminder_create",