@mgcrea/mcp-apple-calendar 0.0.0-bootstrap → 1.3.1
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/dist/cli.js +1 -1
- package/dist/index.d.ts +133 -0
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +1 -1
- package/dist/{server-B2HtiXLF.js → server-DoSfe1y7.js} +719 -95
- package/dist/server-DoSfe1y7.js.map +1 -0
- package/package.json +2 -2
- package/dist/server-B2HtiXLF.js.map +0 -1
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import { AppBusyError as CalendarBusyError, AppNotRunningError as CalendarNotRunningError, AppleAutomationError, AppleAutomationError as AppleCalendarError, BaseConfigSchema, CORE_DATA_EPOCH_OFFSET, IndexUnavailableError, PreconditionError, SchemaDriftError, columnsOf, confirmArg, createOsascriptRunner, describeStore, escapeLike, fingerprintSchema, limitArg, ok, openReadOnly, parseBool, parseConfig, parseIntOpt, parseList, readPackageIdentity, trimmed, withBusyRetry, wrap } from "@mgcrea/mcp-apple-core";
|
|
1
|
+
import { AppBusyError as CalendarBusyError, AppNotRunningError as CalendarNotRunningError, AppleAutomationError, AppleAutomationError as AppleCalendarError, BaseConfigSchema, CORE_DATA_EPOCH_OFFSET, IndexUnavailableError, PreconditionError, SchemaDriftError, columnsOf, confirmArg, createOsascriptRunner, describeStore, escapeLike, fingerprintSchema, limitArg, ok, openReadOnly, parseBool, parseConfig, parseIntOpt, parseList, promptArg, readPackageIdentity, registerSurfaceResources, registerWorkflowPrompt, requiredPromptArg, trimmed, withBusyRetry, wrap } from "@mgcrea/mcp-apple-core";
|
|
2
2
|
import { readdirSync } from "node:fs";
|
|
3
3
|
import { homedir } from "node:os";
|
|
4
4
|
import { join } from "node:path";
|
|
@@ -12,8 +12,8 @@ const pkg = readPackageIdentity(new URL("../package.json", import.meta.url), {
|
|
|
12
12
|
const BUILD_INFO = {
|
|
13
13
|
name: pkg.name,
|
|
14
14
|
version: pkg.version,
|
|
15
|
-
gitCommit: "
|
|
16
|
-
gitCommitDate: "2026-08-
|
|
15
|
+
gitCommit: "312147f",
|
|
16
|
+
gitCommitDate: "2026-08-27T16:06:37+02:00"
|
|
17
17
|
};
|
|
18
18
|
//#endregion
|
|
19
19
|
//#region src/client/errors.ts
|
|
@@ -79,6 +79,187 @@ var CalendarNotWritableError = class extends AppleAutomationError {
|
|
|
79
79
|
}
|
|
80
80
|
};
|
|
81
81
|
//#endregion
|
|
82
|
+
//#region src/client/availability.ts
|
|
83
|
+
/**
|
|
84
|
+
* Free-time arithmetic: turning a set of events into the times nothing is on.
|
|
85
|
+
*
|
|
86
|
+
* ## Why this is its own module, and why it is pure
|
|
87
|
+
*
|
|
88
|
+
* `recurrence.ts` set the precedent — the piece whose correctness rests on a
|
|
89
|
+
* measurement lives apart from the SQL. This is the mirror image. Nothing here
|
|
90
|
+
* rests on a measurement at all; it is interval arithmetic and calendar-day
|
|
91
|
+
* iteration, and it is separated for the opposite reason: it is the only part
|
|
92
|
+
* of an availability answer that can be tested without a store, and the part
|
|
93
|
+
* where an off-by-one books a meeting on top of another meeting.
|
|
94
|
+
*
|
|
95
|
+
* ## The rule that governs every decision below
|
|
96
|
+
*
|
|
97
|
+
* A gap reported here is an assertion that NOTHING is on the calendar then.
|
|
98
|
+
* `docs/calendar.md` already names why that is dangerous: "a short list of
|
|
99
|
+
* events is indistinguishable from a free afternoon". Every other read tool on
|
|
100
|
+
* this surface can afford to return a short list and flag it. This one cannot —
|
|
101
|
+
* shortening the busy set does not shorten the answer, it INVENTS free time.
|
|
102
|
+
* So the caller of this module must hand it a complete busy set or refuse, and
|
|
103
|
+
* everything here assumes that contract has already been checked.
|
|
104
|
+
*
|
|
105
|
+
* ## Local wall clock, deliberately
|
|
106
|
+
*
|
|
107
|
+
* Working hours are a human's, not UTC's: "09:00 to 18:00" means those numbers
|
|
108
|
+
* on the office wall, on both sides of a daylight-saving change. So day
|
|
109
|
+
* boundaries are built with local date components — the same choice, for the
|
|
110
|
+
* same reason, as `at()` in `dates.ts` — and a day that loses an hour simply
|
|
111
|
+
* has one fewer hour in it, which is what actually happened.
|
|
112
|
+
*/
|
|
113
|
+
const WEEKDAY_KEYS = [
|
|
114
|
+
"sun",
|
|
115
|
+
"mon",
|
|
116
|
+
"tue",
|
|
117
|
+
"wed",
|
|
118
|
+
"thu",
|
|
119
|
+
"fri",
|
|
120
|
+
"sat"
|
|
121
|
+
];
|
|
122
|
+
/** Monday to Friday, the default working week. */
|
|
123
|
+
const DEFAULT_WEEKDAYS = [
|
|
124
|
+
"mon",
|
|
125
|
+
"tue",
|
|
126
|
+
"wed",
|
|
127
|
+
"thu",
|
|
128
|
+
"fri"
|
|
129
|
+
];
|
|
130
|
+
const CLOCK$1 = /^(\d{1,2}):(\d{2})$/;
|
|
131
|
+
const pad$2 = (n) => String(n).padStart(2, "0");
|
|
132
|
+
const parseClock = (field, raw) => {
|
|
133
|
+
const text = String(raw ?? "").trim();
|
|
134
|
+
const m = CLOCK$1.exec(text);
|
|
135
|
+
if (!m) throw new InvalidDateError(field, text, "expected a time of day like \"09:00\"");
|
|
136
|
+
const hours = Number(m[1]);
|
|
137
|
+
const minutes = Number(m[2]);
|
|
138
|
+
if (hours > 24 || minutes > 59) throw new InvalidDateError(field, text, `${hours}:${m[2]} is not a time of day`);
|
|
139
|
+
if (hours === 24 && minutes !== 0) throw new InvalidDateError(field, text, "24:00 is the latest time of day, so \"24:30\" is not one");
|
|
140
|
+
return {
|
|
141
|
+
hours,
|
|
142
|
+
minutes
|
|
143
|
+
};
|
|
144
|
+
};
|
|
145
|
+
const weekdaySet = (keys) => new Set(keys.map((k) => WEEKDAY_KEYS.indexOf(k)).filter((i) => i !== -1));
|
|
146
|
+
const startOfLocalDay = (d) => new Date(d.getFullYear(), d.getMonth(), d.getDate(), 0, 0, 0, 0);
|
|
147
|
+
/** Same wall-clock time, n days later. Calendar arithmetic, so DST-safe. */
|
|
148
|
+
const addDays$1 = (d, n) => {
|
|
149
|
+
const out = new Date(d.getTime());
|
|
150
|
+
out.setDate(out.getDate() + n);
|
|
151
|
+
return out;
|
|
152
|
+
};
|
|
153
|
+
/**
|
|
154
|
+
* A time of day on a given local date.
|
|
155
|
+
*
|
|
156
|
+
* `new Date(y, m, d, 24, 0)` rolls to the next midnight on purpose, which is
|
|
157
|
+
* what makes `dayEnd: "24:00"` mean "to the end of this day" rather than an
|
|
158
|
+
* error case the caller has to special-case.
|
|
159
|
+
*/
|
|
160
|
+
const atClock = (day, c) => new Date(day.getFullYear(), day.getMonth(), day.getDate(), c.hours, c.minutes, 0, 0).getTime();
|
|
161
|
+
/**
|
|
162
|
+
* Overlapping and touching intervals collapsed into the fewest that cover the
|
|
163
|
+
* same time, in order.
|
|
164
|
+
*
|
|
165
|
+
* Touching counts as overlapping (`<=`, not `<`): two meetings that end and
|
|
166
|
+
* begin on the same minute leave no gap, and emitting a zero-length one would
|
|
167
|
+
* put "you are free from 10:00 to 10:00" in front of a model.
|
|
168
|
+
*/
|
|
169
|
+
const mergeIntervals = (input) => {
|
|
170
|
+
const sorted = input.filter((i) => i.to > i.from).toSorted((a, b) => a.from - b.from);
|
|
171
|
+
const out = [];
|
|
172
|
+
for (const cur of sorted) {
|
|
173
|
+
const last = out.at(-1);
|
|
174
|
+
if (last && cur.from <= last.to) {
|
|
175
|
+
if (cur.to > last.to) last.to = cur.to;
|
|
176
|
+
continue;
|
|
177
|
+
}
|
|
178
|
+
out.push({
|
|
179
|
+
from: cur.from,
|
|
180
|
+
to: cur.to
|
|
181
|
+
});
|
|
182
|
+
}
|
|
183
|
+
return out;
|
|
184
|
+
};
|
|
185
|
+
/**
|
|
186
|
+
* What is left of `window` once every busy interval is taken out of it.
|
|
187
|
+
*
|
|
188
|
+
* `busy` MUST already be merged and sorted — pass it through `mergeIntervals`
|
|
189
|
+
* first. Taking unsorted input here would silently under-subtract and hand back
|
|
190
|
+
* time that is booked, which is the one error this module exists to prevent, so
|
|
191
|
+
* the precondition is stated rather than defended against: a defensive re-sort
|
|
192
|
+
* would hide the caller's mistake instead of making it impossible.
|
|
193
|
+
*/
|
|
194
|
+
const subtractBusy = (window, busy) => {
|
|
195
|
+
const out = [];
|
|
196
|
+
let cursor = window.from;
|
|
197
|
+
for (const b of busy) {
|
|
198
|
+
if (b.to <= cursor) continue;
|
|
199
|
+
if (b.from >= window.to) break;
|
|
200
|
+
if (b.from > cursor) out.push({
|
|
201
|
+
from: cursor,
|
|
202
|
+
to: b.from
|
|
203
|
+
});
|
|
204
|
+
cursor = b.to;
|
|
205
|
+
if (cursor >= window.to) return out;
|
|
206
|
+
}
|
|
207
|
+
if (cursor < window.to) out.push({
|
|
208
|
+
from: cursor,
|
|
209
|
+
to: window.to
|
|
210
|
+
});
|
|
211
|
+
return out;
|
|
212
|
+
};
|
|
213
|
+
/**
|
|
214
|
+
* The working windows inside `[from, to]`, one per admitted weekday.
|
|
215
|
+
*
|
|
216
|
+
* A `dayEnd` at or before `dayStart` yields no window for that day rather than
|
|
217
|
+
* a negative one. Overnight working hours are not expressible, and saying so
|
|
218
|
+
* with an empty result is better than wrapping around midnight and reporting
|
|
219
|
+
* free time on a day the caller never asked about.
|
|
220
|
+
*/
|
|
221
|
+
const dayWindows = (opts) => {
|
|
222
|
+
const out = [];
|
|
223
|
+
const last = opts.to.getTime();
|
|
224
|
+
for (let day = startOfLocalDay(opts.from); day.getTime() <= last; day = addDays$1(day, 1)) {
|
|
225
|
+
if (!opts.weekdays.has(day.getDay())) continue;
|
|
226
|
+
const open = atClock(day, opts.dayStart);
|
|
227
|
+
const close = atClock(day, opts.dayEnd);
|
|
228
|
+
if (close <= open) continue;
|
|
229
|
+
const from = Math.max(open, opts.from.getTime());
|
|
230
|
+
const to = Math.min(close, last);
|
|
231
|
+
if (to > from) out.push({
|
|
232
|
+
from,
|
|
233
|
+
to
|
|
234
|
+
});
|
|
235
|
+
}
|
|
236
|
+
return out;
|
|
237
|
+
};
|
|
238
|
+
/**
|
|
239
|
+
* Round an instant up to the next granularity boundary of its own hour.
|
|
240
|
+
*
|
|
241
|
+
* Anchored to the top of the hour rather than to the window, so a slot lands on
|
|
242
|
+
* 09:15 and not on 09:07 because that is when the previous meeting happened to
|
|
243
|
+
* end. Callers restrict granularity to a divisor of 60, which is what keeps the
|
|
244
|
+
* per-hour restart invisible.
|
|
245
|
+
*/
|
|
246
|
+
const alignUp = (ms, granularityMinutes) => {
|
|
247
|
+
const d = new Date(ms);
|
|
248
|
+
const topOfHour = new Date(d.getFullYear(), d.getMonth(), d.getDate(), d.getHours(), 0, 0, 0).getTime();
|
|
249
|
+
const step = granularityMinutes * 6e4;
|
|
250
|
+
const past = ms - topOfHour;
|
|
251
|
+
return topOfHour + Math.ceil(past / step) * step;
|
|
252
|
+
};
|
|
253
|
+
/** Local midnight at the start of the day an instant falls on. */
|
|
254
|
+
const startOfLocalDayMs = (ms) => startOfLocalDay(new Date(ms)).getTime();
|
|
255
|
+
/** The following local midnight. Calendar arithmetic, so a DST day is 23h or 25h. */
|
|
256
|
+
const nextLocalDayMs = (ms) => addDays$1(startOfLocalDay(new Date(ms)), 1).getTime();
|
|
257
|
+
/** `2026-08-24`, in local components — the day a slot falls on. */
|
|
258
|
+
const localDay = (ms) => {
|
|
259
|
+
const d = new Date(ms);
|
|
260
|
+
return `${d.getFullYear()}-${pad$2(d.getMonth() + 1)}-${pad$2(d.getDate())}`;
|
|
261
|
+
};
|
|
262
|
+
//#endregion
|
|
82
263
|
//#region src/client/dates.ts
|
|
83
264
|
/**
|
|
84
265
|
* Date handling for the Calendar tools.
|
|
@@ -1464,6 +1645,29 @@ const openStore = (path, mode, logger) => {
|
|
|
1464
1645
|
*/
|
|
1465
1646
|
const STATUS_CANCELLED = 3;
|
|
1466
1647
|
const PARTICIPANT_DECLINED = 3;
|
|
1648
|
+
/**
|
|
1649
|
+
* `EKEventAvailability.free`, on the same inference as the two above — and held
|
|
1650
|
+
* to a stricter standard, because the direction it can fail in is worse.
|
|
1651
|
+
*
|
|
1652
|
+
* Reading this number wrongly makes a busy event look free, which is the one
|
|
1653
|
+
* error `findAvailability` exists to avoid. So unlike `status`, it is not
|
|
1654
|
+
* applied by default at all: `respectFreeMarking` is opt-in, the raw value
|
|
1655
|
+
* travels on every busy block, and until someone measures the column against a
|
|
1656
|
+
* live store every event blocks time regardless of how it is marked.
|
|
1657
|
+
*/
|
|
1658
|
+
const AVAILABILITY_FREE = 1;
|
|
1659
|
+
/**
|
|
1660
|
+
* How many events `findAvailability` will read from one leg before refusing.
|
|
1661
|
+
*
|
|
1662
|
+
* `listEvents` over-fetches `limit * 4 + 50` and caps at 5,000 because it only
|
|
1663
|
+
* needs enough rows to fill a page. This tool needs EVERY row in the window, so
|
|
1664
|
+
* the number here is not a page size — it is the point past which the tool
|
|
1665
|
+
* stops claiming to know what is on the calendar. Sized against the measured
|
|
1666
|
+
* store in `docs/calendar.md` (1,350 items, 1,946 cached occurrences across
|
|
1667
|
+
* four years), so a year-long window on an ordinary calendar sits far under it
|
|
1668
|
+
* and a runaway one is caught rather than answered.
|
|
1669
|
+
*/
|
|
1670
|
+
const BUSY_SCAN_BOUND = 5e3;
|
|
1467
1671
|
var AppleCalendarClient = class {
|
|
1468
1672
|
config;
|
|
1469
1673
|
runner;
|
|
@@ -1706,6 +1910,212 @@ var AppleCalendarClient = class {
|
|
|
1706
1910
|
coverage: null
|
|
1707
1911
|
};
|
|
1708
1912
|
}
|
|
1913
|
+
/**
|
|
1914
|
+
* When nothing is on the calendar, for long enough to hold a meeting.
|
|
1915
|
+
*
|
|
1916
|
+
* ## Why this is not list_events with arithmetic bolted on
|
|
1917
|
+
*
|
|
1918
|
+
* Every other read here returns what it found and flags what it missed, and
|
|
1919
|
+
* a caller reads the flag or does not. This one INVERTS the events: what it
|
|
1920
|
+
* returns is the complement of what it read, so anything the read missed
|
|
1921
|
+
* comes back as free time. A page limit, an unexpanded weekly meeting, an
|
|
1922
|
+
* event past the coverage edge — each of those shortens a `list_events`
|
|
1923
|
+
* result harmlessly and each of them, here, invents a slot that is already
|
|
1924
|
+
* booked. `docs/calendar.md` names the failure directly: "a short list of
|
|
1925
|
+
* events is indistinguishable from a free afternoon."
|
|
1926
|
+
*
|
|
1927
|
+
* So the busy set is either complete or the answer is withheld. Three checks
|
|
1928
|
+
* enforce that, and each returns a refusal rather than a short list:
|
|
1929
|
+
*
|
|
1930
|
+
* 1. the scan bound, so a saturated leg never passes for a quiet week
|
|
1931
|
+
* 2. the expansion, because an unexpanded series is invisible on every
|
|
1932
|
+
* date but one
|
|
1933
|
+
* 3. the coverage edge, past which repeating events simply are not there
|
|
1934
|
+
*
|
|
1935
|
+
* The third clips the window rather than refusing outright, because the part
|
|
1936
|
+
* inside the edge is genuinely answerable — but it says what it cut.
|
|
1937
|
+
*/
|
|
1938
|
+
findAvailability(args) {
|
|
1939
|
+
const store = this.#require();
|
|
1940
|
+
const now = this.#now();
|
|
1941
|
+
const range = parseRange({
|
|
1942
|
+
from: args.from,
|
|
1943
|
+
to: args.to,
|
|
1944
|
+
defaultRangeDays: this.config.defaultRangeDays,
|
|
1945
|
+
maxRangeDays: this.config.maxRangeDays
|
|
1946
|
+
}, now);
|
|
1947
|
+
const dayStart = parseClock("dayStart", args.dayStart);
|
|
1948
|
+
const dayEnd = parseClock("dayEnd", args.dayEnd);
|
|
1949
|
+
const weekdayKeys = args.weekdays?.length ? args.weekdays : DEFAULT_WEEKDAYS;
|
|
1950
|
+
const weekdays = weekdaySet(weekdayKeys);
|
|
1951
|
+
const calendarUuids = this.#calendarUuids(store, args.calendar);
|
|
1952
|
+
const fromApple = this.#toApple(range.from, store);
|
|
1953
|
+
const toApple = this.#toApple(range.to, store);
|
|
1954
|
+
const q = {
|
|
1955
|
+
fromApple,
|
|
1956
|
+
toApple,
|
|
1957
|
+
limit: BUSY_SCAN_BOUND,
|
|
1958
|
+
...calendarUuids ? { calendarUuids } : {}
|
|
1959
|
+
};
|
|
1960
|
+
const items = store.rangeItems(q);
|
|
1961
|
+
const occurrences = store.rangeOccurrences(q);
|
|
1962
|
+
if (items.length >= BUSY_SCAN_BOUND || occurrences.length >= BUSY_SCAN_BOUND) return {
|
|
1963
|
+
degraded: true,
|
|
1964
|
+
capability: "busy-set",
|
|
1965
|
+
reason: `The window holds at least ${BUSY_SCAN_BOUND} events, which is the bound on what this tool reads. Nothing was computed, so this is not "you have no free time" and not "you are entirely free" — it is no answer at all.`,
|
|
1966
|
+
hint: "Ask for a shorter window, or scope it to the calendars that matter with `calendar`. apple_calendar_list_events will still page through the range as it is."
|
|
1967
|
+
};
|
|
1968
|
+
const merged = mergeRange({
|
|
1969
|
+
items,
|
|
1970
|
+
occurrences,
|
|
1971
|
+
coverage: store.coverage(),
|
|
1972
|
+
hasOccurrenceCache: store.caps.hasOccurrenceCache,
|
|
1973
|
+
fromApple,
|
|
1974
|
+
toApple,
|
|
1975
|
+
limit: items.length + occurrences.length,
|
|
1976
|
+
epochOffset: store.caps.epochOffset
|
|
1977
|
+
});
|
|
1978
|
+
if (merged.expansion === "unavailable") return {
|
|
1979
|
+
degraded: true,
|
|
1980
|
+
capability: "occurrence-expansion",
|
|
1981
|
+
reason: "Repeating events are not expanded on this store, so a weekly meeting exists on the date its series begins and nowhere else. Every gap computed from that would be wrong " + `in the same direction — free where it is booked. ${merged.expansionReason ?? ""}`.trim(),
|
|
1982
|
+
hint: "Read the schedule directly with apple_calendar_list_events, which reports the same limitation rather than hiding it behind an answer."
|
|
1983
|
+
};
|
|
1984
|
+
let windowFrom = range.from;
|
|
1985
|
+
let windowTo = range.to;
|
|
1986
|
+
let truncated;
|
|
1987
|
+
const cov = merged.coverage;
|
|
1988
|
+
if (cov) {
|
|
1989
|
+
const covFrom = /* @__PURE__ */ new Date((cov.fromApple + store.caps.epochOffset) * 1e3);
|
|
1990
|
+
const covTo = /* @__PURE__ */ new Date((cov.toApple + store.caps.epochOffset) * 1e3);
|
|
1991
|
+
const reason = "the requested window runs past the range this store has expanded, where repeating events are missing entirely; it was cut back rather than reporting that time as free";
|
|
1992
|
+
if (windowTo.getTime() > covTo.getTime()) {
|
|
1993
|
+
truncated = {
|
|
1994
|
+
reason,
|
|
1995
|
+
requestedTo: toLocalIso(range.to)
|
|
1996
|
+
};
|
|
1997
|
+
windowTo = covTo;
|
|
1998
|
+
}
|
|
1999
|
+
if (windowFrom.getTime() < covFrom.getTime()) {
|
|
2000
|
+
truncated = {
|
|
2001
|
+
...truncated ?? { reason },
|
|
2002
|
+
requestedFrom: toLocalIso(range.from)
|
|
2003
|
+
};
|
|
2004
|
+
windowFrom = covFrom;
|
|
2005
|
+
}
|
|
2006
|
+
if (truncated && windowTo.getTime() <= windowFrom.getTime()) return {
|
|
2007
|
+
degraded: true,
|
|
2008
|
+
capability: "occurrence-coverage",
|
|
2009
|
+
reason: "The whole requested window lies outside the range this store has expanded, so every gap in it would be invented rather than found.",
|
|
2010
|
+
hint: `The expansion reaches ${toLocalIso(covFrom)} to ${toLocalIso(covTo)}. Ask inside that, or use apple_calendar_list_events, which reports single events correctly at any distance.`
|
|
2011
|
+
};
|
|
2012
|
+
}
|
|
2013
|
+
const startedAtNow = windowFrom.getTime() < now.getTime();
|
|
2014
|
+
if (startedAtNow) windowFrom = now;
|
|
2015
|
+
const shell = {
|
|
2016
|
+
durationMinutes: args.durationMinutes,
|
|
2017
|
+
window: {
|
|
2018
|
+
from: toLocalIso(windowFrom),
|
|
2019
|
+
to: toLocalIso(windowTo),
|
|
2020
|
+
clamped: range.clamped,
|
|
2021
|
+
startedAtNow
|
|
2022
|
+
},
|
|
2023
|
+
workingHours: {
|
|
2024
|
+
dayStart: args.dayStart,
|
|
2025
|
+
dayEnd: args.dayEnd,
|
|
2026
|
+
weekdays: weekdayKeys,
|
|
2027
|
+
granularityMinutes: args.granularityMinutes,
|
|
2028
|
+
timeZone: Intl.DateTimeFormat().resolvedOptions().timeZone
|
|
2029
|
+
},
|
|
2030
|
+
expansion: merged.expansion,
|
|
2031
|
+
coverage: cov ? {
|
|
2032
|
+
from: this.#fromApple(cov.fromApple, store),
|
|
2033
|
+
to: this.#fromApple(cov.toApple, store),
|
|
2034
|
+
rows: cov.rows
|
|
2035
|
+
} : null,
|
|
2036
|
+
...truncated ? { truncated } : {}
|
|
2037
|
+
};
|
|
2038
|
+
if (windowTo.getTime() <= windowFrom.getTime()) return {
|
|
2039
|
+
...shell,
|
|
2040
|
+
slots: [],
|
|
2041
|
+
busy: {
|
|
2042
|
+
blocking: 0,
|
|
2043
|
+
intervals: 0
|
|
2044
|
+
},
|
|
2045
|
+
allDayEvents: [],
|
|
2046
|
+
note: "The window holds no future time — every moment in it has already passed. Nothing was checked, so this is not a report that you are busy."
|
|
2047
|
+
};
|
|
2048
|
+
const declined = args.includeDeclined ?? this.config.includeDeclined;
|
|
2049
|
+
const cancelled = args.includeCancelled ?? this.config.includeCancelled;
|
|
2050
|
+
const busy = [];
|
|
2051
|
+
const allDayEvents = [];
|
|
2052
|
+
let blocking = 0;
|
|
2053
|
+
for (const row of merged.rows) {
|
|
2054
|
+
if (row.startApple === null) continue;
|
|
2055
|
+
if (!this.#visible(row, {
|
|
2056
|
+
declined,
|
|
2057
|
+
cancelled
|
|
2058
|
+
})) continue;
|
|
2059
|
+
const startMs = (row.startApple + store.caps.epochOffset) * 1e3;
|
|
2060
|
+
if (row.allDay) {
|
|
2061
|
+
const dayOpen = startOfLocalDayMs(startMs);
|
|
2062
|
+
const dayShut = nextLocalDayMs(startMs);
|
|
2063
|
+
if (dayOpen < windowTo.getTime() && dayShut > windowFrom.getTime()) {
|
|
2064
|
+
const rendered = renderInstant(row.startApple, row.startTz, true, store.caps.epochOffset);
|
|
2065
|
+
allDayEvents.push({
|
|
2066
|
+
day: rendered?.allDay ? rendered.day : localDay(startMs),
|
|
2067
|
+
summary: row.summary,
|
|
2068
|
+
calendar: row.calendarTitle
|
|
2069
|
+
});
|
|
2070
|
+
}
|
|
2071
|
+
if (!args.allDayBusy) continue;
|
|
2072
|
+
const endMs = row.endApple === null ? dayShut : (row.endApple + store.caps.epochOffset) * 1e3;
|
|
2073
|
+
busy.push({
|
|
2074
|
+
from: dayOpen,
|
|
2075
|
+
to: Math.max(dayShut, startOfLocalDayMs(endMs))
|
|
2076
|
+
});
|
|
2077
|
+
blocking += 1;
|
|
2078
|
+
continue;
|
|
2079
|
+
}
|
|
2080
|
+
if (args.respectFreeMarking && row.availability === AVAILABILITY_FREE) continue;
|
|
2081
|
+
const endMs = row.endApple === null ? startMs : (row.endApple + store.caps.epochOffset) * 1e3;
|
|
2082
|
+
if (endMs <= startMs) continue;
|
|
2083
|
+
busy.push({
|
|
2084
|
+
from: startMs,
|
|
2085
|
+
to: endMs
|
|
2086
|
+
});
|
|
2087
|
+
blocking += 1;
|
|
2088
|
+
}
|
|
2089
|
+
const blocks = mergeIntervals(busy);
|
|
2090
|
+
const needMs = args.durationMinutes * 6e4;
|
|
2091
|
+
const slots = [];
|
|
2092
|
+
outer: for (const w of dayWindows({
|
|
2093
|
+
from: windowFrom,
|
|
2094
|
+
to: windowTo,
|
|
2095
|
+
dayStart,
|
|
2096
|
+
dayEnd,
|
|
2097
|
+
weekdays
|
|
2098
|
+
})) for (const gap of subtractBusy(w, blocks)) {
|
|
2099
|
+
const start = alignUp(gap.from, args.granularityMinutes);
|
|
2100
|
+
if (gap.to - start < needMs) continue;
|
|
2101
|
+
slots.push({
|
|
2102
|
+
start: toLocalIso(new Date(start)),
|
|
2103
|
+
end: toLocalIso(new Date(gap.to)),
|
|
2104
|
+
day: localDay(start),
|
|
2105
|
+
minutes: Math.round((gap.to - start) / 6e4)
|
|
2106
|
+
});
|
|
2107
|
+
if (slots.length >= args.limit) break outer;
|
|
2108
|
+
}
|
|
2109
|
+
return {
|
|
2110
|
+
...shell,
|
|
2111
|
+
slots,
|
|
2112
|
+
busy: {
|
|
2113
|
+
blocking,
|
|
2114
|
+
intervals: blocks.length
|
|
2115
|
+
},
|
|
2116
|
+
allDayEvents
|
|
2117
|
+
};
|
|
2118
|
+
}
|
|
1709
2119
|
getEvent(ref) {
|
|
1710
2120
|
const store = this.#require();
|
|
1711
2121
|
const decoded = decodeRef(ref);
|
|
@@ -1971,6 +2381,15 @@ var AppleCalendarClient = class {
|
|
|
1971
2381
|
};
|
|
1972
2382
|
//#endregion
|
|
1973
2383
|
//#region src/config.ts
|
|
2384
|
+
const CLOCK = /^\d{1,2}:\d{2}$/;
|
|
2385
|
+
/**
|
|
2386
|
+
* `Mon`, `MONDAY` and `mon` all mean the same day.
|
|
2387
|
+
*
|
|
2388
|
+
* Normalised here rather than in the schema so the schema's error message stays
|
|
2389
|
+
* the useful one — it lists the seven keys — instead of rejecting a spelling
|
|
2390
|
+
* nobody would think was wrong.
|
|
2391
|
+
*/
|
|
2392
|
+
const parseWorkdays = (raw) => raw?.map((d) => d.trim().toLowerCase().slice(0, 3));
|
|
1974
2393
|
/**
|
|
1975
2394
|
* Configuration is environment-only — this server holds no secret at all, its
|
|
1976
2395
|
* access is the macOS permission the user granted.
|
|
@@ -2014,6 +2433,17 @@ const ConfigSchema = BaseConfigSchema.extend({
|
|
|
2014
2433
|
defaultRangeDays: z.number().int().min(1).max(366).default(7),
|
|
2015
2434
|
/** Hard clamp, so one query cannot ask for a decade. */
|
|
2016
2435
|
maxRangeDays: z.number().int().min(1).max(3660).default(366),
|
|
2436
|
+
/**
|
|
2437
|
+
* The working day `find_availability` assumes when the caller names no hours.
|
|
2438
|
+
*
|
|
2439
|
+
* Local wall clock, both of them: working hours are a person's, so they hold
|
|
2440
|
+
* their numbers across a daylight-saving change rather than drifting an hour.
|
|
2441
|
+
* `24:00` is a legal end and means midnight at the close of that day.
|
|
2442
|
+
*/
|
|
2443
|
+
workdayStart: z.string().regex(CLOCK, "expected a time of day like \"09:00\"").default("09:00"),
|
|
2444
|
+
workdayEnd: z.string().regex(CLOCK, "expected a time of day like \"18:00\"").default("18:00"),
|
|
2445
|
+
/** The days that working day applies to. */
|
|
2446
|
+
workdays: z.array(z.enum(WEEKDAY_KEYS)).min(1).default([...DEFAULT_WEEKDAYS]),
|
|
2017
2447
|
/** Length of a created event when the caller gives neither an end nor a duration. */
|
|
2018
2448
|
defaultEventDurationMinutes: z.number().int().min(1).max(1440).default(60),
|
|
2019
2449
|
/** Whether events the user declined are included when the caller does not say. */
|
|
@@ -2041,6 +2471,7 @@ const ConfigSchema = BaseConfigSchema.extend({
|
|
|
2041
2471
|
*/
|
|
2042
2472
|
const loadConfig = (env = process.env) => parseConfig(ConfigSchema, {
|
|
2043
2473
|
allowWrites: parseBool(env.APPLE_CALENDAR_ALLOW_WRITES),
|
|
2474
|
+
exposePrompts: parseBool(env.APPLE_CALENDAR_EXPOSE_PROMPTS),
|
|
2044
2475
|
debug: parseBool(env.APPLE_CALENDAR_DEBUG),
|
|
2045
2476
|
accounts: parseList(env.APPLE_CALENDAR_ACCOUNTS),
|
|
2046
2477
|
calendars: parseList(env.APPLE_CALENDAR_CALENDARS),
|
|
@@ -2050,6 +2481,9 @@ const loadConfig = (env = process.env) => parseConfig(ConfigSchema, {
|
|
|
2050
2481
|
defaultRangeDays: parseIntOpt(env.APPLE_CALENDAR_DEFAULT_RANGE_DAYS),
|
|
2051
2482
|
maxRangeDays: parseIntOpt(env.APPLE_CALENDAR_MAX_RANGE_DAYS),
|
|
2052
2483
|
defaultEventDurationMinutes: parseIntOpt(env.APPLE_CALENDAR_DEFAULT_EVENT_DURATION_MINUTES),
|
|
2484
|
+
workdayStart: trimmed(env.APPLE_CALENDAR_WORKDAY_START),
|
|
2485
|
+
workdayEnd: trimmed(env.APPLE_CALENDAR_WORKDAY_END),
|
|
2486
|
+
workdays: parseWorkdays(parseList(env.APPLE_CALENDAR_WORKDAYS)),
|
|
2053
2487
|
includeDeclined: parseBool(env.APPLE_CALENDAR_INCLUDE_DECLINED),
|
|
2054
2488
|
includeCancelled: parseBool(env.APPLE_CALENDAR_INCLUDE_CANCELLED),
|
|
2055
2489
|
osascriptPath: trimmed(env.APPLE_CALENDAR_OSASCRIPT_PATH),
|
|
@@ -2058,6 +2492,126 @@ const loadConfig = (env = process.env) => parseConfig(ConfigSchema, {
|
|
|
2058
2492
|
timeZone: trimmed(env.APPLE_CALENDAR_TIMEZONE)
|
|
2059
2493
|
});
|
|
2060
2494
|
//#endregion
|
|
2495
|
+
//#region src/guide.ts
|
|
2496
|
+
/**
|
|
2497
|
+
* The Calendar operating manual, served as `cupertino://calendar/guide` and
|
|
2498
|
+
* embedded ahead of every Calendar prompt. Static by design — see the note in
|
|
2499
|
+
* the Mail guide.
|
|
2500
|
+
*/
|
|
2501
|
+
const CALENDAR_GUIDE = `# Apple Calendar — how to drive this server
|
|
2502
|
+
|
|
2503
|
+
## Refs are opaque, and they name an occurrence
|
|
2504
|
+
|
|
2505
|
+
Events come back with a \`ref\` like \`c1:<calendar>/<occurrence>/<uid>\`. Pass it
|
|
2506
|
+
back verbatim. The part that matters: a ref names **one occurrence**, not the
|
|
2507
|
+
series. Reading it reports that occurrence's times; acting on it acts on that
|
|
2508
|
+
occurrence. Do not assume changing one changes the repeat.
|
|
2509
|
+
|
|
2510
|
+
## Which tool, under which constraint
|
|
2511
|
+
|
|
2512
|
+
- **"When am I free", "find me 45 minutes", "what does Thursday look like"** is
|
|
2513
|
+
\`apple_calendar_find_availability\`. Reach for it instead of listing events and
|
|
2514
|
+
looking for gaps by hand — that is where an occurrence of a repeating meeting
|
|
2515
|
+
gets missed. It works in working hours (09:00–18:00 on weekdays by default),
|
|
2516
|
+
in this machine's timezone, and never offers time already past.
|
|
2517
|
+
- **What is on** is \`apple_calendar_list_events\` over a bounded range.
|
|
2518
|
+
- **Finding a specific event** is \`apple_calendar_search_events\`.
|
|
2519
|
+
- **Calendar names** must match what Calendar calls them — read the inventory
|
|
2520
|
+
resource first. A mistyped name matches nothing rather than erroring.
|
|
2521
|
+
|
|
2522
|
+
## Empty is booked; degraded is unknown
|
|
2523
|
+
|
|
2524
|
+
On availability these are two different answers and must never be reported the
|
|
2525
|
+
same way. An empty \`slots\` list means **the time is booked**. \`degraded: true\`
|
|
2526
|
+
means the question could not be answered — too many events to read, a store
|
|
2527
|
+
that does not expand repeats, or a window past the expansion range — and the
|
|
2528
|
+
right reply is "I could not check", never "you are free".
|
|
2529
|
+
|
|
2530
|
+
All-day events do not block time by default, because an all-day event is as
|
|
2531
|
+
often a birthday as a holiday. They come back in \`allDayEvents\`: read that
|
|
2532
|
+
before booking over one rather than turning \`allDayBusy\` on blindly. Declined
|
|
2533
|
+
and cancelled events never block.
|
|
2534
|
+
|
|
2535
|
+
## Writes, and the thing this server will not do
|
|
2536
|
+
|
|
2537
|
+
Mutating tools exist only when writes are enabled. If you cannot see
|
|
2538
|
+
\`apple_calendar_create_event\`, writes are off.
|
|
2539
|
+
|
|
2540
|
+
**There is no \`attendees\` parameter, anywhere, on purpose.** Adding an attendee
|
|
2541
|
+
sends mail to a person. This server creates events on calendars; it does not
|
|
2542
|
+
invite anyone. If the user wants someone invited, create the event and tell them
|
|
2543
|
+
to invite from Calendar — do not silently create an event they believe was sent.
|
|
2544
|
+
|
|
2545
|
+
Read-only calendars are refused rather than silently skipped.
|
|
2546
|
+
`;
|
|
2547
|
+
//#endregion
|
|
2548
|
+
//#region src/prompts.ts
|
|
2549
|
+
const CTX = {
|
|
2550
|
+
surface: "calendar",
|
|
2551
|
+
guide: CALENDAR_GUIDE
|
|
2552
|
+
};
|
|
2553
|
+
/**
|
|
2554
|
+
* Calendar's workflow prompts.
|
|
2555
|
+
*
|
|
2556
|
+
* The scheduling one carries the constraint that costs the most when it is
|
|
2557
|
+
* missed: this server can create an event but cannot invite anyone to it, so a
|
|
2558
|
+
* model that treats "schedule a meeting with Ana" as done has told the user
|
|
2559
|
+
* something false about a message that was never sent.
|
|
2560
|
+
*/
|
|
2561
|
+
const registerPrompts = (server, allowWrites) => {
|
|
2562
|
+
registerWorkflowPrompt(server, CTX, {
|
|
2563
|
+
name: "apple_calendar_whats_my_day",
|
|
2564
|
+
title: "What my day looks like",
|
|
2565
|
+
description: "Read back a day or a range as something you can act on — what is fixed, what is movable, and where the real free time is. Read-only.",
|
|
2566
|
+
argsSchema: {
|
|
2567
|
+
when: promptArg("Which day or range, e.g. \"today\", \"tomorrow\", \"next week\". Defaults to today."),
|
|
2568
|
+
calendar: promptArg("Restrict to one calendar, exactly as Calendar spells it.")
|
|
2569
|
+
},
|
|
2570
|
+
build: ({ when, calendar }) => `Tell me what ${when ?? "today"} looks like${calendar ? `, on the ${calendar} calendar` : ""}.
|
|
2571
|
+
|
|
2572
|
+
1. \`apple_calendar_list_events\` over that range${calendar ? " for that calendar" : ""}. Bound the range explicitly — do not list open-endedly and trim afterwards.
|
|
2573
|
+
2. Then \`apple_calendar_find_availability\` over the same range, so you can say
|
|
2574
|
+
where the usable gaps are rather than leaving the user to subtract meetings
|
|
2575
|
+
from a day in their head. Report each gap's real length.
|
|
2576
|
+
3. Call out anything in \`allDayEvents\` — a holiday or someone's day off changes
|
|
2577
|
+
what the rest of the day means, and it does not block time by default.
|
|
2578
|
+
4. Flag the pressure points: back-to-back blocks with no gap, anything starting
|
|
2579
|
+
before or ending after normal hours, and any two events that overlap.
|
|
2580
|
+
|
|
2581
|
+
If a call comes back \`degraded: true\`, say which part of the range you could not
|
|
2582
|
+
read. An empty result means booked; degraded means unknown, and they must not be
|
|
2583
|
+
reported the same way.`
|
|
2584
|
+
});
|
|
2585
|
+
if (!allowWrites) return;
|
|
2586
|
+
registerWorkflowPrompt(server, CTX, {
|
|
2587
|
+
name: "apple_calendar_schedule",
|
|
2588
|
+
title: "Schedule something",
|
|
2589
|
+
description: "Find a time that genuinely works and put an event on the calendar. Requires writes. Note that this cannot invite anyone — it creates the event only.",
|
|
2590
|
+
argsSchema: {
|
|
2591
|
+
what: requiredPromptArg("What the event is, e.g. \"45 minutes with Ana about pricing\"."),
|
|
2592
|
+
when: promptArg("Constraint on when, e.g. \"next week\", \"Thursday afternoon\"."),
|
|
2593
|
+
calendar: promptArg("Which calendar to create it on, exactly as Calendar spells it.")
|
|
2594
|
+
},
|
|
2595
|
+
build: ({ what, when, calendar }) => `Schedule: ${what}${when ? `\n\nWhen: ${when}` : ""}
|
|
2596
|
+
|
|
2597
|
+
1. Work out the duration from the description; ask if it is genuinely unclear
|
|
2598
|
+
rather than defaulting to an hour.
|
|
2599
|
+
2. \`apple_calendar_find_availability\` over ${when ? `the window implied by "${when}"` : "the coming week"}. Do not list events and pick a gap yourself — a repeating meeting's
|
|
2600
|
+
occurrence is exactly what that misses.
|
|
2601
|
+
3. Offer the user two or three real options before creating anything, with the
|
|
2602
|
+
full length of each gap so they can see whether it is tight. If availability
|
|
2603
|
+
comes back \`degraded: true\`, stop and say you could not check — do not offer
|
|
2604
|
+
a slot you could not verify.
|
|
2605
|
+
4. On their choice, \`apple_calendar_create_event\`${calendar ? ` on the ${calendar} calendar` : " on a calendar from the inventory, not an invented name"}.
|
|
2606
|
+
|
|
2607
|
+
**Then be explicit about what did not happen: nobody was invited.** This server
|
|
2608
|
+
has no \`attendees\` parameter on any tool — adding one would send mail, and it
|
|
2609
|
+
deliberately cannot. If someone else needs to be at this, the event is on the
|
|
2610
|
+
calendar and the invitation still has to be sent from Calendar by hand. Say so
|
|
2611
|
+
plainly; do not let "scheduled with Ana" stand when Ana was never told.`
|
|
2612
|
+
});
|
|
2613
|
+
};
|
|
2614
|
+
//#endregion
|
|
2061
2615
|
//#region src/tools/util.ts
|
|
2062
2616
|
const eventRefArg = z.string().min(1).describe("An opaque event ref from a list or search result (looks like \"c1:<calendar>/<occurrence>/<uid>\"). Do not construct one by hand.");
|
|
2063
2617
|
const calendarArg = z.string().optional().describe("A calendar name or uid, e.g. \"Work\". Use apple_calendar_list_calendars to see what exists.");
|
|
@@ -2073,6 +2627,107 @@ const toArg = z.string().optional().describe("End of the window. A bare day runs
|
|
|
2073
2627
|
const includeDeclinedArg = z.boolean().optional().describe("Include events you have declined. Off by default; they are still on the calendar.");
|
|
2074
2628
|
const includeCancelledArg = z.boolean().optional().describe("Include events an organiser cancelled but that are still in the store. Off by default.");
|
|
2075
2629
|
//#endregion
|
|
2630
|
+
//#region src/tools/diagnostics.ts
|
|
2631
|
+
/**
|
|
2632
|
+
* Build the report.
|
|
2633
|
+
*
|
|
2634
|
+
* Split out of the tool registration so the `cupertino://calendar/diagnostics`
|
|
2635
|
+
* resource can serve the same bytes. Two renderings of one probe: duplicated,
|
|
2636
|
+
* the resource and the tool would drift, and the disagreement would surface as
|
|
2637
|
+
* "the diagnostics lied" — the one thing this file must never do.
|
|
2638
|
+
*/
|
|
2639
|
+
const buildDiagnostics = async (client, ctx) => {
|
|
2640
|
+
const lanes = client.lanes();
|
|
2641
|
+
const located = client.locate();
|
|
2642
|
+
const store = client.index();
|
|
2643
|
+
/**
|
|
2644
|
+
* Three-valued, and the middle value is the useful one.
|
|
2645
|
+
*
|
|
2646
|
+
* Calendar's store has a constant filename, so `stat` answers "is it
|
|
2647
|
+
* there" even when `access(2)` is denied. That lets this separate "the
|
|
2648
|
+
* grant is missing" from "Calendar was never set up on this account",
|
|
2649
|
+
* which Reminders cannot do — its filename carries a generated UUID, so
|
|
2650
|
+
* without the grant there is no path to test at all.
|
|
2651
|
+
*/
|
|
2652
|
+
const fullDiskAccess = located.readable ? "granted" : located.exists ? "denied (found the store, cannot read it)" : "unknown (no store file at the expected path)";
|
|
2653
|
+
return {
|
|
2654
|
+
server: { lanes },
|
|
2655
|
+
permissions: {
|
|
2656
|
+
fullDiskAccess,
|
|
2657
|
+
automation: ctx.allowWrites ? "needed for writes only. Reads never send an Apple Event, so a read-only setup prompts for nothing." : "not needed — writes are off, and reads never send an Apple Event. Turning writes on will prompt for Automation the first time one runs.",
|
|
2658
|
+
...located.readable ? {} : { howToGrant: [
|
|
2659
|
+
"System Settings > Privacy & Security > Full Disk Access",
|
|
2660
|
+
"Add the app that launches this server (Terminal, iTerm, VS Code, Claude...), then restart it.",
|
|
2661
|
+
"Granting it to Calendar.app does nothing — the reader needs the permission, not Calendar."
|
|
2662
|
+
] }
|
|
2663
|
+
},
|
|
2664
|
+
store: {
|
|
2665
|
+
containerPath: located.containerPath,
|
|
2666
|
+
path: located.storePath,
|
|
2667
|
+
containerListable: located.containerListable,
|
|
2668
|
+
extrasPresent: located.extrasPresent,
|
|
2669
|
+
candidates: located.candidates.length,
|
|
2670
|
+
exists: located.exists,
|
|
2671
|
+
readable: located.readable,
|
|
2672
|
+
sizeBytes: located.size,
|
|
2673
|
+
walPresent: located.walPresent,
|
|
2674
|
+
walSizeBytes: located.walSizeBytes,
|
|
2675
|
+
fingerprint: lanes.storeFingerprint,
|
|
2676
|
+
reason: located.reason
|
|
2677
|
+
},
|
|
2678
|
+
capabilities: store ? {
|
|
2679
|
+
hasOccurrenceCache: store.caps.hasOccurrenceCache,
|
|
2680
|
+
hasOccurrenceDays: store.caps.hasOccurrenceDays,
|
|
2681
|
+
hasRecurrence: store.caps.hasRecurrence,
|
|
2682
|
+
hasExceptionDates: store.caps.hasExceptionDates,
|
|
2683
|
+
hasLocation: store.caps.hasLocation,
|
|
2684
|
+
hasAttachments: store.caps.hasAttachments,
|
|
2685
|
+
hasParticipants: store.caps.hasParticipants,
|
|
2686
|
+
hasAlarms: store.caps.hasAlarms,
|
|
2687
|
+
itemColumns: store.caps.itemColumns.size,
|
|
2688
|
+
calendarColumns: store.caps.calendarColumns.size
|
|
2689
|
+
} : null,
|
|
2690
|
+
settings: {
|
|
2691
|
+
allowWrites: ctx.allowWrites,
|
|
2692
|
+
exposePrompts: client.config.exposePrompts,
|
|
2693
|
+
accountAllowlist: client.config.accounts,
|
|
2694
|
+
calendarAllowlist: client.config.calendars,
|
|
2695
|
+
defaultCalendar: client.config.defaultCalendar ?? null,
|
|
2696
|
+
defaultRangeDays: client.config.defaultRangeDays,
|
|
2697
|
+
maxRangeDays: client.config.maxRangeDays,
|
|
2698
|
+
includeDeclined: client.config.includeDeclined,
|
|
2699
|
+
includeCancelled: client.config.includeCancelled,
|
|
2700
|
+
indexMode: client.config.indexMode,
|
|
2701
|
+
maxResults: client.config.maxResults,
|
|
2702
|
+
timeZone: client.config.timeZone ?? null
|
|
2703
|
+
},
|
|
2704
|
+
caveats: [
|
|
2705
|
+
"Writes go through Apple Events and are real side effects: on a shared, CalDAV or Exchange calendar a created event syncs within seconds and other people see it. There is no draft state and no undo.",
|
|
2706
|
+
"Single occurrences of a repeating event can be neither edited nor deleted through this server, and that is a limit of Calendar's scripting interface rather than a choice: there is no way to detach one occurrence, and the excluded-dates property reads back a 1903 sentinel and throws on assignment (measured, macOS 26.6). Both are refused rather than applied to the whole series. Calendar.app can still do them.",
|
|
2707
|
+
"Attendees cannot be set, because that emails a person. There is no \"all future occurrences\" either, which would need two writes with no transaction between them.",
|
|
2708
|
+
"Calendar reads through the file lane only. Apple Events was measured at 3.4s for a single 90-day range query, with the cost falling per round trip rather than per event, so there is no slower-but-working fallback to offer when Full Disk Access is missing. Reads need the grant.",
|
|
2709
|
+
"Repeating events are expanded from OccurrenceCache, which was measured to reach about two years either side of today on the probed store. That is an edge: a range running past it returns fewer repeating events than exist, so every list_events result carries `coverage`, and sets `truncated` rather than returning a short list silently.",
|
|
2710
|
+
"The `status` and `invitationStatus` numbers are EventKit's documented constants, and this store is assumed to mirror them — likely, but not measured. That is why cancelled and declined events are only hidden when you ask, and why the raw value is on every result.",
|
|
2711
|
+
"Writes will go through Apple Events, targeting com.apple.iCal. Note the bundle id is iCal, not Calendar, which is the one place this surface differs from the other three."
|
|
2712
|
+
]
|
|
2713
|
+
};
|
|
2714
|
+
};
|
|
2715
|
+
/**
|
|
2716
|
+
* One tool that answers "why is this not working".
|
|
2717
|
+
*
|
|
2718
|
+
* Unlike the Reminders equivalent this probes no Apple Event before reading:
|
|
2719
|
+
* Calendar has no Apple Events read lane to probe (`docs/distribution.md`), so
|
|
2720
|
+
* firing one here would trigger the Automation prompt for a capability the
|
|
2721
|
+
* server does not yet have.
|
|
2722
|
+
*/
|
|
2723
|
+
const registerDiagnosticsTools = (server, client, ctx) => {
|
|
2724
|
+
server.registerTool("apple_calendar_diagnostics", {
|
|
2725
|
+
description: "Report which lanes are live, which macOS permissions are granted, and what each missing one is blocking. Start here when a tool fails or reports nothing — it names the exact System Settings pane to open.",
|
|
2726
|
+
inputSchema: {},
|
|
2727
|
+
annotations: { readOnlyHint: true }
|
|
2728
|
+
}, async () => wrap(() => buildDiagnostics(client, ctx)).then((r) => r ?? ok({})));
|
|
2729
|
+
};
|
|
2730
|
+
//#endregion
|
|
2076
2731
|
//#region src/tools/actions.ts
|
|
2077
2732
|
/**
|
|
2078
2733
|
* The mutating tools.
|
|
@@ -2128,6 +2783,49 @@ const registerActionTools = (server, client) => {
|
|
|
2128
2783
|
}, async ({ refs }) => wrap(() => client.deleteEvents(refs)));
|
|
2129
2784
|
};
|
|
2130
2785
|
//#endregion
|
|
2786
|
+
//#region src/tools/availability.ts
|
|
2787
|
+
/**
|
|
2788
|
+
* The one read on this surface that answers a question instead of returning
|
|
2789
|
+
* rows, and the only one whose result is the COMPLEMENT of what it read.
|
|
2790
|
+
*
|
|
2791
|
+
* It lives apart from `events.ts` for that reason. The three tools there share
|
|
2792
|
+
* a failure mode — return fewer events than exist, flag it, and the caller is
|
|
2793
|
+
* merely under-informed. This one turns the same shortfall into a positive
|
|
2794
|
+
* claim that a time is free, so its refusal paths are load-bearing rather than
|
|
2795
|
+
* defensive, and keeping them next to a `list_events` that legitimately pages
|
|
2796
|
+
* would invite someone to make the two consistent.
|
|
2797
|
+
*/
|
|
2798
|
+
const registerAvailabilityTools = (server, client) => {
|
|
2799
|
+
server.registerTool("apple_calendar_find_availability", {
|
|
2800
|
+
description: "Find the times nothing is on the calendar, long enough to hold a meeting of a given length. This is the tool for \"when am I free\", \"find me 45 minutes next week\" or \"what does Thursday look like\" — reach for it instead of listing events and looking for gaps yourself, which is where an occurrence of a repeating meeting gets missed. Working hours default to 09:00-18:00 on weekdays, in THIS MACHINE'S timezone, and only time inside them is offered. Slots start on a 15-minute boundary and each one reports how long the whole gap is, which is usually longer than you asked for. Time already past today is never offered. It refuses rather than guesses: if the window holds too many events to read, if this store does not expand repeating events, or if the window runs past the range the expansion covers, you get `degraded: true` and a reason instead of free time that is not free. An empty `slots` list means BOOKED; `degraded` means UNKNOWN. Declined and cancelled events do not block time; all-day events do not either, but they are listed in `allDayEvents` so you can see the holiday you are about to book over.",
|
|
2801
|
+
inputSchema: {
|
|
2802
|
+
durationMinutes: z.number().int().min(1).max(1440).describe("How long the meeting needs to be. A gap shorter than this is not returned."),
|
|
2803
|
+
from: z.string().optional().describe("Start of the window. ISO-8601 \"2026-08-21\" or \"2026-08-21T09:00\", or \"today\", \"tomorrow\", \"next monday\", \"+2d\". Defaults to today. A start already in the past is pulled forward to now."),
|
|
2804
|
+
to: toArg,
|
|
2805
|
+
calendar: calendarArg,
|
|
2806
|
+
dayStart: z.string().optional().describe("Earliest time of day to offer, local wall clock, e.g. \"09:00\". Defaults to APPLE_CALENDAR_WORKDAY_START (09:00)."),
|
|
2807
|
+
dayEnd: z.string().optional().describe("Latest time of day to offer, e.g. \"18:00\" — or \"24:00\" for the end of the day. Defaults to APPLE_CALENDAR_WORKDAY_END (18:00)."),
|
|
2808
|
+
weekdays: z.array(z.enum(WEEKDAY_KEYS)).min(1).optional().describe("Which days to offer, e.g. [\"mon\",\"tue\",\"wed\",\"thu\",\"fri\"]. Defaults to the configured working week. Pass all seven to include the weekend."),
|
|
2809
|
+
granularityMinutes: z.number().int().min(1).max(60).refine((n) => 60 % n === 0, { message: "must divide 60 evenly — 5, 10, 15, 20, 30 or 60" }).optional().describe("Slot starts are rounded up to this boundary. Default 15."),
|
|
2810
|
+
allDayBusy: z.boolean().optional().describe("Let an all-day event block its whole day. Off by default, because an all-day event is as often a birthday as a holiday, and blocking on \"Ana's birthday\" would hide a working day. They are reported in `allDayEvents` either way — read that before booking rather than turning this on blindly."),
|
|
2811
|
+
respectFreeMarking: z.boolean().optional().describe("Skip events the calendar marks as \"free\" rather than \"busy\". Off by default: the stored constant is inferred from EventKit and has not been measured against a real store, and reading it wrongly would offer a slot that is booked. Every busy block reports its raw value so you can check before turning this on."),
|
|
2812
|
+
includeDeclined: includeDeclinedArg,
|
|
2813
|
+
includeCancelled: includeCancelledArg,
|
|
2814
|
+
limit: limitArg
|
|
2815
|
+
},
|
|
2816
|
+
annotations: { readOnlyHint: true }
|
|
2817
|
+
}, async (args) => wrap(async () => client.findAvailability({
|
|
2818
|
+
...args,
|
|
2819
|
+
dayStart: args.dayStart ?? client.config.workdayStart,
|
|
2820
|
+
dayEnd: args.dayEnd ?? client.config.workdayEnd,
|
|
2821
|
+
weekdays: args.weekdays ?? client.config.workdays,
|
|
2822
|
+
granularityMinutes: args.granularityMinutes ?? 15,
|
|
2823
|
+
allDayBusy: args.allDayBusy ?? false,
|
|
2824
|
+
respectFreeMarking: args.respectFreeMarking ?? false,
|
|
2825
|
+
limit: args.limit ?? Math.min(25, client.config.maxResults)
|
|
2826
|
+
})));
|
|
2827
|
+
};
|
|
2828
|
+
//#endregion
|
|
2131
2829
|
//#region src/tools/calendars.ts
|
|
2132
2830
|
/**
|
|
2133
2831
|
* NOTE ON `async` BELOW: core's `wrap` is typed `() => Promise<T>` because every
|
|
@@ -2148,97 +2846,6 @@ const registerCalendarTools = (server, client) => {
|
|
|
2148
2846
|
}, async () => wrap(async () => client.accounts()));
|
|
2149
2847
|
};
|
|
2150
2848
|
//#endregion
|
|
2151
|
-
//#region src/tools/diagnostics.ts
|
|
2152
|
-
/**
|
|
2153
|
-
* One tool that answers "why is this not working".
|
|
2154
|
-
*
|
|
2155
|
-
* Unlike the Reminders equivalent this probes no Apple Event before reading:
|
|
2156
|
-
* Calendar has no Apple Events read lane to probe (`docs/distribution.md`), so
|
|
2157
|
-
* firing one here would trigger the Automation prompt for a capability the
|
|
2158
|
-
* server does not yet have.
|
|
2159
|
-
*/
|
|
2160
|
-
const registerDiagnosticsTools = (server, client, ctx) => {
|
|
2161
|
-
server.registerTool("apple_calendar_diagnostics", {
|
|
2162
|
-
description: "Report which lanes are live, which macOS permissions are granted, and what each missing one is blocking. Start here when a tool fails or reports nothing — it names the exact System Settings pane to open.",
|
|
2163
|
-
inputSchema: {},
|
|
2164
|
-
annotations: { readOnlyHint: true }
|
|
2165
|
-
}, async () => wrap(async () => {
|
|
2166
|
-
const lanes = client.lanes();
|
|
2167
|
-
const located = client.locate();
|
|
2168
|
-
const store = client.index();
|
|
2169
|
-
/**
|
|
2170
|
-
* Three-valued, and the middle value is the useful one.
|
|
2171
|
-
*
|
|
2172
|
-
* Calendar's store has a constant filename, so `stat` answers "is it
|
|
2173
|
-
* there" even when `access(2)` is denied. That lets this separate "the
|
|
2174
|
-
* grant is missing" from "Calendar was never set up on this account",
|
|
2175
|
-
* which Reminders cannot do — its filename carries a generated UUID, so
|
|
2176
|
-
* without the grant there is no path to test at all.
|
|
2177
|
-
*/
|
|
2178
|
-
const fullDiskAccess = located.readable ? "granted" : located.exists ? "denied (found the store, cannot read it)" : "unknown (no store file at the expected path)";
|
|
2179
|
-
return {
|
|
2180
|
-
server: { lanes },
|
|
2181
|
-
permissions: {
|
|
2182
|
-
fullDiskAccess,
|
|
2183
|
-
automation: ctx.allowWrites ? "needed for writes only. Reads never send an Apple Event, so a read-only setup prompts for nothing." : "not needed — writes are off, and reads never send an Apple Event. Turning writes on will prompt for Automation the first time one runs.",
|
|
2184
|
-
...located.readable ? {} : { howToGrant: [
|
|
2185
|
-
"System Settings > Privacy & Security > Full Disk Access",
|
|
2186
|
-
"Add the app that launches this server (Terminal, iTerm, VS Code, Claude...), then restart it.",
|
|
2187
|
-
"Granting it to Calendar.app does nothing — the reader needs the permission, not Calendar."
|
|
2188
|
-
] }
|
|
2189
|
-
},
|
|
2190
|
-
store: {
|
|
2191
|
-
containerPath: located.containerPath,
|
|
2192
|
-
path: located.storePath,
|
|
2193
|
-
containerListable: located.containerListable,
|
|
2194
|
-
extrasPresent: located.extrasPresent,
|
|
2195
|
-
candidates: located.candidates.length,
|
|
2196
|
-
exists: located.exists,
|
|
2197
|
-
readable: located.readable,
|
|
2198
|
-
sizeBytes: located.size,
|
|
2199
|
-
walPresent: located.walPresent,
|
|
2200
|
-
walSizeBytes: located.walSizeBytes,
|
|
2201
|
-
fingerprint: lanes.storeFingerprint,
|
|
2202
|
-
reason: located.reason
|
|
2203
|
-
},
|
|
2204
|
-
capabilities: store ? {
|
|
2205
|
-
hasOccurrenceCache: store.caps.hasOccurrenceCache,
|
|
2206
|
-
hasOccurrenceDays: store.caps.hasOccurrenceDays,
|
|
2207
|
-
hasRecurrence: store.caps.hasRecurrence,
|
|
2208
|
-
hasExceptionDates: store.caps.hasExceptionDates,
|
|
2209
|
-
hasLocation: store.caps.hasLocation,
|
|
2210
|
-
hasAttachments: store.caps.hasAttachments,
|
|
2211
|
-
hasParticipants: store.caps.hasParticipants,
|
|
2212
|
-
hasAlarms: store.caps.hasAlarms,
|
|
2213
|
-
itemColumns: store.caps.itemColumns.size,
|
|
2214
|
-
calendarColumns: store.caps.calendarColumns.size
|
|
2215
|
-
} : null,
|
|
2216
|
-
settings: {
|
|
2217
|
-
allowWrites: ctx.allowWrites,
|
|
2218
|
-
accountAllowlist: client.config.accounts,
|
|
2219
|
-
calendarAllowlist: client.config.calendars,
|
|
2220
|
-
defaultCalendar: client.config.defaultCalendar ?? null,
|
|
2221
|
-
defaultRangeDays: client.config.defaultRangeDays,
|
|
2222
|
-
maxRangeDays: client.config.maxRangeDays,
|
|
2223
|
-
includeDeclined: client.config.includeDeclined,
|
|
2224
|
-
includeCancelled: client.config.includeCancelled,
|
|
2225
|
-
indexMode: client.config.indexMode,
|
|
2226
|
-
maxResults: client.config.maxResults,
|
|
2227
|
-
timeZone: client.config.timeZone ?? null
|
|
2228
|
-
},
|
|
2229
|
-
caveats: [
|
|
2230
|
-
"Writes go through Apple Events and are real side effects: on a shared, CalDAV or Exchange calendar a created event syncs within seconds and other people see it. There is no draft state and no undo.",
|
|
2231
|
-
"Single occurrences of a repeating event can be neither edited nor deleted through this server, and that is a limit of Calendar's scripting interface rather than a choice: there is no way to detach one occurrence, and the excluded-dates property reads back a 1903 sentinel and throws on assignment (measured, macOS 26.6). Both are refused rather than applied to the whole series. Calendar.app can still do them.",
|
|
2232
|
-
"Attendees cannot be set, because that emails a person. There is no \"all future occurrences\" either, which would need two writes with no transaction between them.",
|
|
2233
|
-
"Calendar reads through the file lane only. Apple Events was measured at 3.4s for a single 90-day range query, with the cost falling per round trip rather than per event, so there is no slower-but-working fallback to offer when Full Disk Access is missing. Reads need the grant.",
|
|
2234
|
-
"Repeating events are expanded from OccurrenceCache, which was measured to reach about two years either side of today on the probed store. That is an edge: a range running past it returns fewer repeating events than exist, so every list_events result carries `coverage`, and sets `truncated` rather than returning a short list silently.",
|
|
2235
|
-
"The `status` and `invitationStatus` numbers are EventKit's documented constants, and this store is assumed to mirror them — likely, but not measured. That is why cancelled and declined events are only hidden when you ask, and why the raw value is on every result.",
|
|
2236
|
-
"Writes will go through Apple Events, targeting com.apple.iCal. Note the bundle id is iCal, not Calendar, which is the one place this surface differs from the other three."
|
|
2237
|
-
]
|
|
2238
|
-
};
|
|
2239
|
-
}).then((r) => r ?? ok({})));
|
|
2240
|
-
};
|
|
2241
|
-
//#endregion
|
|
2242
2849
|
//#region src/tools/events.ts
|
|
2243
2850
|
/** Declared once and spread into both tools, so the two cannot drift apart. */
|
|
2244
2851
|
const filterSchema = {
|
|
@@ -2300,6 +2907,7 @@ const registerTools = (server, client, ctx) => {
|
|
|
2300
2907
|
registerDiagnosticsTools(server, client, ctx);
|
|
2301
2908
|
registerCalendarTools(server, client);
|
|
2302
2909
|
registerEventTools(server, client);
|
|
2910
|
+
registerAvailabilityTools(server, client);
|
|
2303
2911
|
if (!ctx.allowWrites) return;
|
|
2304
2912
|
registerActionTools(server, client);
|
|
2305
2913
|
};
|
|
@@ -2326,6 +2934,22 @@ const createServer = (opts) => {
|
|
|
2326
2934
|
...opts.now ? { now: opts.now } : {}
|
|
2327
2935
|
});
|
|
2328
2936
|
registerTools(server, client, { allowWrites: config.allowWrites });
|
|
2937
|
+
if (config.exposePrompts) {
|
|
2938
|
+
registerPrompts(server, config.allowWrites);
|
|
2939
|
+
registerSurfaceResources(server, {
|
|
2940
|
+
surface: "calendar",
|
|
2941
|
+
displayName: "Calendar",
|
|
2942
|
+
guide: CALENDAR_GUIDE,
|
|
2943
|
+
diagnostics: () => buildDiagnostics(client, { allowWrites: config.allowWrites }),
|
|
2944
|
+
inventory: {
|
|
2945
|
+
describes: "accounts and calendars",
|
|
2946
|
+
read: async () => ({
|
|
2947
|
+
accounts: await client.accounts(),
|
|
2948
|
+
calendars: await client.calendars()
|
|
2949
|
+
})
|
|
2950
|
+
}
|
|
2951
|
+
});
|
|
2952
|
+
}
|
|
2329
2953
|
return {
|
|
2330
2954
|
server,
|
|
2331
2955
|
client
|
|
@@ -2334,4 +2958,4 @@ const createServer = (opts) => {
|
|
|
2334
2958
|
//#endregion
|
|
2335
2959
|
export { BUILD_INFO as A, CALENDAR_SURFACE as C, CalendarNotWritableError as D, CalendarNotRunningError as E, EventNotFoundError as O, CALENDAR_BUNDLE_ID as S, CalendarNotFoundError as T, STORE_FILENAME as _, loadConfig as a, locateStore as b, introspect as c, decodeRef as d, encodeRef as f, GROUP_CONTAINER as g, EXTRAS_FILENAME as h, registerTools as i, InvalidDateError as k, openStore as l, uuidOf as m, SERVER_VERSION as n, AppleCalendarClient as o, seriesRefOf as p, createServer as r, CalendarStore as s, SERVER_NAME as t, REF_VERSION as u, defaultContainerPath as v, CalendarBusyError as w, AppleCalendarError as x, defaultStorePath as y };
|
|
2336
2960
|
|
|
2337
|
-
//# sourceMappingURL=server-
|
|
2961
|
+
//# sourceMappingURL=server-DoSfe1y7.js.map
|