causly-server 0.0.0-stage → 1.5.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.
Files changed (91) hide show
  1. package/.env.example +120 -0
  2. package/CHANGELOG.md +126 -0
  3. package/LICENSE +21 -0
  4. package/README.md +377 -3
  5. package/bin/causly.js +192 -0
  6. package/index.js +229 -0
  7. package/package.json +59 -6
  8. package/packs/calendar.js +141 -0
  9. package/packs/command.js +22 -0
  10. package/packs/database.js +95 -0
  11. package/packs/directory.js +63 -0
  12. package/packs/discord.js +63 -0
  13. package/packs/docker.js +202 -0
  14. package/packs/files.js +106 -0
  15. package/packs/git.js +211 -0
  16. package/packs/github.js +321 -0
  17. package/packs/gmail.js +98 -0
  18. package/packs/leads.js +46 -0
  19. package/packs/linear.js +104 -0
  20. package/packs/loader.js +247 -0
  21. package/packs/manifest.json +1152 -0
  22. package/packs/meetings.js +141 -0
  23. package/packs/notion.js +185 -0
  24. package/packs/pipeline.js +151 -0
  25. package/packs/project.js +70 -0
  26. package/packs/registry.js +149 -0
  27. package/packs/reminders.js +57 -0
  28. package/packs/research.js +34 -0
  29. package/packs/secrets.js +77 -0
  30. package/packs/sentry.js +97 -0
  31. package/packs/seo.js +70 -0
  32. package/packs/slack.js +95 -0
  33. package/packs/stripe.js +127 -0
  34. package/packs/supabase.js +73 -0
  35. package/packs/telegram.js +70 -0
  36. package/packs/terraform.js +247 -0
  37. package/packs/vercel.js +114 -0
  38. package/packs/workflow.js +84 -0
  39. package/prompts/cofounder.js +66 -0
  40. package/prompts/playbooks.js +75 -0
  41. package/tools/backupOps.js +87 -0
  42. package/tools/boundaryAttest.js +184 -0
  43. package/tools/calendarOps.js +563 -0
  44. package/tools/cliOps.js +233 -0
  45. package/tools/commandOps.js +198 -0
  46. package/tools/dbOps.js +117 -0
  47. package/tools/directoryOps.js +96 -0
  48. package/tools/discordOps.js +133 -0
  49. package/tools/dockerOps.js +259 -0
  50. package/tools/envFile.js +89 -0
  51. package/tools/envLoader.js +85 -0
  52. package/tools/fileOps.js +125 -0
  53. package/tools/gitOps.js +170 -0
  54. package/tools/githubOps.js +357 -0
  55. package/tools/gmailOps.js +221 -0
  56. package/tools/googleAuth.js +51 -0
  57. package/tools/googleLogin.js +230 -0
  58. package/tools/leadOps.js +447 -0
  59. package/tools/linearOps.js +304 -0
  60. package/tools/logger.js +47 -0
  61. package/tools/meetingOps.js +697 -0
  62. package/tools/notionOps.js +215 -0
  63. package/tools/paths.js +36 -0
  64. package/tools/pipelineOps.js +511 -0
  65. package/tools/projectOps.js +192 -0
  66. package/tools/reminderAlerts.js +48 -0
  67. package/tools/reminderOps.js +129 -0
  68. package/tools/scheduler.js +46 -0
  69. package/tools/secretsOps.js +133 -0
  70. package/tools/security.js +488 -0
  71. package/tools/sentryOps.js +130 -0
  72. package/tools/seoOps.js +561 -0
  73. package/tools/services.js +295 -0
  74. package/tools/setupOps.js +245 -0
  75. package/tools/slackOps.js +151 -0
  76. package/tools/statusOps.js +104 -0
  77. package/tools/store.js +111 -0
  78. package/tools/stripeOps.js +299 -0
  79. package/tools/supabaseOps.js +94 -0
  80. package/tools/telegramOps.js +153 -0
  81. package/tools/terminal.js +124 -0
  82. package/tools/terraformOps.js +171 -0
  83. package/tools/toolWrapper.js +60 -0
  84. package/tools/toolsetOps.js +123 -0
  85. package/tools/transcribeOps.js +221 -0
  86. package/tools/updateCheck.js +58 -0
  87. package/tools/vercelOps.js +198 -0
  88. package/tools/verifyBoundaryAttest.js +25 -0
  89. package/tools/webOps.js +474 -0
  90. package/tools/workflowOps.js +371 -0
  91. package/tools/workspace.js +85 -0
@@ -0,0 +1,563 @@
1
+ // Google Calendar tools. No new dependencies: Calendar REST API v3 over fetch.
2
+ //
3
+ // calendar_list_calendars calendars this account can see
4
+ // calendar_list_events events in a range (today, tomorrow, next 7 days, or exact dates)
5
+ // calendar_get_event one event in full
6
+ // calendar_free_busy busy blocks for one or more calendars
7
+ // calendar_find_slots free meeting slots inside working hours
8
+ // calendar_create_event create an event (inviting people needs confirm: true)
9
+ // calendar_update_event change an event (events with guests need confirm: true)
10
+ // calendar_delete_event delete an event (DESTRUCTIVE)
11
+ //
12
+ // Credentials: CALENDAR_REFRESH_TOKEN plus the OAuth client from CALENDAR_CLIENT_ID /
13
+ // CALENDAR_CLIENT_SECRET (or the GMAIL_CLIENT_ID / GMAIL_CLIENT_SECRET of the same client).
14
+ // The refresh token needs the calendar.events and calendar.readonly scopes.
15
+ //
16
+ // Timezone: pass `timezone` (IANA name, e.g. Asia/Kolkata), or set CAUSLY_TIMEZONE,
17
+ // otherwise the machine's timezone is used. Times without an offset are read in that zone.
18
+ import crypto from "crypto";
19
+ import { googleCredentials, getAccessToken } from "./googleAuth.js";
20
+ import { UNTRUSTED_NOTICE } from "./webOps.js";
21
+
22
+ const API = "https://www.googleapis.com/calendar/v3";
23
+ const MIN = 60_000;
24
+ const DAY = 86_400_000;
25
+ const clamp = (n, lo, hi) => Math.min(Math.max(n, lo), hi);
26
+ const num = (v, fallback) => (Number.isFinite(Number(v)) && v !== undefined && v !== null ? Number(v) : fallback);
27
+ const EMAIL = /^[^\s@,;<>]+@[^\s@,;<>]+\.[^\s@,;<>]+$/;
28
+ const enc = encodeURIComponent;
29
+ const pad = (n) => String(n).padStart(2, "0");
30
+
31
+ // ---------------------------------------------------------------- time helpers
32
+
33
+ export function defaultTimezone(env = process.env) {
34
+ return env.CAUSLY_TIMEZONE || Intl.DateTimeFormat().resolvedOptions().timeZone || "UTC";
35
+ }
36
+
37
+ export function checkTimezone(tz) {
38
+ try {
39
+ new Intl.DateTimeFormat("en-US", { timeZone: tz });
40
+ return tz;
41
+ } catch {
42
+ throw new Error(`Unknown timezone "${tz}". Use an IANA name such as Asia/Kolkata.`);
43
+ }
44
+ }
45
+
46
+ /** Offset of a timezone from UTC at a moment, in ms (IST is +19,800,000). */
47
+ export function tzOffsetMs(tz, ms) {
48
+ const name =
49
+ new Intl.DateTimeFormat("en-US", { timeZone: tz, timeZoneName: "longOffset" })
50
+ .formatToParts(new Date(ms))
51
+ .find((p) => p.type === "timeZoneName")?.value ?? "GMT";
52
+ const m = /([+\-\u2212])(\d{1,2})(?::(\d{2}))?$/.exec(name);
53
+ if (!m) return 0;
54
+ return (m[1] === "+" ? 1 : -1) * (Number(m[2]) * 60 + Number(m[3] || 0)) * MIN;
55
+ }
56
+
57
+ /** Wall-clock parts of a moment in a timezone. */
58
+ export function zonedParts(ms, tz) {
59
+ const p = Object.fromEntries(
60
+ new Intl.DateTimeFormat("en-CA", {
61
+ timeZone: tz,
62
+ year: "numeric",
63
+ month: "2-digit",
64
+ day: "2-digit",
65
+ hour: "2-digit",
66
+ minute: "2-digit",
67
+ hourCycle: "h23",
68
+ })
69
+ .formatToParts(new Date(ms))
70
+ .map((x) => [x.type, x.value])
71
+ );
72
+ return { y: Number(p.year), m: Number(p.month), d: Number(p.day), hh: Number(p.hour), mm: Number(p.minute) };
73
+ }
74
+
75
+ /** The moment a wall-clock time happens in a timezone. */
76
+ export function zonedToInstant({ y, m, d, hh = 0, mm = 0 }, tz) {
77
+ const guess = Date.UTC(y, m - 1, d, hh, mm);
78
+ const first = guess - tzOffsetMs(tz, guess);
79
+ return guess - tzOffsetMs(tz, first);
80
+ }
81
+
82
+ function ymdPlus(ms, tz, add) {
83
+ const { y, m, d } = zonedParts(ms, tz);
84
+ const t = new Date(Date.UTC(y, m - 1, d + add));
85
+ return { y: t.getUTCFullYear(), m: t.getUTCMonth() + 1, d: t.getUTCDate() };
86
+ }
87
+
88
+ /** Midnight (in tz) of the day containing ms, shifted by `add` days. */
89
+ export const dayStart = (ms, tz, add = 0) => zonedToInstant(ymdPlus(ms, tz, add), tz);
90
+
91
+ /** e.g. 2026-10-23T15:00:00+05:30 */
92
+ export function toLocalISO(ms, tz) {
93
+ const { y, m, d, hh, mm } = zonedParts(ms, tz);
94
+ const off = tzOffsetMs(tz, ms);
95
+ const a = Math.abs(off) / MIN;
96
+ return `${y}-${pad(m)}-${pad(d)}T${pad(hh)}:${pad(mm)}:00${off < 0 ? "-" : "+"}${pad(Math.floor(a / 60))}:${pad(a % 60)}`;
97
+ }
98
+
99
+ const TIME_RE = /^(\d{4})-(\d{2})-(\d{2})(?:[T ](\d{2}):(\d{2})(?::(\d{2}))?(?:\.\d+)?\s*(Z|[+-]\d{2}:?\d{2})?)?$/i;
100
+
101
+ /**
102
+ * Accepts 2026-10-23 (whole day), 2026-10-23T15:00 (read in tz) or a full timestamp with
103
+ * Z or an offset. Returns { ms, dateOnly }.
104
+ */
105
+ export function parseTime(value, tz, label = "time") {
106
+ const s = String(value ?? "").trim();
107
+ const r = TIME_RE.exec(s);
108
+ const bad = () => new Error(`${label} must look like 2026-10-23, 2026-10-23T15:00 or 2026-10-23T15:00:00+05:30 (got "${s}").`);
109
+ if (!r) throw bad();
110
+ const [, Y, Mo, D, H, Mi, S, off] = r;
111
+ if (Number(Mo) < 1 || Number(Mo) > 12 || Number(D) < 1 || Number(D) > 31) throw bad();
112
+ const dateOnly = H === undefined;
113
+ if (!dateOnly && (Number(H) > 23 || Number(Mi) > 59)) throw bad();
114
+ let ms;
115
+ if (!dateOnly && off) {
116
+ const zone = off.toUpperCase() === "Z" ? "Z" : off.includes(":") ? off : `${off.slice(0, 3)}:${off.slice(3)}`;
117
+ ms = Date.parse(`${Y}-${Mo}-${D}T${H}:${Mi}:${S || "00"}${zone}`);
118
+ } else {
119
+ ms = zonedToInstant({ y: Number(Y), m: Number(Mo), d: Number(D), hh: dateOnly ? 0 : Number(H), mm: dateOnly ? 0 : Number(Mi) }, tz);
120
+ }
121
+ if (!Number.isFinite(ms)) throw bad();
122
+ return { ms, dateOnly };
123
+ }
124
+
125
+ /** Turns when / from / to into [start, end) in ms. */
126
+ export function resolveRange({ when, from, to }, { tz, now, defaultWhen = "today" }) {
127
+ let start;
128
+ let end;
129
+ if (from || to) {
130
+ start = from ? parseTime(from, tz, "from").ms : now;
131
+ if (to) {
132
+ const t = parseTime(to, tz, "to");
133
+ end = t.dateOnly ? dayStart(t.ms, tz, 1) : t.ms; // a bare date means "through that day"
134
+ } else {
135
+ end = start + 7 * DAY;
136
+ }
137
+ } else {
138
+ const w = when ?? defaultWhen;
139
+ if (w === "today") [start, end] = [dayStart(now, tz, 0), dayStart(now, tz, 1)];
140
+ else if (w === "tomorrow") [start, end] = [dayStart(now, tz, 1), dayStart(now, tz, 2)];
141
+ else if (w === "next_7_days") [start, end] = [now, now + 7 * DAY];
142
+ else throw new Error("when must be today, tomorrow or next_7_days (or pass from / to).");
143
+ }
144
+ if (!(end > start)) throw new Error("The end of the range must be after its start.");
145
+ return { start, end };
146
+ }
147
+
148
+ // ---------------------------------------------------------------- API client
149
+
150
+ function client(deps = {}) {
151
+ const fetchFn = deps.fetch ?? globalThis.fetch;
152
+ const now = deps.now ?? Date.now;
153
+ let token;
154
+ return async function call(path, { method = "GET", query, body } = {}) {
155
+ if (!token) {
156
+ const creds = googleCredentials({
157
+ refreshTokenEnv: "CALENDAR_REFRESH_TOKEN",
158
+ clientIdEnvs: ["CALENDAR_CLIENT_ID", "GMAIL_CLIENT_ID"],
159
+ clientSecretEnvs: ["CALENDAR_CLIENT_SECRET", "GMAIL_CLIENT_SECRET"],
160
+ });
161
+ token = await getAccessToken(creds, { fetch: fetchFn, now });
162
+ }
163
+ const pairs = Object.entries(query ?? {}).filter(([, v]) => v !== undefined && v !== null && v !== "");
164
+ const qs = pairs.length ? `?${new URLSearchParams(pairs.map(([k, v]) => [k, String(v)]))}` : "";
165
+ const res = await fetchFn(`${API}${path}${qs}`, {
166
+ method,
167
+ headers: { authorization: `Bearer ${token}`, ...(body ? { "content-type": "application/json" } : {}) },
168
+ body: body ? JSON.stringify(body) : undefined,
169
+ signal: AbortSignal.timeout(20_000),
170
+ });
171
+ if (res.status === 204) return null;
172
+ const data = await res.json().catch(() => ({}));
173
+ if (!res.ok) {
174
+ const message = data?.error?.message || `HTTP ${res.status}`;
175
+ if (res.status === 401) throw new Error("Google rejected the access token. Check CALENDAR_REFRESH_TOKEN.");
176
+ if (res.status === 403) {
177
+ throw new Error(
178
+ `Google Calendar refused the request: ${message}. The refresh token needs the calendar.events and calendar.readonly scopes, and the Calendar API must be enabled in the Google Cloud project.`
179
+ );
180
+ }
181
+ if (res.status === 404) throw new Error("Not found. Check the event id and calendar id.");
182
+ if (res.status === 410) throw new Error("That event was already deleted.");
183
+ throw new Error(`Google Calendar error: ${message}`);
184
+ }
185
+ return data;
186
+ };
187
+ }
188
+
189
+ // ---------------------------------------------------------------- shaping
190
+
191
+ function simplify(e) {
192
+ const guests = (e.attendees || []).map((a) => ({
193
+ email: a.email,
194
+ ...(a.displayName ? { name: a.displayName } : {}),
195
+ response: a.responseStatus,
196
+ ...(a.self ? { self: true } : {}),
197
+ ...(a.organizer ? { organizer: true } : {}),
198
+ }));
199
+ return {
200
+ id: e.id,
201
+ title: e.summary || "(no title)",
202
+ start: e.start?.dateTime || e.start?.date,
203
+ end: e.end?.dateTime || e.end?.date,
204
+ all_day: Boolean(e.start?.date),
205
+ status: e.status,
206
+ ...(e.location ? { location: e.location } : {}),
207
+ ...(e.description ? { description: String(e.description).slice(0, 1000) } : {}),
208
+ ...(guests.length ? { attendees: guests } : {}),
209
+ ...(e.hangoutLink ? { meet_link: e.hangoutLink } : {}),
210
+ organizer: e.organizer?.email,
211
+ recurring: Boolean(e.recurringEventId),
212
+ link: e.htmlLink,
213
+ };
214
+ }
215
+
216
+ const hasGuests = (e) => (e?.attendees || []).some((a) => !a.self);
217
+
218
+ function cleanEmails(list, label = "attendees") {
219
+ const out = [...new Set((list ?? []).map((x) => String(x).trim().toLowerCase()).filter(Boolean))];
220
+ const bad = out.find((x) => !EMAIL.test(x));
221
+ if (bad) throw new Error(`"${bad}" is not a valid email address (${label}).`);
222
+ if (out.length > 50) throw new Error(`Too many ${label} (50 max).`);
223
+ return out;
224
+ }
225
+
226
+ /** start/end objects for the API. Default length applies when `end` is not given. */
227
+ function buildTimes({ start, end, duration_minutes }, tz, defaultMs, defaultDays = 1) {
228
+ const s = parseTime(start, tz, "start");
229
+ if (s.dateOnly) {
230
+ if (end && !parseTime(end, tz, "end").dateOnly) throw new Error("start is a whole day, so end must be a date too (or leave it out).");
231
+ const days = end ? Math.max(1, Math.round((dayStart(parseTime(end, tz, "end").ms, tz, 1) - s.ms) / DAY)) : defaultDays;
232
+ const endDay = ymdPlus(s.ms, tz, days);
233
+ const startDay = ymdPlus(s.ms, tz, 0);
234
+ const iso = ({ y, m, d }) => `${y}-${pad(m)}-${pad(d)}`;
235
+ return { start: { date: iso(startDay) }, end: { date: iso(endDay) }, startMs: s.ms, endMs: s.ms + days * DAY, allDay: true };
236
+ }
237
+ let endMs;
238
+ if (end) {
239
+ const e = parseTime(end, tz, "end");
240
+ if (e.dateOnly) throw new Error("start has a time, so end must have a time too.");
241
+ endMs = e.ms;
242
+ } else {
243
+ endMs = s.ms + clamp(num(duration_minutes, defaultMs / MIN), 5, 24 * 60) * MIN;
244
+ }
245
+ if (endMs <= s.ms) throw new Error("The event must end after it starts.");
246
+ return {
247
+ start: { dateTime: new Date(s.ms).toISOString(), timeZone: tz },
248
+ end: { dateTime: new Date(endMs).toISOString(), timeZone: tz },
249
+ startMs: s.ms,
250
+ endMs,
251
+ allDay: false,
252
+ };
253
+ }
254
+
255
+ const needsConfirm = (what) =>
256
+ new Error(`${what} This emails real people, so ask the user first, then re-call with confirm: true.`);
257
+
258
+ // ---------------------------------------------------------------- calendar_list_calendars
259
+
260
+ export async function listCalendars(_input = {}, deps = {}) {
261
+ const call = client(deps);
262
+ const data = await call("/users/me/calendarList", { query: { maxResults: 100, minAccessRole: "reader" } });
263
+ const calendars = (data.items || []).map((c) => ({
264
+ id: c.id,
265
+ name: c.summaryOverride || c.summary,
266
+ ...(c.primary ? { primary: true } : {}),
267
+ access: c.accessRole,
268
+ timezone: c.timeZone,
269
+ }));
270
+ return { count: calendars.length, calendars, default_timezone: defaultTimezone() };
271
+ }
272
+
273
+ // ---------------------------------------------------------------- calendar_list_events
274
+
275
+ export async function listEvents({ when, from, to, query, calendar_id = "primary", limit = 25, timezone } = {}, deps = {}) {
276
+ const tz = checkTimezone(timezone || defaultTimezone());
277
+ const now = (deps.now ?? Date.now)();
278
+ const range = resolveRange({ when, from, to }, { tz, now, defaultWhen: "today" });
279
+ const call = client(deps);
280
+ const data = await call(`/calendars/${enc(calendar_id)}/events`, {
281
+ query: {
282
+ timeMin: new Date(range.start).toISOString(),
283
+ timeMax: new Date(range.end).toISOString(),
284
+ singleEvents: "true",
285
+ orderBy: "startTime",
286
+ maxResults: clamp(Math.trunc(num(limit, 25)), 1, 100),
287
+ q: query,
288
+ timeZone: tz,
289
+ },
290
+ });
291
+ const events = (data.items || []).filter((e) => e.status !== "cancelled").map(simplify);
292
+ return {
293
+ calendar: calendar_id,
294
+ timezone: tz,
295
+ range: { start: toLocalISO(range.start, tz), end: toLocalISO(range.end, tz) },
296
+ count: events.length,
297
+ events,
298
+ notice: UNTRUSTED_NOTICE,
299
+ };
300
+ }
301
+
302
+ // ---------------------------------------------------------------- calendar_get_event
303
+
304
+ export async function getEvent({ event_id, calendar_id = "primary" } = {}, deps = {}) {
305
+ if (!event_id) throw new Error("event_id is required.");
306
+ const call = client(deps);
307
+ const e = await call(`/calendars/${enc(calendar_id)}/events/${enc(event_id)}`);
308
+ return { event: simplify(e), notice: UNTRUSTED_NOTICE };
309
+ }
310
+
311
+ // ---------------------------------------------------------------- free / busy
312
+
313
+ async function fetchBusy(call, { start, end, tz, ids }) {
314
+ const data = await call("/freeBusy", {
315
+ method: "POST",
316
+ body: { timeMin: new Date(start).toISOString(), timeMax: new Date(end).toISOString(), timeZone: tz, items: ids.map((id) => ({ id })) },
317
+ });
318
+ return ids.map((id) => {
319
+ const c = data.calendars?.[id];
320
+ return {
321
+ id,
322
+ busy: (c?.busy || []).map((b) => ({ start: Date.parse(b.start), end: Date.parse(b.end) })),
323
+ errors: (c?.errors || []).map((x) => x.reason),
324
+ };
325
+ });
326
+ }
327
+
328
+ export async function freeBusy({ from, to, when, calendar_ids, attendees, timezone } = {}, deps = {}) {
329
+ const tz = checkTimezone(timezone || defaultTimezone());
330
+ const now = (deps.now ?? Date.now)();
331
+ const range = resolveRange({ when, from, to }, { tz, now, defaultWhen: "next_7_days" });
332
+ if (range.end - range.start > 31 * DAY) throw new Error("Pick a range of 31 days or less.");
333
+ const ids = [...new Set([...(calendar_ids?.length ? calendar_ids : ["primary"]), ...cleanEmails(attendees)])];
334
+ const call = client(deps);
335
+ const result = await fetchBusy(call, { start: range.start, end: range.end, tz, ids });
336
+ return {
337
+ timezone: tz,
338
+ range: { start: toLocalISO(range.start, tz), end: toLocalISO(range.end, tz) },
339
+ calendars: result.map((r) => ({
340
+ id: r.id,
341
+ busy: r.busy.map((b) => ({ start: toLocalISO(b.start, tz), end: toLocalISO(b.end, tz) })),
342
+ ...(r.errors.length ? { not_readable: r.errors, note: "Their busy times could not be read (calendar not shared or not found)." } : {}),
343
+ })),
344
+ };
345
+ }
346
+
347
+ // ---------------------------------------------------------------- calendar_find_slots
348
+
349
+ const HHMM = /^([01]?\d|2[0-3]):([0-5]\d)$/;
350
+
351
+ /** Free windows of at least minMs inside working hours, given busy intervals. */
352
+ export function freeWindows({ busy, start, end, tz, workStart, workEnd, weekdaysOnly = true, minMs }) {
353
+ const merged = busy
354
+ .filter((b) => b.end > b.start)
355
+ .sort((a, b) => a.start - b.start)
356
+ .reduce((acc, b) => {
357
+ const last = acc[acc.length - 1];
358
+ if (last && b.start <= last.end) last.end = Math.max(last.end, b.end);
359
+ else acc.push({ ...b });
360
+ return acc;
361
+ }, []);
362
+ const out = [];
363
+ for (let i = 0; ; i++) {
364
+ const day = ymdPlus(start, tz, i);
365
+ if (zonedToInstant(day, tz) >= end) break;
366
+ const dow = new Date(Date.UTC(day.y, day.m - 1, day.d)).getUTCDay();
367
+ if (weekdaysOnly && (dow === 0 || dow === 6)) continue;
368
+ let cursor = Math.max(zonedToInstant({ ...day, hh: workStart[0], mm: workStart[1] }, tz), start);
369
+ const close = Math.min(zonedToInstant({ ...day, hh: workEnd[0], mm: workEnd[1] }, tz), end);
370
+ for (const b of merged) {
371
+ if (b.end <= cursor) continue;
372
+ if (b.start >= close) break;
373
+ if (b.start - cursor >= minMs) out.push({ start: cursor, end: b.start });
374
+ cursor = Math.max(cursor, b.end);
375
+ }
376
+ if (close - cursor >= minMs) out.push({ start: cursor, end: close });
377
+ }
378
+ return out;
379
+ }
380
+
381
+ export async function findSlots(
382
+ { duration_minutes = 30, when, from, to, timezone, work_start = "09:00", work_end = "18:00", weekdays_only = true, calendar_ids, attendees, limit = 10 } = {},
383
+ deps = {}
384
+ ) {
385
+ const tz = checkTimezone(timezone || defaultTimezone());
386
+ const now = (deps.now ?? Date.now)();
387
+ const range = resolveRange({ when, from, to }, { tz, now, defaultWhen: "next_7_days" });
388
+ if (range.end - range.start > 31 * DAY) throw new Error("Pick a range of 31 days or less.");
389
+ const ws = HHMM.exec(work_start);
390
+ const we = HHMM.exec(work_end);
391
+ if (!ws || !we) throw new Error("work_start and work_end must look like 09:00.");
392
+ const workStart = [Number(ws[1]), Number(ws[2])];
393
+ const workEnd = [Number(we[1]), Number(we[2])];
394
+ if (workEnd[0] * 60 + workEnd[1] <= workStart[0] * 60 + workStart[1]) throw new Error("work_end must be after work_start.");
395
+ const length = clamp(Math.trunc(num(duration_minutes, 30)), 5, 480);
396
+ const ids = [...new Set([...(calendar_ids?.length ? calendar_ids : ["primary"]), ...cleanEmails(attendees)])];
397
+
398
+ const call = client(deps);
399
+ const results = await fetchBusy(call, { start: range.start, end: range.end, tz, ids });
400
+ const unreadable = results.filter((r) => r.errors.length).map((r) => r.id);
401
+
402
+ const quarter = 15 * MIN;
403
+ const start = Math.max(range.start, Math.ceil(now / quarter) * quarter); // never offer the past
404
+ const windows = freeWindows({
405
+ busy: results.flatMap((r) => r.busy),
406
+ start,
407
+ end: range.end,
408
+ tz,
409
+ workStart,
410
+ workEnd,
411
+ weekdaysOnly: weekdays_only !== false,
412
+ minMs: length * MIN,
413
+ });
414
+ const dayName = new Intl.DateTimeFormat("en-US", { timeZone: tz, weekday: "short" });
415
+ const max = clamp(Math.trunc(num(limit, 10)), 1, 50);
416
+ return {
417
+ timezone: tz,
418
+ duration_minutes: length,
419
+ working_hours: `${work_start}-${work_end}${weekdays_only !== false ? " Mon-Fri" : ""}`,
420
+ checked: ids.filter((id) => !unreadable.includes(id)),
421
+ ...(unreadable.length
422
+ ? { warning: `Could not read the calendar of: ${unreadable.join(", ")}. Their busy times were NOT considered.` }
423
+ : {}),
424
+ total_windows: windows.length,
425
+ free_windows: windows.slice(0, max).map((w) => ({
426
+ day: dayName.format(new Date(w.start)),
427
+ start: toLocalISO(w.start, tz),
428
+ end: toLocalISO(w.end, tz),
429
+ minutes: Math.round((w.end - w.start) / MIN),
430
+ })),
431
+ note: "Each window is free time; any start inside it that leaves room for the duration works.",
432
+ };
433
+ }
434
+
435
+ // ---------------------------------------------------------------- calendar_create_event
436
+
437
+ export async function createEvent(
438
+ { title, start, end, duration_minutes, timezone, description, location, attendees, add_meet_link, calendar_id = "primary", confirm } = {},
439
+ deps = {}
440
+ ) {
441
+ if (!title || !String(title).trim()) throw new Error("title is required.");
442
+ if (!start) throw new Error("start is required.");
443
+ const tz = checkTimezone(timezone || defaultTimezone());
444
+ const guests = cleanEmails(attendees);
445
+ if (guests.length && confirm !== true) {
446
+ throw needsConfirm(`Creating this event would invite ${guests.length} ${guests.length === 1 ? "person" : "people"} (${guests.join(", ")}).`);
447
+ }
448
+ const times = buildTimes({ start, end, duration_minutes }, tz, 30 * MIN, 1);
449
+ const call = client(deps);
450
+
451
+ let overlaps = [];
452
+ if (!times.allDay) {
453
+ try {
454
+ const near = await call(`/calendars/${enc(calendar_id)}/events`, {
455
+ query: { timeMin: new Date(times.startMs).toISOString(), timeMax: new Date(times.endMs).toISOString(), singleEvents: "true", maxResults: 10, timeZone: tz },
456
+ });
457
+ overlaps = (near.items || [])
458
+ .filter((e) => e.status !== "cancelled" && e.transparency !== "transparent" && e.start?.dateTime)
459
+ .map((e) => ({ title: e.summary || "(no title)", start: e.start.dateTime, end: e.end?.dateTime }));
460
+ } catch {
461
+ // the overlap check is a courtesy; creating the event must not depend on it
462
+ }
463
+ }
464
+
465
+ const body = {
466
+ summary: String(title).trim(),
467
+ start: times.start,
468
+ end: times.end,
469
+ ...(description ? { description: String(description) } : {}),
470
+ ...(location ? { location: String(location) } : {}),
471
+ ...(guests.length ? { attendees: guests.map((email) => ({ email })) } : {}),
472
+ ...(add_meet_link
473
+ ? { conferenceData: { createRequest: { requestId: crypto.randomUUID(), conferenceSolutionKey: { type: "hangoutsMeet" } } } }
474
+ : {}),
475
+ };
476
+ const data = await call(`/calendars/${enc(calendar_id)}/events`, {
477
+ method: "POST",
478
+ query: { sendUpdates: guests.length ? "all" : "none", ...(add_meet_link ? { conferenceDataVersion: 1 } : {}) },
479
+ body,
480
+ });
481
+ return { created: true, invited: guests.length, event: simplify(data), ...(overlaps.length ? { overlaps_with: overlaps } : {}) };
482
+ }
483
+
484
+ // ---------------------------------------------------------------- calendar_update_event
485
+
486
+ export async function updateEvent(
487
+ { event_id, title, start, end, duration_minutes, timezone, description, location, add_attendees, add_meet_link, calendar_id = "primary", confirm } = {},
488
+ deps = {}
489
+ ) {
490
+ if (!event_id) throw new Error("event_id is required.");
491
+ const tz = checkTimezone(timezone || defaultTimezone());
492
+ const newGuests = cleanEmails(add_attendees, "add_attendees");
493
+ const call = client(deps);
494
+ const path = `/calendars/${enc(calendar_id)}/events/${enc(event_id)}`;
495
+ const existing = await call(path);
496
+
497
+ const notify = hasGuests(existing) || newGuests.length > 0;
498
+ if (notify && confirm !== true) {
499
+ throw needsConfirm(
500
+ newGuests.length
501
+ ? `This change would invite ${newGuests.join(", ")}${hasGuests(existing) ? " and notify the current guests" : ""}.`
502
+ : "This event has guests, so they would get an update email."
503
+ );
504
+ }
505
+
506
+ const body = {};
507
+ if (title !== undefined) body.summary = String(title);
508
+ if (description !== undefined) body.description = String(description);
509
+ if (location !== undefined) body.location = String(location);
510
+
511
+ if (start !== undefined || end !== undefined || duration_minutes !== undefined) {
512
+ const oldStart = existing.start?.dateTime || existing.start?.date;
513
+ const oldEnd = existing.end?.dateTime || existing.end?.date;
514
+ const spanMs = Date.parse(oldEnd) - Date.parse(oldStart);
515
+ const newStart = start ?? oldStart;
516
+ // an explicit end or duration wins; moving only the start keeps the old length
517
+ const times = buildTimes(
518
+ { start: newStart, end, duration_minutes },
519
+ tz,
520
+ Number.isFinite(spanMs) && spanMs > 0 ? spanMs : 30 * MIN,
521
+ Math.max(1, Math.round(spanMs / DAY) || 1)
522
+ );
523
+ body.start = times.start;
524
+ body.end = times.end;
525
+ }
526
+
527
+ if (newGuests.length) {
528
+ const have = new Set((existing.attendees || []).map((a) => String(a.email).toLowerCase()));
529
+ body.attendees = [...(existing.attendees || []), ...newGuests.filter((e) => !have.has(e)).map((email) => ({ email }))];
530
+ }
531
+ if (add_meet_link && !existing.hangoutLink) {
532
+ body.conferenceData = { createRequest: { requestId: crypto.randomUUID(), conferenceSolutionKey: { type: "hangoutsMeet" } } };
533
+ }
534
+ if (!Object.keys(body).length) throw new Error("Nothing to change. Pass title, start, end, description, location, add_attendees or add_meet_link.");
535
+
536
+ const data = await call(path, {
537
+ method: "PATCH",
538
+ query: { sendUpdates: notify ? "all" : "none", ...(body.conferenceData ? { conferenceDataVersion: 1 } : {}) },
539
+ body,
540
+ });
541
+ return {
542
+ updated: true,
543
+ event: simplify(data),
544
+ ...(existing.recurringEventId || existing.recurrence ? { note: "This is part of a recurring series; the change was applied to this event id only as given." } : {}),
545
+ };
546
+ }
547
+
548
+ // ---------------------------------------------------------------- calendar_delete_event
549
+
550
+ export async function deleteEvent({ event_id, calendar_id = "primary" } = {}, deps = {}) {
551
+ if (!event_id) throw new Error("event_id is required.");
552
+ const call = client(deps);
553
+ const path = `/calendars/${enc(calendar_id)}/events/${enc(event_id)}`;
554
+ const existing = await call(path);
555
+ const notify = hasGuests(existing);
556
+ await call(path, { method: "DELETE", query: { sendUpdates: notify ? "all" : "none" } });
557
+ return {
558
+ deleted: true,
559
+ title: existing.summary || "(no title)",
560
+ start: existing.start?.dateTime || existing.start?.date,
561
+ ...(notify ? { guests_notified: true } : {}),
562
+ };
563
+ }