circal-mcp 0.3.0 → 0.4.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.
@@ -2332,10 +2332,9 @@ function occurrencesOn(events, calendars, day) {
2332
2332
  continuesAfter,
2333
2333
  dayIndex: daysBetween(firstDay, dayStart) + 1,
2334
2334
  dayCount: daysBetween(firstDay, lastDay) + 1,
2335
- // A tick is per occurrence, so it is looked up per occurrence. The
2336
- // list is empty on everything that is not a task, which is almost
2337
- // every event in the store.
2338
- done: event.doneKeys.length > 0 && event.doneKeys.includes(instantKey(start)),
2335
+ // An event is never done — the field exists for a task's occurrences,
2336
+ // resolved in `taskOccurrences`.
2337
+ done: false,
2339
2338
  lane: 0
2340
2339
  });
2341
2340
  }
@@ -2347,22 +2346,99 @@ function packLanes(occurrences, pad2 = 6) {
2347
2346
  const laneEnds = [];
2348
2347
  return occurrences.map((occ) => {
2349
2348
  if (occ.event.allDay) return occ;
2350
- if (occ.event.task) return occ;
2349
+ if (occ.task && occ.startMin === occ.endMin) return occ;
2351
2350
  let lane = laneEnds.findIndex((end) => end <= occ.startMin + pad2);
2352
2351
  if (lane === -1) lane = laneEnds.length;
2353
2352
  laneEnds[lane] = occ.endMin;
2354
2353
  return { ...occ, lane };
2355
2354
  });
2356
2355
  }
2357
- function dayAgenda(events, calendars, day) {
2358
- const all = occurrencesOn(events, calendars, day);
2356
+ function dayAgenda(events, calendars, day, tasks = []) {
2357
+ const all = [...occurrencesOn(events, calendars, day), ...taskOccurrences(tasks, calendars, day)];
2358
+ all.sort((a, b) => a.start.getTime() - b.start.getTime() || a.end.getTime() - b.end.getTime());
2359
2359
  const allDay = all.filter((o) => o.event.allDay);
2360
2360
  const timed = packLanes(all.filter((o) => !o.event.allDay));
2361
2361
  return { timed, allDay, all: [...allDay, ...timed] };
2362
2362
  }
2363
- function toggleDoneKey(keys, at) {
2364
- const key = instantKey(at);
2365
- return keys.includes(key) ? keys.filter((k) => k !== key) : [...keys, key];
2363
+ function taskEvent(task, start, end) {
2364
+ return {
2365
+ id: task.id,
2366
+ title: task.title,
2367
+ calendarId: task.calendarId,
2368
+ start,
2369
+ end,
2370
+ allDay: false,
2371
+ tzid: "",
2372
+ location: "",
2373
+ url: "",
2374
+ notes: task.notes,
2375
+ ...NEVER,
2376
+ seriesId: "",
2377
+ seriesKey: "",
2378
+ color: task.color,
2379
+ icon: task.icon,
2380
+ free: false,
2381
+ remindLead: REMIND_INHERIT,
2382
+ organizer: null,
2383
+ attendees: [],
2384
+ attendeeCount: 0,
2385
+ updatedAt: 0
2386
+ };
2387
+ }
2388
+ function taskOccurrences(tasks, calendars, day) {
2389
+ const hidden = new Set(calendars.filter((c) => !c.visible).map((c) => c.id));
2390
+ const dayStart = startOfDay(day);
2391
+ const dayEnd = addDays(dayStart, 1);
2392
+ const out = [];
2393
+ for (const task of tasks) {
2394
+ if (hidden.has(task.calendarId)) continue;
2395
+ let planned = false;
2396
+ for (const plan of task.plans) {
2397
+ if (plan.end <= dayStart.getTime() || plan.start >= dayEnd.getTime()) continue;
2398
+ planned = true;
2399
+ const start = new Date(plan.start);
2400
+ const end = new Date(plan.end);
2401
+ const continuesBefore = start < dayStart;
2402
+ const continuesAfter = end > dayEnd;
2403
+ const firstDay = startOfDay(start);
2404
+ const lastDay = startOfDay(new Date(Math.max(plan.start, plan.end - 1)));
2405
+ out.push({
2406
+ key: `${task.id}@${instantKey(start)}`,
2407
+ event: taskEvent(task, plan.start, plan.end),
2408
+ task,
2409
+ start,
2410
+ end,
2411
+ startMin: continuesBefore ? 0 : minutesOfDay(start),
2412
+ endMin: end >= dayEnd ? MINUTES_PER_DAY : minutesOfDay(end),
2413
+ continuesBefore,
2414
+ continuesAfter,
2415
+ dayIndex: daysBetween(firstDay, dayStart) + 1,
2416
+ dayCount: daysBetween(firstDay, lastDay) + 1,
2417
+ done: task.done,
2418
+ lane: 0
2419
+ });
2420
+ }
2421
+ if (planned || task.dueAllDay) continue;
2422
+ if (task.due < dayStart.getTime() || task.due >= dayEnd.getTime()) continue;
2423
+ const due = new Date(task.due);
2424
+ out.push({
2425
+ key: `${task.id}@${instantKey(due)}`,
2426
+ event: taskEvent(task, task.due, task.due),
2427
+ task,
2428
+ start: due,
2429
+ end: due,
2430
+ startMin: minutesOfDay(due),
2431
+ endMin: minutesOfDay(due),
2432
+ continuesBefore: false,
2433
+ continuesAfter: false,
2434
+ dayIndex: 1,
2435
+ dayCount: 1,
2436
+ done: task.done,
2437
+ lane: 0
2438
+ });
2439
+ }
2440
+ out.sort((a, b) => a.start.getTime() - b.start.getTime() || a.end.getTime() - b.end.getTime());
2441
+ return out;
2366
2442
  }
2367
2443
  var DEFAULT_WAKING = { from: 8 * 60, to: 22 * 60 };
2368
2444
  function freeGaps(occurrences, minMinutes, from, to) {
@@ -2386,13 +2462,15 @@ function dayLoad(occurrences, waking) {
2386
2462
  }
2387
2463
  var FIELD_PENALTY = { location: 30, notes: 45 };
2388
2464
  var penalise = (score, cost) => score === null ? null : score - cost;
2389
- function scoreEvent(event, query) {
2390
- const scores = [
2391
- fuzzyScore(event.title, query),
2392
- penalise(fuzzyScore(event.location, query), FIELD_PENALTY.location),
2393
- penalise(fuzzyScore(event.notes, query), FIELD_PENALTY.notes)
2394
- ].filter((score) => score !== null);
2395
- return scores.length > 0 ? Math.max(...scores) : null;
2465
+ function scoreEventBy(event, query) {
2466
+ let best = null;
2467
+ const consider = (field, score) => {
2468
+ if (score !== null && (best === null || score > best.score)) best = { field, score };
2469
+ };
2470
+ consider("title", fuzzyScore(event.title, query));
2471
+ consider("location", penalise(fuzzyScore(event.location, query), FIELD_PENALTY.location));
2472
+ consider("notes", penalise(fuzzyScore(event.notes, query), FIELD_PENALTY.notes));
2473
+ return best;
2396
2474
  }
2397
2475
  var PALETTE = [
2398
2476
  "#7ce3d8",
@@ -2424,15 +2502,61 @@ function spanLabel(occ) {
2424
2502
  // src/lib/store.ts
2425
2503
  var import_react2 = __toESM(require_react(), 1);
2426
2504
 
2505
+ // src/lib/tasks.ts
2506
+ var ESTIMATE_MAX = 24 * 60;
2507
+ var PLANS_MAX = 32;
2508
+ function readEstimate(value) {
2509
+ if (typeof value !== "number" || !Number.isFinite(value)) return null;
2510
+ return Math.min(ESTIMATE_MAX, Math.max(0, Math.round(value)));
2511
+ }
2512
+ function readPriority(value) {
2513
+ return value === 1 || value === 2 ? value : 0;
2514
+ }
2515
+ function readPlans(value) {
2516
+ if (!Array.isArray(value)) return [];
2517
+ const plans = [];
2518
+ for (const entry of value) {
2519
+ if (typeof entry !== "object" || entry === null) continue;
2520
+ const { start, end } = entry;
2521
+ if (typeof start !== "number" || typeof end !== "number") continue;
2522
+ if (!Number.isFinite(start) || !Number.isFinite(end)) continue;
2523
+ if (end <= start) continue;
2524
+ plans.push({ start, end });
2525
+ if (plans.length === PLANS_MAX) break;
2526
+ }
2527
+ return plans.sort((a, b) => a.start - b.start || a.end - b.end);
2528
+ }
2529
+ var DUE_SOON_DAYS = 7;
2530
+ function taskState(task, now) {
2531
+ if (task.done) return "done";
2532
+ const today = startOfDay(now);
2533
+ if (task.dueAllDay) {
2534
+ const dueDay = startOfDay(new Date(task.due));
2535
+ if (dueDay < today) return "overdue";
2536
+ return dueDay < addDays(today, DUE_SOON_DAYS + 1) ? "due-soon" : "later";
2537
+ }
2538
+ if (task.due <= now.getTime()) return "overdue";
2539
+ return startOfDay(new Date(task.due)) < addDays(today, DUE_SOON_DAYS + 1) ? "due-soon" : "later";
2540
+ }
2541
+ var NOTES_PENALTY = 45;
2542
+ function scoreTaskBy(task, query) {
2543
+ const title = fuzzyScore(task.title, query);
2544
+ const notes = fuzzyScore(task.notes, query);
2545
+ const penalised = notes === null ? null : notes - NOTES_PENALTY;
2546
+ let best = title === null ? null : { field: "title", score: title };
2547
+ if (penalised !== null && (best === null || penalised > best.score)) {
2548
+ best = { field: "notes", score: penalised };
2549
+ }
2550
+ return best;
2551
+ }
2552
+ function compareTasks(a, b) {
2553
+ return a.due - b.due || b.priority - a.priority || a.title.localeCompare(b.title);
2554
+ }
2555
+
2427
2556
  // src/lib/mutate.ts
2428
2557
  function applyUpdate(events, id, patch, now) {
2429
2558
  return events.map((e) => e.id === id ? { ...e, ...patch, updatedAt: now } : e);
2430
2559
  }
2431
- function applyToggleDone(events, id, at, now) {
2432
- return events.map(
2433
- (e) => e.id === id ? { ...e, doneKeys: toggleDoneKey(e.doneKeys, at), updatedAt: now } : e
2434
- );
2435
- }
2436
2560
  function applyRemove(events, id, now) {
2437
2561
  const buried = events.filter((e) => e.id === id || e.seriesId === id);
2438
2562
  return {
@@ -2789,6 +2913,12 @@ function parseQuickAdd(input, reference, now = /* @__PURE__ */ new Date(), fallb
2789
2913
  repeat
2790
2914
  };
2791
2915
  }
2916
+ var EDIT_FILLER = /* @__PURE__ */ new Set([...FILLER, "to", "by"]);
2917
+
2918
+ // src/lib/remind.ts
2919
+ function remindLabel(minutes) {
2920
+ return minutes === 0 ? "As it starts" : `${formatDuration(minutes)} before`;
2921
+ }
2792
2922
 
2793
2923
  // src/lib/theme.ts
2794
2924
  var THEMES = ["midnight", "ember", "paper"];
@@ -3367,7 +3497,10 @@ var DEFAULT_SETTINGS = {
3367
3497
  // `DEFAULT_WAKING`'s own pair: this setting exists to replace the constant,
3368
3498
  // not to disagree with it on day one.
3369
3499
  wakingFrom: DEFAULT_WAKING.from,
3370
- wakingTo: DEFAULT_WAKING.to
3500
+ wakingTo: DEFAULT_WAKING.to,
3501
+ // Opt-in: writes to the calendar nobody asked for are the one thing an
3502
+ // instrument must never surprise anybody with.
3503
+ autoSchedule: false
3371
3504
  };
3372
3505
  var DEFAULT_CALENDARS = [
3373
3506
  {
@@ -3426,8 +3559,6 @@ function draftEvent(day, startMin, endMin, calendarId, repeat = "never") {
3426
3559
  seriesKey: "",
3427
3560
  color: "",
3428
3561
  icon: "",
3429
- task: false,
3430
- doneKeys: [],
3431
3562
  // Busy: a gesture that named a time meant that time to be spoken for.
3432
3563
  free: false,
3433
3564
  // Inheriting, which is the only answer a gesture gives: the drag named a
@@ -3565,13 +3696,12 @@ function readSettings(value) {
3565
3696
  // waking window to the defaults, same as every other bounded field here.
3566
3697
  weekStart: value.weekStart === 0 || value.weekStart === 1 || value.weekStart === 6 ? value.weekStart : DEFAULT_SETTINGS.weekStart,
3567
3698
  wakingFrom: typeof value.wakingFrom === "number" ? value.wakingFrom : DEFAULT_SETTINGS.wakingFrom,
3568
- wakingTo: typeof value.wakingTo === "number" ? value.wakingTo : DEFAULT_SETTINGS.wakingTo
3699
+ wakingTo: typeof value.wakingTo === "number" ? value.wakingTo : DEFAULT_SETTINGS.wakingTo,
3700
+ // Off unless the backup says so — a restore must not start writing to
3701
+ // the calendar because the field was absent.
3702
+ autoSchedule: value.autoSchedule === true
3569
3703
  };
3570
3704
  }
3571
- var INSTANT_KEY = /^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}$/;
3572
- function readDoneKeys(value) {
3573
- return Array.isArray(value) ? [...new Set(value.filter((k) => typeof k === "string" && INSTANT_KEY.test(k)))] : [];
3574
- }
3575
3705
  function derivedId(value, start, end) {
3576
3706
  const text = (key) => typeof value[key] === "string" ? value[key] : "";
3577
3707
  return `${start}-${fnv1a([start, end, text("title"), text("location"), text("seriesKey")].join("|"))}`;
@@ -3581,8 +3711,7 @@ function readEvent(value) {
3581
3711
  const { id, title, calendarId, start, end } = value;
3582
3712
  if (typeof start !== "number" || typeof end !== "number") return null;
3583
3713
  if (!Number.isFinite(start) || !Number.isFinite(end)) return null;
3584
- const task = value.task === true;
3585
- if (task ? end < start : end <= start) return null;
3714
+ if (end <= start) return null;
3586
3715
  return {
3587
3716
  // Derived from the record's own fields, never minted: `Math.random()` gave
3588
3717
  // a different id every read, so the same backup imported twice was two
@@ -3612,11 +3741,6 @@ function readEvent(value) {
3612
3741
  // it has to tell apart. This used to slice at one shared cap, which turned
3613
3742
  // ten of the 247 shipped names into strings matching no path.
3614
3743
  icon: readMark(value.icon),
3615
- task,
3616
- // The same instant-key check EXDATE gets, and the same reason: a key this
3617
- // does not spell matches no occurrence, so it is a row of dead weight that
3618
- // survives every future export.
3619
- doneKeys: readDoneKeys(value.doneKeys),
3620
3744
  // Anything other than a literal `true` is busy, which is what an event
3621
3745
  // exported before this field existed was. A truthy string in a
3622
3746
  // hand-edited blob must not quietly take a meeting off the day's total.
@@ -3668,10 +3792,33 @@ function readCalendar(value) {
3668
3792
  ...feedUrl ? { feed: { url: feedUrl, fetchedAt: 0, error: "" } } : {}
3669
3793
  };
3670
3794
  }
3795
+ function readTask(value) {
3796
+ if (!isRecord(value)) return null;
3797
+ const { id, title, due } = value;
3798
+ if (typeof id !== "string" || !id) return null;
3799
+ if (typeof due !== "number" || !Number.isFinite(due)) return null;
3800
+ return {
3801
+ id,
3802
+ title: typeof title === "string" ? title : "Untitled",
3803
+ notes: typeof value.notes === "string" ? value.notes : "",
3804
+ calendarId: typeof value.calendarId === "string" ? value.calendarId : "",
3805
+ // The mark and the colour take an event's own validators and defaults.
3806
+ icon: readMark(value.icon),
3807
+ color: typeof value.color === "string" ? value.color : "",
3808
+ due,
3809
+ dueAllDay: value.dueAllDay === true,
3810
+ estimate: readEstimate(value.estimate),
3811
+ priority: readPriority(value.priority),
3812
+ plans: readPlans(value.plans),
3813
+ done: value.done === true,
3814
+ updatedAt: readStamp(value.updatedAt)
3815
+ };
3816
+ }
3671
3817
  function readBackup(parsed) {
3672
3818
  if (!isRecord(parsed) || parsed.app !== "circal") return { ok: false, reason: "not-circal" };
3673
3819
  const events = (Array.isArray(parsed.events) ? parsed.events : []).map(readEvent).filter((e) => e !== null);
3674
3820
  const calendars = (Array.isArray(parsed.calendars) ? parsed.calendars : []).map(readCalendar).filter((c) => c !== null);
3821
+ const tasks = (Array.isArray(parsed.tasks) ? parsed.tasks : []).map(readTask).filter((t) => t !== null);
3675
3822
  if (calendars.length === 0) return { ok: false, reason: "no-calendars" };
3676
3823
  const known = new Set(calendars.map((c) => c.id));
3677
3824
  const fallback = calendars[0].id;
@@ -3683,6 +3830,8 @@ function readBackup(parsed) {
3683
3830
  return e.seriesId && !seriesIds.has(e.seriesId) ? { ...parented, seriesId: "", seriesKey: "" } : parented;
3684
3831
  }),
3685
3832
  calendars,
3833
+ // A task's calendar is parked the same way an event's is, same reason.
3834
+ tasks: tasks.map((t) => known.has(t.calendarId) ? t : { ...t, calendarId: fallback }),
3686
3835
  settings: readSettings(parsed.settings),
3687
3836
  // Not through `readSettings`: this is bookkeeping about the object rather
3688
3837
  // than a field of it, and a reader that clamped it into `Settings` would
@@ -3693,7 +3842,7 @@ function readBackup(parsed) {
3693
3842
  }
3694
3843
 
3695
3844
  // src/lib/mirror.ts
3696
- var MIRROR_VERSION = 2;
3845
+ var MIRROR_VERSION = 3;
3697
3846
  function settled(calendars) {
3698
3847
  return calendars.map(
3699
3848
  (c) => c.feed ? { ...c, feed: { ...c.feed, fetchedAt: 0, error: "" } } : c
@@ -3707,6 +3856,7 @@ function serialiseMirror(s, rev, zone = localZone()) {
3707
3856
  zone,
3708
3857
  rev,
3709
3858
  events: s.events,
3859
+ tasks: s.tasks,
3710
3860
  calendars: settled(s.calendars),
3711
3861
  settings: s.settings,
3712
3862
  settingsAt: s.settingsAt,
@@ -3727,6 +3877,7 @@ function readMirrorFile(text) {
3727
3877
  const envelope = parsed;
3728
3878
  return {
3729
3879
  events: read.events,
3880
+ tasks: read.tasks,
3730
3881
  calendars: read.calendars,
3731
3882
  settings: read.settings,
3732
3883
  // Both through `readBackup`, which is the boundary that already assumes
@@ -3917,6 +4068,7 @@ async function loadForRead(ctx) {
3917
4068
  }
3918
4069
  function fileStore(ctx) {
3919
4070
  return {
4071
+ transport: `file ${ctx.path}`,
3920
4072
  load: () => loadForRead(ctx),
3921
4073
  write: (mutate) => casWrite(ctx, mutate)
3922
4074
  };
@@ -4238,6 +4390,7 @@ function cloudStore(ctx) {
4238
4390
  }
4239
4391
  let writeQueue2 = Promise.resolve();
4240
4392
  return {
4393
+ transport: `cloud (${ctx.credentials.email || "signed in"})`,
4241
4394
  load: loadOnce,
4242
4395
  write(mutate) {
4243
4396
  const run = writeQueue2.then(() => writeOnce(mutate));
@@ -4571,22 +4724,47 @@ function requireScope(event, scope) {
4571
4724
  }
4572
4725
  return scope;
4573
4726
  }
4574
- function resolveCalendar(calendarId, calendars, settings) {
4575
- if (calendarId) {
4576
- const found2 = calendars.find((c) => c.id === calendarId);
4577
- if (!found2) throw new RefusalError(`no calendar with id "${calendarId}"`);
4578
- if (found2.feed) {
4579
- throw new RefusalError(
4580
- `"${found2.name}" is a subscription \u2014 its events are pulled from a feed, never written to`
4581
- );
4582
- }
4583
- return found2;
4727
+ function findCalendar(ref, calendars) {
4728
+ const byId = calendars.find((c) => c.id === ref);
4729
+ if (byId) return byId;
4730
+ const needle = ref.trim().toLowerCase();
4731
+ const matches = needle ? calendars.filter((c) => c.name.toLowerCase().startsWith(needle)) : [];
4732
+ if (matches.length === 1) return matches[0];
4733
+ if (matches.length > 1) {
4734
+ throw new RefusalError(
4735
+ `"${ref}" names ${matches.length} calendars (${matches.map((c) => c.name).join(", ")}) \u2014 use an id from list_calendars`
4736
+ );
4737
+ }
4738
+ throw new RefusalError(`no calendar with id or name "${ref}"`);
4739
+ }
4740
+ function requireWritable(calendar) {
4741
+ if (calendar.feed) {
4742
+ throw new RefusalError(
4743
+ `"${calendar.name}" is a subscription \u2014 its events are pulled from a feed, never written to`
4744
+ );
4584
4745
  }
4746
+ return calendar;
4747
+ }
4748
+ function resolveCalendar(calendarId, calendars, settings, pin) {
4749
+ if (calendarId) return requireWritable(findCalendar(calendarId, calendars));
4750
+ if (pin) return requireWritable(findCalendar(pin, calendars));
4585
4751
  const id = defaultCalendarId(calendars, settings.defaultCalendar);
4586
4752
  const found = calendars.find((c) => c.id === id);
4587
4753
  if (!found) throw new RefusalError("there is no calendar to put this on \u2014 create one first");
4588
4754
  return found;
4589
4755
  }
4756
+ function defaultedCalendar(calendarId, pin) {
4757
+ return calendarId === void 0 && !pin;
4758
+ }
4759
+ function scopedToCalendar(contents, ref) {
4760
+ if (!ref) return contents;
4761
+ const calendar = findCalendar(ref, contents.calendars);
4762
+ return {
4763
+ ...contents,
4764
+ events: contents.events.filter((e) => e.calendarId === calendar.id),
4765
+ tasks: contents.tasks.filter((t) => t.calendarId === calendar.id)
4766
+ };
4767
+ }
4590
4768
  function refuseIfFeed(event, calendars) {
4591
4769
  const calendar = calendars.find((c) => c.id === event.calendarId);
4592
4770
  if (calendar?.feed) {
@@ -4595,49 +4773,70 @@ function refuseIfFeed(event, calendars) {
4595
4773
  );
4596
4774
  }
4597
4775
  }
4598
- function buildRecurrence(input) {
4776
+ function buildRecurrence(input, zone = "") {
4599
4777
  if (!input) return { ...NEVER };
4600
- const until = input.until ? addDays(fromDayKey(input.until), 1).getTime() - 1 : 0;
4778
+ const boundary = input.until ? addDays(fromDayKey(input.until), 1) : null;
4779
+ const until = boundary ? (zone ? instantOfWall(boundary, zone) : boundary.getTime()) - 1 : 0;
4601
4780
  return readRecurrence({ ...input, until, exdates: [], rdates: [] });
4602
4781
  }
4603
- function resolveSpanForCreate(input, day, defaultDuration) {
4604
- if (input.task === true) {
4605
- if (input.start === void 0) throw new RefusalError('a task needs "start" (HH:MM)');
4606
- const at = atMinutes(day, parseTime(input.start, "start")).getTime();
4607
- return { start: at, end: at, allDay: false, task: true };
4782
+ function spanInstant(day, minutes, zone) {
4783
+ return zone ? instantOfWall(atMinutes(day, minutes), zone) : atMinutes(day, minutes).getTime();
4784
+ }
4785
+ function zoneFrom(value) {
4786
+ if (value === "") return "";
4787
+ if (!isZone(value)) {
4788
+ throw new RefusalError(
4789
+ `"${value}" is not an IANA time zone this runtime knows \u2014 try "Asia/Seoul"`
4790
+ );
4608
4791
  }
4792
+ return value;
4793
+ }
4794
+ function leadFrom(value) {
4795
+ if (value === "inherit") return REMIND_INHERIT;
4796
+ if (value === "silent") return REMIND_SILENT;
4797
+ if (readRemindLead(value) !== value) {
4798
+ throw new RefusalError(
4799
+ `"remindLead" must be whole minutes, 0 to ${REMIND_LEAD_MAX} \u2014 got ${value}`
4800
+ );
4801
+ }
4802
+ return value;
4803
+ }
4804
+ function resolveSpanForCreate(input, day, defaultDuration, zone) {
4609
4805
  const endIsDay = input.end !== void 0 && DATE_KEY_RE.test(input.end);
4610
4806
  const noTimeGiven = input.start === void 0 && (input.end === void 0 || endIsDay) && input.duration === void 0;
4611
4807
  const allDay = input.allDay === true || input.allDay === void 0 && noTimeGiven;
4612
4808
  if (allDay) {
4809
+ if (zone) {
4810
+ throw new RefusalError(
4811
+ 'an all-day event carries no clock to zone \u2014 drop "zone" or give a timed "start"'
4812
+ );
4813
+ }
4613
4814
  const days = input.end ? Math.max(1, daysBetween(day, fromDayKey(input.end)) + 1) : 1;
4614
4815
  return {
4615
4816
  start: startOfDay(day).getTime(),
4616
4817
  end: addDays(startOfDay(day), days).getTime(),
4617
- allDay: true,
4618
- task: false
4818
+ allDay: true
4619
4819
  };
4620
4820
  }
4621
4821
  if (input.start === void 0) throw new RefusalError('a timed event needs "start" (HH:MM)');
4622
4822
  const startMin = parseTime(input.start, "start");
4623
4823
  const endMin = input.end !== void 0 ? parseTime(input.end, "end") : startMin + (input.duration ?? defaultDuration);
4624
4824
  return {
4625
- start: atMinutes(day, startMin).getTime(),
4626
- end: atMinutes(day, endMin).getTime(),
4627
- allDay: false,
4628
- task: false
4825
+ start: spanInstant(day, startMin, zone),
4826
+ end: spanInstant(day, endMin, zone),
4827
+ allDay: false
4629
4828
  };
4630
4829
  }
4631
- function resolveSpanForPatch(input, base, anchorMs) {
4632
- const day = input.date ? parseDay(input.date) : startOfDay(new Date(anchorMs));
4633
- const task = input.task ?? base.task;
4634
- if (task) {
4635
- const startMin2 = input.start !== void 0 ? parseTime(input.start, "start") : minutesOfDay(new Date(anchorMs));
4636
- const at = atMinutes(day, startMin2).getTime();
4637
- return { start: at, end: at, allDay: false, task: true };
4638
- }
4830
+ function resolveSpanForPatch(input, base, anchorMs, zone) {
4831
+ const anchorWall = base.tzid ? wallClockIn(anchorMs, base.tzid) : new Date(anchorMs);
4832
+ const day = input.date ? parseDay(input.date) : startOfDay(anchorWall);
4639
4833
  const allDay = input.allDay ?? base.allDay;
4640
4834
  if (allDay) {
4835
+ if (input.zone) {
4836
+ throw new RefusalError(
4837
+ 'an all-day event carries no clock to zone \u2014 drop "zone" or give a timed "start"'
4838
+ );
4839
+ }
4641
4840
  const baseDays = Math.max(
4642
4841
  1,
4643
4842
  daysBetween(startOfDay(new Date(base.start)), startOfDay(new Date(base.end)))
@@ -4646,56 +4845,55 @@ function resolveSpanForPatch(input, base, anchorMs) {
4646
4845
  return {
4647
4846
  start: startOfDay(day).getTime(),
4648
4847
  end: addDays(startOfDay(day), days).getTime(),
4649
- allDay: true,
4650
- task: false
4848
+ allDay: true
4651
4849
  };
4652
4850
  }
4653
4851
  const baseDurationMin = Math.max(1, Math.round((base.end - base.start) / 6e4));
4654
- const startMin = input.start !== void 0 ? parseTime(input.start, "start") : minutesOfDay(new Date(anchorMs));
4852
+ const startMin = input.start !== void 0 ? parseTime(input.start, "start") : minutesOfDay(anchorWall);
4655
4853
  const endMin = input.end !== void 0 ? parseTime(input.end, "end") : startMin + (input.duration ?? baseDurationMin);
4656
4854
  return {
4657
- start: atMinutes(day, startMin).getTime(),
4658
- end: atMinutes(day, endMin).getTime(),
4659
- allDay: false,
4660
- task: false
4855
+ start: spanInstant(day, startMin, zone),
4856
+ end: spanInstant(day, endMin, zone),
4857
+ allDay: false
4661
4858
  };
4662
4859
  }
4663
4860
  function validate(draft) {
4664
4861
  const validated = readEvent({ ...draft, id: "pending" });
4665
4862
  if (!validated)
4666
- throw new RefusalError("the assembled event failed validation (check start, end and task)");
4863
+ throw new RefusalError("the assembled event failed validation (check start and end)");
4667
4864
  const { id: _drop, ...rest } = validated;
4668
4865
  return rest;
4669
4866
  }
4670
- function buildEventRecord(input, calendars, settings) {
4867
+ function buildEventRecord(input, calendars, settings, pin) {
4671
4868
  if (!input.date) throw new RefusalError('an event needs "date" (YYYY-MM-DD)');
4672
4869
  const day = parseDay(input.date);
4673
- const calendar = resolveCalendar(input.calendarId, calendars, settings);
4674
- const span = resolveSpanForCreate(input, day, settings.defaultDuration);
4870
+ const calendar = resolveCalendar(input.calendarId, calendars, settings, pin);
4871
+ const zone = input.zone ? zoneFrom(input.zone) : "";
4872
+ const span = resolveSpanForCreate(input, day, settings.defaultDuration, zone);
4675
4873
  return validate({
4676
4874
  title: input.title ?? "",
4677
4875
  calendarId: calendar.id,
4678
4876
  start: span.start,
4679
4877
  end: span.end,
4680
4878
  allDay: span.allDay,
4681
- tzid: "",
4879
+ // The zone the times were spelled in, kept so every surface can print
4880
+ // both clocks — and so a rule walks that zone's own wall calendar.
4881
+ tzid: zone,
4682
4882
  location: input.location ?? "",
4683
4883
  url: input.url ?? "",
4684
4884
  notes: input.notes ?? "",
4685
- ...buildRecurrence(input.recurrence),
4885
+ ...buildRecurrence(input.recurrence, zone),
4686
4886
  seriesId: "",
4687
4887
  seriesKey: "",
4688
4888
  color: input.color ?? "",
4689
4889
  icon: input.icon ?? "",
4690
- task: span.task,
4691
- doneKeys: [],
4692
4890
  // Busy. There is no tool argument for this yet, and an agent asked to put
4693
4891
  // an hour in the diary means the hour to be spoken for.
4694
4892
  free: false,
4695
- // Inheriting, for the same reason: no tool argument names a lead, and an
4696
- // event created with one of its own would be an agent quietly deciding
4697
- // how much warning its user wants.
4698
- remindLead: REMIND_INHERIT,
4893
+ // Inheriting unless the caller named a lead of its own — an event created
4894
+ // with one it was not asked for would be an agent quietly deciding how
4895
+ // much warning its user wants.
4896
+ remindLead: input.remindLead !== void 0 ? leadFrom(input.remindLead) : REMIND_INHERIT,
4699
4897
  // Nobody, for the reason there is no tool argument and never will be: an
4700
4898
  // agent has no channel to invite anyone on either, so a guest list it
4701
4899
  // assembled would be a list of people who were never asked.
@@ -4710,34 +4908,34 @@ function buildEventRecord(input, calendars, settings) {
4710
4908
  }
4711
4909
  function applyPatchToEvent(base, input, calendars, settings, anchorMs = base.start) {
4712
4910
  const calendar = input.calendarId !== void 0 ? resolveCalendar(input.calendarId, calendars, settings) : calendars.find((c) => c.id === base.calendarId) ?? resolveCalendar(void 0, calendars, settings);
4713
- const span = resolveSpanForPatch(input, base, anchorMs);
4911
+ const zone = input.zone !== void 0 ? zoneFrom(input.zone) : base.tzid;
4912
+ const span = resolveSpanForPatch(input, base, anchorMs, zone);
4714
4913
  return validate({
4715
4914
  title: input.title ?? base.title,
4716
4915
  calendarId: calendar.id,
4717
4916
  start: span.start,
4718
4917
  end: span.end,
4719
4918
  allDay: span.allDay,
4720
- tzid: base.tzid,
4919
+ // The patch's zone, else the base's — cleared with the clock when the
4920
+ // patch flips the event all-day, since an all-day event is never zoned.
4921
+ tzid: span.allDay ? "" : zone,
4721
4922
  location: input.location ?? base.location,
4722
4923
  url: input.url ?? base.url,
4723
4924
  notes: input.notes ?? base.notes,
4724
- ...input.recurrence ? buildRecurrence(input.recurrence) : recurrenceOf(base),
4925
+ ...input.recurrence ? buildRecurrence(input.recurrence, zone) : recurrenceOf(base),
4725
4926
  seriesId: base.seriesId,
4726
4927
  seriesKey: base.seriesKey,
4727
4928
  color: input.color ?? base.color,
4728
4929
  icon: input.icon ?? base.icon,
4729
- task: span.task,
4730
- doneKeys: base.doneKeys,
4731
4930
  // Carried, like every other field a patch does not name: `validate` reads
4732
4931
  // the draft through `backup.readEvent`, which defaults an absent `free`
4733
4932
  // to busy — so leaving it out would quietly rebook a free event the
4734
4933
  // moment its title was edited.
4735
4934
  free: base.free,
4736
- // Carried for exactly the reason above, and this is the field where it
4737
- // costs most: an event the user silenced would start speaking again the
4738
- // moment an assistant fixed a typo in its title, because `readEvent`
4739
- // reads an absent lead as inheriting.
4740
- remindLead: base.remindLead,
4935
+ // Carried unless the patch names one — `readEvent` reads an absent lead
4936
+ // as inheriting, so leaving it out would un-silence an event the user
4937
+ // silenced the moment an assistant fixed a typo in its title.
4938
+ remindLead: input.remindLead !== void 0 ? leadFrom(input.remindLead) : base.remindLead,
4741
4939
  // Carried, and this is the field where dropping it would be least
4742
4940
  // recoverable: the guest list is a record of a message somebody else sent,
4743
4941
  // and `readEvent` reads an absent one as no invitation at all — so an
@@ -4755,27 +4953,63 @@ function carried(contents) {
4755
4953
  return {
4756
4954
  events: contents.events,
4757
4955
  calendars: contents.calendars,
4956
+ tasks: contents.tasks,
4758
4957
  settings: contents.settings,
4759
4958
  settingsAt: contents.settingsAt,
4760
4959
  graves: contents.graves
4761
4960
  };
4762
4961
  }
4962
+ var ID_SHAPE_RE = /^([0-9a-z]+-[0-9a-z]{6}|x-.+)$/;
4963
+ function notFound(contents, id, kind) {
4964
+ const grave = contents.graves.find((g) => g.id === id);
4965
+ if (grave) {
4966
+ return new RefusalError(
4967
+ `no ${kind} with id "${id}" \u2014 deleted ${new Date(grave.at).toISOString()} (a grave for it is in the document, so this writer or another one removed it)`
4968
+ );
4969
+ }
4970
+ if (ID_SHAPE_RE.test(id)) {
4971
+ return new RefusalError(
4972
+ `no ${kind} with id "${id}", and no grave for it either \u2014 the id has this app's shape, so another writer may have dropped or rewritten it; re-read with list_day or search`
4973
+ );
4974
+ }
4975
+ return new RefusalError(`no ${kind} with id "${id}"`);
4976
+ }
4763
4977
  function calendarName(calendars, id) {
4764
4978
  return calendars.find((c) => c.id === id)?.name ?? "\u2014";
4765
4979
  }
4980
+ function zoneSuffix(event, clock) {
4981
+ if (!event.tzid || event.allDay) return "";
4982
+ const start = formatTimeShort(wallClockIn(event.start, event.tzid), clock);
4983
+ const end = formatTimeShort(wallClockIn(event.end, event.tzid), clock);
4984
+ return ` (${start}\u2013${end} ${event.tzid})`;
4985
+ }
4766
4986
  function eventSummaryLine(event, calendars, clock) {
4767
- const when = event.task ? `${dayKey(new Date(event.start))} ${formatTimeShort(new Date(event.start), clock)}` : event.allDay ? `${dayKey(new Date(event.start))} \u2013 ${dayKey(addDays(new Date(event.end), -1))} (all day)` : `${dayKey(new Date(event.start))} ${formatTimeShort(new Date(event.start), clock)}\u2013${formatTimeShort(new Date(event.end), clock)}`;
4987
+ const when = event.allDay ? `${dayKey(new Date(event.start))} \u2013 ${dayKey(addDays(new Date(event.end), -1))} (all day)` : `${dayKey(new Date(event.start))} ${formatTimeShort(new Date(event.start), clock)}\u2013${formatTimeShort(new Date(event.end), clock)}${zoneSuffix(event, clock)}`;
4768
4988
  return `${event.title || "Untitled"} \u2014 [${calendarName(calendars, event.calendarId)}] \u2014 ${when} \u2014 id=${event.id}`;
4769
4989
  }
4990
+ function taskSummaryLine(task, calendars, clock) {
4991
+ const when = task.dueAllDay ? `due ${dayKey(new Date(task.due))}` : `due ${dayKey(new Date(task.due))} ${formatTimeShort(new Date(task.due), clock)}`;
4992
+ const bits = [
4993
+ `${task.title || "Untitled"} (task)`,
4994
+ `[${calendarName(calendars, task.calendarId)}]`,
4995
+ when,
4996
+ task.done ? "done" : "open",
4997
+ ...task.estimate !== null ? [`~${formatDuration(task.estimate)}`] : [],
4998
+ ...task.priority > 0 ? [task.priority === 2 ? "urgent" : "high"] : [],
4999
+ `id=${task.id}`
5000
+ ];
5001
+ return bits.join(" \u2014 ");
5002
+ }
4770
5003
  function occurrenceLine(occ, calendars, clock) {
4771
- const when = occ.event.allDay ? "all day" : occ.event.task ? `${occ.start.toISOString()} (${formatTimeShort(occ.start, clock)})` : `${occ.start.toISOString()} (${formatTimeShort(occ.start, clock)}) \u2013 ${occ.end.toISOString()} (${formatTimeShort(occ.end, clock)})`;
5004
+ const pip = occ.task && occ.start.getTime() === occ.end.getTime();
5005
+ const when = occ.event.allDay ? "all day" : pip ? `${occ.start.toISOString()} (${formatTimeShort(occ.start, clock)}) due` : `${occ.start.toISOString()} (${formatTimeShort(occ.start, clock)}) \u2013 ${occ.end.toISOString()} (${formatTimeShort(occ.end, clock)})`;
4772
5006
  const span = spanLabel(occ);
4773
5007
  const bits = [
4774
5008
  occ.event.title || "Untitled",
4775
5009
  `[${calendarName(calendars, occ.event.calendarId)}]`,
4776
5010
  when,
4777
5011
  ...span ? [span] : [],
4778
- ...occ.event.task ? [occ.done ? "done" : "open"] : [],
5012
+ ...occ.task ? [occ.done ? "done" : "open"] : [],
4779
5013
  `id=${occ.event.id}`,
4780
5014
  `occurrence=${dayKey(occ.start)}`
4781
5015
  ];
@@ -4784,7 +5018,7 @@ function occurrenceLine(occ, calendars, clock) {
4784
5018
  function listDay(contents, date) {
4785
5019
  assertDateKey(date);
4786
5020
  const day = fromDayKey(date);
4787
- const agenda = dayAgenda(contents.events, contents.calendars, day);
5021
+ const agenda = dayAgenda(contents.events, contents.calendars, day, contents.tasks);
4788
5022
  const clock = contents.settings.clock;
4789
5023
  return [
4790
5024
  `${date} \u2014 ${agenda.all.length} occurrence${agenda.all.length === 1 ? "" : "s"}`,
@@ -4814,7 +5048,10 @@ function listRange(contents, from, to) {
4814
5048
  let truncated = false;
4815
5049
  for (let i = 0; i < span && !truncated; i++) {
4816
5050
  const day = addDays(fromDay, i);
4817
- const occs = occurrencesOn(contents.events, contents.calendars, day);
5051
+ const occs = [
5052
+ ...occurrencesOn(contents.events, contents.calendars, day),
5053
+ ...taskOccurrences(contents.tasks, contents.calendars, day)
5054
+ ].sort((a, b) => a.start.getTime() - b.start.getTime() || a.end.getTime() - b.end.getTime());
4818
5055
  if (occs.length === 0) continue;
4819
5056
  lines.push(`${dayKey(day)}:`);
4820
5057
  for (const occ of occs) {
@@ -4831,16 +5068,29 @@ function listRange(contents, from, to) {
4831
5068
  }
4832
5069
  function searchEvents(contents, query, limit = 20) {
4833
5070
  const clock = contents.settings.clock;
4834
- const scored = contents.events.map((event) => ({ event, score: scoreEvent(event, query) })).filter((s) => s.score !== null).sort((a, b) => b.score - a.score).slice(0, Math.max(0, limit));
5071
+ const cap = Math.max(0, limit);
5072
+ const scored = contents.events.map((event) => ({
5073
+ line: eventSummaryLine(event, contents.calendars, clock),
5074
+ hit: scoreEventBy(event, query)
5075
+ })).concat(
5076
+ contents.tasks.map((task) => ({
5077
+ line: taskSummaryLine(task, contents.calendars, clock),
5078
+ hit: scoreTaskBy(task, query)
5079
+ }))
5080
+ ).filter((s) => s.hit !== null).sort((a, b) => b.hit.score - a.hit.score).slice(0, cap);
4835
5081
  return [
4836
5082
  `"${query}" \u2014 ${scored.length} match${scored.length === 1 ? "" : "es"}`,
4837
- ...scored.length ? scored.map(({ event }) => eventSummaryLine(event, contents.calendars, clock)) : ["(none)"]
5083
+ // A hit outside the title says so: a notes match on a long note is
5084
+ // otherwise indistinguishable from what the record is called.
5085
+ ...scored.length ? scored.map(
5086
+ ({ line, hit }) => hit.field === "title" ? line : `${line} \u2014 matched ${hit.field}`
5087
+ ) : ["(none)"]
4838
5088
  ].join("\n");
4839
5089
  }
4840
5090
  function freeSlots(contents, date, minutes, from, to) {
4841
5091
  assertDateKey(date);
4842
5092
  const day = fromDayKey(date);
4843
- const occs = occurrencesOn(contents.events, contents.calendars, day);
5093
+ const occs = dayAgenda(contents.events, contents.calendars, day, contents.tasks).all;
4844
5094
  const fromMin = from !== void 0 ? parseTime(from, "from") : contents.settings.wakingFrom ?? DEFAULT_WAKING.from;
4845
5095
  const toMin = to !== void 0 ? parseTime(to, "to") : contents.settings.wakingTo ?? DEFAULT_WAKING.to;
4846
5096
  const clock = contents.settings.clock;
@@ -4861,24 +5111,56 @@ function listCalendars(contents) {
4861
5111
  }
4862
5112
  function describeEvent(contents, id) {
4863
5113
  const event = contents.events.find((e) => e.id === id);
4864
- if (!event) throw new RefusalError(`no event with id "${id}"`);
5114
+ if (!event) {
5115
+ const task = contents.tasks.find((t) => t.id === id);
5116
+ if (task) return describeTask(contents, task);
5117
+ throw notFound(contents, id, "event");
5118
+ }
4865
5119
  const calendar = contents.calendars.find((c) => c.id === event.calendarId);
4866
5120
  const clock = contents.settings.clock;
4867
5121
  return [
4868
5122
  `${event.title || "Untitled"} (id=${event.id})`,
4869
5123
  `Calendar: ${calendarName(contents.calendars, event.calendarId)}${calendar?.feed ? " (subscription, read-only)" : ""}`,
4870
- event.task ? `Task at ${dayKey(new Date(event.start))} ${formatTimeShort(new Date(event.start), clock)}` : event.allDay ? `All day, ${dayKey(new Date(event.start))} to ${dayKey(addDays(new Date(event.end), -1))}` : `${dayKey(new Date(event.start))} ${formatTimeShort(new Date(event.start), clock)}\u2013${formatTimeShort(new Date(event.end), clock)}`,
5124
+ event.allDay ? `All day, ${dayKey(new Date(event.start))} to ${dayKey(addDays(new Date(event.end), -1))}` : `${dayKey(new Date(event.start))} ${formatTimeShort(new Date(event.start), clock)}\u2013${formatTimeShort(new Date(event.end), clock)}`,
5125
+ // Both clocks for a zoned event — the times above are local, and the
5126
+ // hours the deadline was written in are the ones its owner reasons in.
5127
+ ...event.tzid && !event.allDay ? [
5128
+ `Zone: ${event.tzid} \u2014 ${formatTimeShort(wallClockIn(event.start, event.tzid), clock)}\u2013${formatTimeShort(wallClockIn(event.end, event.tzid), clock)} there`
5129
+ ] : [],
4871
5130
  `Repeats: ${repeats(event) ? describeRule(event) : "never"}`,
5131
+ ...event.remindLead !== REMIND_INHERIT ? [`Remind: ${event.remindLead === REMIND_SILENT ? "silent" : remindLabel(event.remindLead)}`] : [],
4872
5132
  ...event.location ? [`Location: ${event.location}`] : [],
4873
5133
  ...event.url ? [`URL: ${event.url}`] : [],
4874
5134
  ...event.notes ? [`Notes: ${event.notes}`] : [],
4875
- ...event.seriesId ? [`Overrides one occurrence of id=${event.seriesId} (${event.seriesKey})`] : [],
4876
- ...event.task ? [
4877
- `Done: ${event.doneKeys.length} occurrence${event.doneKeys.length === 1 ? "" : "s"} ticked`
4878
- ] : []
5135
+ ...event.seriesId ? [`Overrides one occurrence of id=${event.seriesId} (${event.seriesKey})`] : []
4879
5136
  ].join("\n");
4880
5137
  }
4881
- function performQuickAdd(contents, text, date) {
5138
+ function describeTask(contents, task) {
5139
+ const clock = contents.settings.clock;
5140
+ return [
5141
+ `${task.title || "Untitled"} (task, id=${task.id})`,
5142
+ `Calendar: ${calendarName(contents.calendars, task.calendarId)}`,
5143
+ task.dueAllDay ? `Due ${dayKey(new Date(task.due))}` : `Due ${dayKey(new Date(task.due))} ${formatTimeShort(new Date(task.due), clock)}`,
5144
+ `Status: ${task.done ? "done" : "open"}`,
5145
+ ...task.estimate !== null ? [`Estimate: ${formatDuration(task.estimate)}`] : [],
5146
+ ...task.priority > 0 ? [`Priority: ${task.priority === 2 ? "urgent" : "high"}`] : [],
5147
+ ...task.plans.length ? task.plans.map(
5148
+ (p) => `Planned: ${dayKey(new Date(p.start))} ${formatTimeShort(new Date(p.start), clock)}\u2013${formatTimeShort(new Date(p.end), clock)}`
5149
+ ) : [],
5150
+ ...task.notes ? [`Notes: ${task.notes}`] : []
5151
+ ].join("\n");
5152
+ }
5153
+ function syncStatus(contents, transport) {
5154
+ return [
5155
+ `Document: rev ${contents.rev}, format version ${contents.version}, zone ${contents.zone || "(unrecorded)"}`,
5156
+ `Transport: ${transport}`,
5157
+ `Counts: ${contents.events.length} events, ${contents.tasks.length} tasks, ${contents.calendars.length} calendars, ${contents.graves.length} graves`
5158
+ ].join("\n");
5159
+ }
5160
+ function defaultedSuffix(defaulted) {
5161
+ return defaulted ? " \u2014 calendar defaulted, not named" : "";
5162
+ }
5163
+ function performQuickAdd(contents, text, date, pin) {
4882
5164
  if (!text.trim()) throw new RefusalError('"text" is empty');
4883
5165
  const reference = date ? parseDay(date) : startOfDay(/* @__PURE__ */ new Date());
4884
5166
  const parsed = parseQuickAdd(text, reference, /* @__PURE__ */ new Date(), contents.settings.defaultDuration);
@@ -4887,7 +5169,7 @@ function performQuickAdd(contents, text, date) {
4887
5169
  `could not read "${text}" as an event \u2014 name something beyond a bare title, e.g. "lunch tomorrow 1pm 45m" or "gym every monday 7am"`
4888
5170
  );
4889
5171
  }
4890
- const calendar = resolveCalendar(void 0, contents.calendars, contents.settings);
5172
+ const calendar = resolveCalendar(void 0, contents.calendars, contents.settings, pin);
4891
5173
  const record = validate({
4892
5174
  ...draftEvent(parsed.day, parsed.startMin, parsed.endMin, calendar.id),
4893
5175
  title: parsed.title,
@@ -4896,20 +5178,182 @@ function performQuickAdd(contents, text, date) {
4896
5178
  const event = { ...record, id: newId(), updatedAt: Date.now() };
4897
5179
  return {
4898
5180
  mutated: { ...carried(contents), events: [...contents.events, event] },
4899
- message: `Created ${eventSummaryLine(event, contents.calendars, contents.settings.clock)}`
5181
+ message: `Created ${eventSummaryLine(event, contents.calendars, contents.settings.clock)}` + defaultedSuffix(defaultedCalendar(void 0, pin))
4900
5182
  };
4901
5183
  }
4902
- function performCreateEvent(contents, input) {
4903
- const record = buildEventRecord(input, contents.calendars, contents.settings);
5184
+ var KEYED_ID_PREFIX = "x-";
5185
+ function keyedId(key) {
5186
+ return `${KEYED_ID_PREFIX}${key}`;
5187
+ }
5188
+ function performCreateEvent(contents, input, key, pin) {
5189
+ const suffix = defaultedSuffix(defaultedCalendar(input.calendarId, pin));
5190
+ if (key) {
5191
+ const id = keyedId(key);
5192
+ const existing = contents.events.find((e) => e.id === id);
5193
+ if (existing) {
5194
+ refuseIfFeed(existing, contents.calendars);
5195
+ const record3 = applyPatchToEvent(existing, input, contents.calendars, contents.settings);
5196
+ const events = applyUpdate(contents.events, id, record3, Date.now());
5197
+ const updated = events.find((e) => e.id === id);
5198
+ return {
5199
+ mutated: { ...carried(contents), events },
5200
+ message: `Updated (by key "${key}") ` + eventSummaryLine(updated, contents.calendars, contents.settings.clock) + suffix
5201
+ };
5202
+ }
5203
+ const record2 = buildEventRecord(input, contents.calendars, contents.settings, pin);
5204
+ const event2 = { ...record2, id, updatedAt: Date.now() };
5205
+ return {
5206
+ mutated: { ...carried(contents), events: [...contents.events, event2] },
5207
+ message: `Created ${eventSummaryLine(event2, contents.calendars, contents.settings.clock)}` + suffix
5208
+ };
5209
+ }
5210
+ const record = buildEventRecord(input, contents.calendars, contents.settings, pin);
4904
5211
  const event = { ...record, id: newId(), updatedAt: Date.now() };
4905
5212
  return {
4906
5213
  mutated: { ...carried(contents), events: [...contents.events, event] },
4907
- message: `Created ${eventSummaryLine(event, contents.calendars, contents.settings.clock)}`
5214
+ message: `Created ${eventSummaryLine(event, contents.calendars, contents.settings.clock)}` + suffix
5215
+ };
5216
+ }
5217
+ var CREATE_BATCH_MAX = 100;
5218
+ function performCreateEvents(contents, inputs, pin) {
5219
+ if (inputs.length === 0) throw new RefusalError('"events" is empty');
5220
+ if (inputs.length > CREATE_BATCH_MAX) {
5221
+ throw new RefusalError(
5222
+ `the batch is capped at ${CREATE_BATCH_MAX} events \u2014 got ${inputs.length}`
5223
+ );
5224
+ }
5225
+ let current = contents;
5226
+ const lines = [];
5227
+ for (const [index, input] of inputs.entries()) {
5228
+ let outcome;
5229
+ try {
5230
+ outcome = performCreateEvent(current, input, input.key, pin);
5231
+ } catch (error) {
5232
+ if (error instanceof RefusalError) {
5233
+ throw new RefusalError(`event ${index + 1} of ${inputs.length}: ${error.message}`);
5234
+ }
5235
+ throw error;
5236
+ }
5237
+ current = { ...current, ...outcome.mutated };
5238
+ lines.push(outcome.message);
5239
+ }
5240
+ return { mutated: carried(current), message: lines.join("\n") };
5241
+ }
5242
+ var PRIORITY_RANK = { normal: 0, high: 1, urgent: 2 };
5243
+ function requireTask(contents, id, tool) {
5244
+ const task = contents.tasks.find((t) => t.id === id);
5245
+ if (task) return task;
5246
+ if (contents.events.some((e) => e.id === id)) {
5247
+ throw new RefusalError(`"${id}" is an event, not a task \u2014 ${tool} works on tasks`);
5248
+ }
5249
+ throw notFound(contents, id, "task");
5250
+ }
5251
+ function requireValidTask(assembled) {
5252
+ const task = readTask(assembled);
5253
+ if (!task) throw new RefusalError("the assembled task failed validation (check date and time)");
5254
+ return task;
5255
+ }
5256
+ function performCreateTask(contents, input, pin) {
5257
+ if (!input.date) throw new RefusalError('a task needs "date" (YYYY-MM-DD), its due day');
5258
+ const day = parseDay(input.date);
5259
+ const calendar = resolveCalendar(input.calendarId, contents.calendars, contents.settings, pin);
5260
+ const dueAllDay = input.time === void 0 || input.time === null;
5261
+ const task = requireValidTask({
5262
+ id: newId(),
5263
+ title: input.title ?? "",
5264
+ notes: input.notes ?? "",
5265
+ calendarId: calendar.id,
5266
+ icon: input.icon ?? "",
5267
+ color: input.color ?? "",
5268
+ due: dueAllDay ? startOfDay(day).getTime() : atMinutes(day, parseTime(input.time, "time")).getTime(),
5269
+ dueAllDay,
5270
+ estimate: input.estimate ?? null,
5271
+ priority: PRIORITY_RANK[input.priority ?? "normal"],
5272
+ plans: [],
5273
+ done: false,
5274
+ updatedAt: Date.now()
5275
+ });
5276
+ return {
5277
+ mutated: { ...carried(contents), tasks: [...contents.tasks, task] },
5278
+ message: `Created ${taskSummaryLine(task, contents.calendars, contents.settings.clock)}` + defaultedSuffix(defaultedCalendar(input.calendarId, pin))
4908
5279
  };
4909
5280
  }
5281
+ function performUpdateTask(contents, id, patch) {
5282
+ const original = requireTask(contents, id, "update_task");
5283
+ const day = patch.date ? parseDay(patch.date) : startOfDay(new Date(original.due));
5284
+ const dueAllDay = patch.time === null ? true : patch.time !== void 0 ? false : original.dueAllDay;
5285
+ const dueMin = typeof patch.time === "string" ? parseTime(patch.time, "time") : minutesOfDay(new Date(original.due));
5286
+ const calendar = patch.calendarId ? resolveCalendar(patch.calendarId, contents.calendars, contents.settings) : void 0;
5287
+ const next = requireValidTask({
5288
+ ...original,
5289
+ title: patch.title ?? original.title,
5290
+ notes: patch.notes ?? original.notes,
5291
+ calendarId: calendar?.id ?? original.calendarId,
5292
+ color: patch.color ?? original.color,
5293
+ icon: patch.icon ?? original.icon,
5294
+ due: dueAllDay ? startOfDay(day).getTime() : atMinutes(day, dueMin).getTime(),
5295
+ dueAllDay,
5296
+ estimate: patch.estimate !== void 0 ? patch.estimate : original.estimate,
5297
+ priority: patch.priority !== void 0 ? PRIORITY_RANK[patch.priority] : original.priority,
5298
+ updatedAt: Date.now()
5299
+ });
5300
+ return {
5301
+ mutated: {
5302
+ ...carried(contents),
5303
+ tasks: contents.tasks.map((t) => t.id === id ? next : t)
5304
+ },
5305
+ message: `Updated ${taskSummaryLine(next, contents.calendars, contents.settings.clock)}`
5306
+ };
5307
+ }
5308
+ function performPlanTask(contents, id, date, start, end, duration) {
5309
+ const original = requireTask(contents, id, "plan_task");
5310
+ if (original.done) throw new RefusalError("this task is already done \u2014 nothing to plan");
5311
+ if (original.plans.length >= PLANS_MAX) {
5312
+ throw new RefusalError(`this task already carries ${PLANS_MAX} plans, the cap`);
5313
+ }
5314
+ const day = parseDay(date);
5315
+ const startMin = parseTime(start, "start");
5316
+ const endMin = end !== void 0 ? parseTime(end, "end") : startMin + (duration ?? original.estimate ?? contents.settings.defaultDuration);
5317
+ if (endMin <= startMin) throw new RefusalError('"end" must be after "start"');
5318
+ const plan = { start: atMinutes(day, startMin).getTime(), end: atMinutes(day, endMin).getTime() };
5319
+ const next = {
5320
+ ...original,
5321
+ plans: [...original.plans, plan].sort((a, b) => a.start - b.start || a.end - b.end),
5322
+ updatedAt: Date.now()
5323
+ };
5324
+ const clock = contents.settings.clock;
5325
+ return {
5326
+ mutated: { ...carried(contents), tasks: contents.tasks.map((t) => t.id === id ? next : t) },
5327
+ message: `Planned "${next.title || "Untitled"}" ${date} ${formatTimeShort(new Date(plan.start), clock)}\u2013${formatTimeShort(new Date(plan.end), clock)}`
5328
+ };
5329
+ }
5330
+ function listTasks(contents, now = /* @__PURE__ */ new Date()) {
5331
+ if (contents.tasks.length === 0) return "No tasks.";
5332
+ const clock = contents.settings.clock;
5333
+ const groups = [
5334
+ ["Overdue", []],
5335
+ ["Due soon", []],
5336
+ ["Later", []],
5337
+ ["Done", []]
5338
+ ];
5339
+ const index = { overdue: 0, "due-soon": 1, later: 2, done: 3 };
5340
+ for (const task of contents.tasks) groups[index[taskState(task, now)]][1].push(task);
5341
+ const sections = groups.filter(([, rows]) => rows.length > 0).map(
5342
+ ([label, rows]) => [
5343
+ `${label} (${rows.length}):`,
5344
+ ...rows.sort(compareTasks).map((t) => ` ${taskSummaryLine(t, contents.calendars, clock)}`)
5345
+ ].join("\n")
5346
+ );
5347
+ return sections.join("\n\n");
5348
+ }
4910
5349
  function performUpdateEvent(contents, id, scopeArg, occurrence, patch) {
4911
5350
  const original = contents.events.find((e) => e.id === id);
4912
- if (!original) throw new RefusalError(`no event with id "${id}"`);
5351
+ if (!original) {
5352
+ if (contents.tasks.some((t) => t.id === id)) {
5353
+ throw new RefusalError(`id "${id}" is a task \u2014 use update_task, plan_task or toggle_done.`);
5354
+ }
5355
+ throw notFound(contents, id, "event");
5356
+ }
4913
5357
  refuseIfFeed(original, contents.calendars);
4914
5358
  const scope = requireScope(original, scopeArg);
4915
5359
  const clock = contents.settings.clock;
@@ -4951,7 +5395,10 @@ function performUpdateEvent(contents, id, scopeArg, occurrence, patch) {
4951
5395
  }
4952
5396
  function performDeleteEvent(contents, id, scopeArg, occurrence) {
4953
5397
  const original = contents.events.find((e) => e.id === id);
4954
- if (!original) throw new RefusalError(`no event with id "${id}"`);
5398
+ if (!original) {
5399
+ if (contents.tasks.some((t) => t.id === id)) return performDeleteTask(contents, id);
5400
+ throw notFound(contents, id, "event");
5401
+ }
4955
5402
  refuseIfFeed(original, contents.calendars);
4956
5403
  const scope = requireScope(original, scopeArg);
4957
5404
  const title = original.title || "Untitled";
@@ -4992,17 +5439,37 @@ function performDeleteEvent(contents, id, scopeArg, occurrence) {
4992
5439
  message: `Deleted "${title}" from ${dayKey(new Date(at))} on.`
4993
5440
  };
4994
5441
  }
4995
- function performToggleDone(contents, id, occurrence) {
4996
- const original = contents.events.find((e) => e.id === id);
4997
- if (!original) throw new RefusalError(`no event with id "${id}"`);
4998
- if (!original.task) throw new RefusalError(`"${original.title || "Untitled"}" is not a task`);
4999
- refuseIfFeed(original, contents.calendars);
5000
- const at = resolveInstant(original, occurrence);
5001
- const wasDone = original.doneKeys.includes(instantKey(at));
5002
- const events = applyToggleDone(contents.events, original.id, at, Date.now());
5442
+ function performDeleteTask(contents, id) {
5443
+ const task = contents.tasks.find((t) => t.id === id);
5444
+ if (!task) {
5445
+ if (contents.events.some((e) => e.id === id)) {
5446
+ throw new RefusalError(`"${id}" is an event, not a task \u2014 use delete_event`);
5447
+ }
5448
+ throw notFound(contents, id, "task");
5449
+ }
5450
+ return {
5451
+ mutated: {
5452
+ ...carried(contents),
5453
+ tasks: contents.tasks.filter((t) => t.id !== id),
5454
+ graves: [...contents.graves, { id, at: Date.now() }]
5455
+ },
5456
+ message: `Deleted the task "${task.title || "Untitled"}".`
5457
+ };
5458
+ }
5459
+ function performToggleDone(contents, id, _occurrence) {
5460
+ const original = contents.tasks.find((t) => t.id === id);
5461
+ if (!original) {
5462
+ if (contents.events.some((e) => e.id === id)) {
5463
+ throw new RefusalError(`"${id}" is an event, not a task`);
5464
+ }
5465
+ throw notFound(contents, id, "task");
5466
+ }
5467
+ const tasks = contents.tasks.map(
5468
+ (t) => t.id === id ? { ...t, done: !t.done, updatedAt: Date.now() } : t
5469
+ );
5003
5470
  return {
5004
- mutated: { ...carried(contents), events },
5005
- message: `${wasDone ? "Reopened" : "Completed"} "${original.title || "Untitled"}" on ${dayKey(new Date(at))}.`
5471
+ mutated: { ...carried(contents), tasks },
5472
+ message: `${original.done ? "Reopened" : "Completed"} "${original.title || "Untitled"}".`
5006
5473
  };
5007
5474
  }
5008
5475
  function performCreateCalendar(contents, name, color, icon) {
@@ -5079,13 +5546,12 @@ function eventFieldsShape() {
5079
5546
  return {
5080
5547
  title: z.string().optional(),
5081
5548
  date: dateKey("YYYY-MM-DD, local.").optional(),
5082
- start: z.string().describe("HH:MM, 24-hour local clock. Required for a timed event or a task.").optional(),
5549
+ start: z.string().describe("HH:MM, 24-hour local clock. Required for a timed event.").optional(),
5083
5550
  end: z.string().describe(
5084
5551
  "HH:MM for a timed event, or YYYY-MM-DD (inclusive) for a multi-day all-day event. Alternative to duration."
5085
5552
  ).optional(),
5086
5553
  duration: z.number().describe("Minutes \u2014 an alternative to end, for a timed event.").optional(),
5087
5554
  allDay: z.boolean().describe("Forces an all-day event. Inferred when start, end and duration are all omitted.").optional(),
5088
- task: z.boolean().describe("A dated to-do (one moment, not a span) rather than an event.").optional(),
5089
5555
  calendarId: z.string().describe(
5090
5556
  "Defaults to the user's default calendar. Must not be a subscription \u2014 those are read-only."
5091
5557
  ).optional(),
@@ -5094,29 +5560,65 @@ function eventFieldsShape() {
5094
5560
  location: z.string().optional(),
5095
5561
  url: z.string().optional(),
5096
5562
  notes: z.string().optional(),
5563
+ zone: z.string().describe(
5564
+ `IANA zone the date and times are written in, e.g. "Asia/Seoul" \u2014 for a deadline abroad. Stored on the event so every surface prints both clocks; timed events only. On update, "" clears it back to floating (local) time, and patched times are read on the event's own zone.`
5565
+ ).optional(),
5566
+ remindLead: z.union([z.number().int().min(0).max(REMIND_LEAD_MAX), z.enum(["inherit", "silent"])]).describe(
5567
+ 'Reminder lead: minutes before the start (0 = as it starts), "silent" (never fires), or "inherit" (follow the calendar, then the setting \u2014 the default).'
5568
+ ).optional(),
5097
5569
  recurrence: recurrenceSchema.optional()
5098
5570
  };
5099
5571
  }
5572
+ var upsertKeySchema = z.string().regex(/^[A-Za-z0-9._:-]{1,64}$/, "letters, digits, . _ : -, up to 64").describe(
5573
+ "Caller-chosen external key for idempotent upsert: the same key always names the same event, so re-running a mirror updates in place instead of duplicating. Omitted, every call creates a fresh event."
5574
+ );
5100
5575
  var createEventFieldsSchema = z.strictObject({
5101
5576
  ...eventFieldsShape(),
5102
- date: dateKey("YYYY-MM-DD, local.")
5577
+ date: dateKey("YYYY-MM-DD, local."),
5578
+ key: upsertKeySchema.optional()
5103
5579
  });
5104
5580
  var patchEventFieldsSchema = z.strictObject(eventFieldsShape());
5581
+ function taskFieldsShape() {
5582
+ return {
5583
+ title: z.string().optional(),
5584
+ date: dateKey("YYYY-MM-DD, local \u2014 the due day.").optional(),
5585
+ time: z.string().nullable().describe(
5586
+ "HH:MM due time, 24-hour local clock. Omit for a deadline that is a date with no time on it; null (on update) clears an existing due time back to that."
5587
+ ).optional(),
5588
+ estimate: z.number().nullable().describe("Minutes of work this looks like. null (on update) clears it back to unknown.").optional(),
5589
+ priority: z.enum(["normal", "high", "urgent"]).optional(),
5590
+ calendarId: z.string().describe(
5591
+ "Defaults to the user's default calendar. Must not be a subscription \u2014 those are read-only."
5592
+ ).optional(),
5593
+ color: z.string().describe("#rrggbb. Empty follows the calendar's own colour.").optional(),
5594
+ icon: z.string().describe("A Phosphor icon name or one emoji. Empty follows the calendar's own mark.").optional(),
5595
+ notes: z.string().optional()
5596
+ };
5597
+ }
5598
+ var createTaskFieldsSchema = z.strictObject({
5599
+ ...taskFieldsShape(),
5600
+ date: dateKey("YYYY-MM-DD, local \u2014 the due day.")
5601
+ });
5602
+ var patchTaskFieldsSchema = z.strictObject(taskFieldsShape());
5105
5603
  var SCOPE_DESCRIPTION = 'Required when the target event repeats: "one" (just this occurrence), "future" (this one and everything after), or "all" (the whole series). Ignored when the event does not repeat.';
5106
5604
  var OCCURRENCE_DESCRIPTION = 'Which occurrence, as a YYYY-MM-DD date or a full "YYYY-MM-DDTHH:mm:ss" instant. Required with scope "one" or "future".';
5107
5605
  var scopeSchema = z.enum(["one", "future", "all"]).describe(SCOPE_DESCRIPTION);
5108
5606
  var occurrenceSchema = z.string().describe(OCCURRENCE_DESCRIPTION);
5109
- function registerTools(server, store3) {
5607
+ var calendarFilterSchema = z.string().describe("Scope to one calendar \u2014 its id, or a unique name prefix. Omit for every calendar.");
5608
+ function registerTools(server, store3, pin) {
5110
5609
  server.registerTool(
5111
5610
  "list_day",
5112
5611
  {
5113
5612
  description: "The agenda for one local day: recurrence expanded, all-day and timed occurrences listed separately, each with its id (for update_event/delete_event/toggle_done), its occurrence key, and \u2014 for a multi-day span \u2014 its Day N/M position.",
5114
- inputSchema: z.strictObject({ date: dateKey("YYYY-MM-DD, local.") }),
5613
+ inputSchema: z.strictObject({
5614
+ date: dateKey("YYYY-MM-DD, local."),
5615
+ calendar: calendarFilterSchema.optional()
5616
+ }),
5115
5617
  annotations: { readOnlyHint: true }
5116
5618
  },
5117
- async ({ date }) => guarded(async () => {
5619
+ async ({ date, calendar }) => guarded(async () => {
5118
5620
  const { contents, warning } = await store3.load();
5119
- return withWarning(listDay(contents, date), warning);
5621
+ return withWarning(listDay(scopedToCalendar(contents, calendar), date), warning);
5120
5622
  })
5121
5623
  );
5122
5624
  server.registerTool(
@@ -5125,28 +5627,33 @@ function registerTools(server, store3) {
5125
5627
  description: `The agenda across several local days, one section per day that has something on it. Capped at ${MAX_RANGE_DAYS} days of span and ${MAX_RANGE_OCCURRENCES} occurrences total \u2014 a wide range may come back truncated, which the response says.`,
5126
5628
  inputSchema: z.strictObject({
5127
5629
  from: dateKey("YYYY-MM-DD, local, inclusive."),
5128
- to: dateKey("YYYY-MM-DD, local, inclusive.")
5630
+ to: dateKey("YYYY-MM-DD, local, inclusive."),
5631
+ calendar: calendarFilterSchema.optional()
5129
5632
  }),
5130
5633
  annotations: { readOnlyHint: true }
5131
5634
  },
5132
- async ({ from, to }) => guarded(async () => {
5635
+ async ({ from, to, calendar }) => guarded(async () => {
5133
5636
  const { contents, warning } = await store3.load();
5134
- return withWarning(listRange(contents, from, to), warning);
5637
+ return withWarning(listRange(scopedToCalendar(contents, calendar), from, to), warning);
5135
5638
  })
5136
5639
  );
5137
5640
  server.registerTool(
5138
5641
  "search",
5139
5642
  {
5140
- description: "Ranked title/location/notes search over the user's own events (never a subscription's).",
5643
+ description: "Ranked title/location/notes search over the user's own events (never a subscription's). Each result names the field it matched when it was not the title.",
5141
5644
  inputSchema: z.strictObject({
5142
5645
  query: z.string(),
5143
- limit: z.number().int().min(1).describe("Default 20.").optional()
5646
+ limit: z.number().int().min(1).describe("Default 20.").optional(),
5647
+ calendar: calendarFilterSchema.optional()
5144
5648
  }),
5145
5649
  annotations: { readOnlyHint: true }
5146
5650
  },
5147
- async ({ query, limit }) => guarded(async () => {
5651
+ async ({ query, limit, calendar }) => guarded(async () => {
5148
5652
  const { contents, warning } = await store3.load();
5149
- return withWarning(searchEvents(contents, query, limit), warning);
5653
+ return withWarning(
5654
+ searchEvents(scopedToCalendar(contents, calendar), query, limit),
5655
+ warning
5656
+ );
5150
5657
  })
5151
5658
  );
5152
5659
  server.registerTool(
@@ -5157,13 +5664,17 @@ function registerTools(server, store3) {
5157
5664
  date: dateKey("YYYY-MM-DD, local."),
5158
5665
  minutes: z.number().describe("Minimum slot length, in minutes."),
5159
5666
  from: z.string().describe("HH:MM, local. Defaults to the user's waking-hours start.").optional(),
5160
- to: z.string().describe("HH:MM, local. Defaults to the user's waking-hours end.").optional()
5667
+ to: z.string().describe("HH:MM, local. Defaults to the user's waking-hours end.").optional(),
5668
+ calendar: calendarFilterSchema.optional()
5161
5669
  }),
5162
5670
  annotations: { readOnlyHint: true }
5163
5671
  },
5164
- async ({ date, minutes, from, to }) => guarded(async () => {
5672
+ async ({ date, minutes, from, to, calendar }) => guarded(async () => {
5165
5673
  const { contents, warning } = await store3.load();
5166
- return withWarning(freeSlots(contents, date, minutes, from, to), warning);
5674
+ return withWarning(
5675
+ freeSlots(scopedToCalendar(contents, calendar), date, minutes, from, to),
5676
+ warning
5677
+ );
5167
5678
  })
5168
5679
  );
5169
5680
  server.registerTool(
@@ -5204,7 +5715,7 @@ function registerTools(server, store3) {
5204
5715
  },
5205
5716
  async ({ text, date }) => guarded(
5206
5717
  () => store3.write((contents) => {
5207
- const outcome = performQuickAdd(contents, text, date);
5718
+ const outcome = performQuickAdd(contents, text, date, pin);
5208
5719
  return { result: outcome.mutated, summary: outcome.message };
5209
5720
  })
5210
5721
  )
@@ -5212,13 +5723,30 @@ function registerTools(server, store3) {
5212
5723
  server.registerTool(
5213
5724
  "create_event",
5214
5725
  {
5215
- description: "Creates an event from explicit fields, for anything quick_add's grammar cannot spell \u2014 a full recurrence, a specific calendar, notes. Every field is validated the same way an imported backup is.",
5726
+ description: "Creates an event from explicit fields, for anything quick_add's grammar cannot spell \u2014 a full recurrence, a specific calendar, notes, a foreign time zone, a reminder lead. Every field is validated the same way an imported backup is. `key` makes the call an idempotent upsert.",
5216
5727
  inputSchema: createEventFieldsSchema,
5217
5728
  annotations: { readOnlyHint: false, destructiveHint: false, idempotentHint: false }
5218
5729
  },
5219
5730
  async (input) => guarded(
5220
5731
  () => store3.write((contents) => {
5221
- const outcome = performCreateEvent(contents, input);
5732
+ const { key, ...fields } = input;
5733
+ const outcome = performCreateEvent(contents, fields, key, pin);
5734
+ return { result: outcome.mutated, summary: outcome.message };
5735
+ })
5736
+ )
5737
+ );
5738
+ server.registerTool(
5739
+ "create_events",
5740
+ {
5741
+ description: `Creates (or, with a per-record \`key\`, upserts) up to ${CREATE_BATCH_MAX} events in one write \u2014 one revision bump, all-or-nothing: any refused record refuses the whole batch naming which. The convergent way to mirror an external document into the calendar.`,
5742
+ inputSchema: z.strictObject({
5743
+ events: z.array(createEventFieldsSchema).min(1).max(CREATE_BATCH_MAX)
5744
+ }),
5745
+ annotations: { readOnlyHint: false, destructiveHint: false, idempotentHint: false }
5746
+ },
5747
+ async ({ events }) => guarded(
5748
+ () => store3.write((contents) => {
5749
+ const outcome = performCreateEvents(contents, events, pin);
5222
5750
  return { result: outcome.mutated, summary: outcome.message };
5223
5751
  })
5224
5752
  )
@@ -5260,13 +5788,27 @@ function registerTools(server, store3) {
5260
5788
  })
5261
5789
  )
5262
5790
  );
5791
+ server.registerTool(
5792
+ "delete_task",
5793
+ {
5794
+ description: "Deletes a task. The same removal delete_event performs on a task id \u2014 this name exists so the task verbs read as the set they are (create_task, update_task, delete_task).",
5795
+ inputSchema: z.strictObject({ id: z.string() }),
5796
+ annotations: { destructiveHint: true, idempotentHint: false }
5797
+ },
5798
+ async ({ id }) => guarded(
5799
+ () => store3.write((contents) => {
5800
+ const outcome = performDeleteTask(contents, id);
5801
+ return { result: outcome.mutated, summary: outcome.message };
5802
+ })
5803
+ )
5804
+ );
5263
5805
  server.registerTool(
5264
5806
  "toggle_done",
5265
5807
  {
5266
- description: "Ticks a task off, or back on, one occurrence at a time \u2014 never the whole series at once.",
5808
+ description: "Ticks a task off, or back on \u2014 a task has one deadline and is finished once.",
5267
5809
  inputSchema: z.strictObject({
5268
5810
  id: z.string(),
5269
- occurrence: z.string().describe("YYYY-MM-DD or a full instant. Required for a repeating task.").optional()
5811
+ occurrence: z.string().describe("Accepted for compatibility and ignored \u2014 a task is ticked whole.").optional()
5270
5812
  }),
5271
5813
  annotations: { destructiveHint: false, idempotentHint: false }
5272
5814
  },
@@ -5277,6 +5819,66 @@ function registerTools(server, store3) {
5277
5819
  })
5278
5820
  )
5279
5821
  );
5822
+ server.registerTool(
5823
+ "list_tasks",
5824
+ {
5825
+ description: "Every task, grouped the way the app's own sidebar groups them \u2014 Overdue, Due soon, Later, Done \u2014 soonest deadline first inside each group.",
5826
+ inputSchema: z.strictObject({}),
5827
+ annotations: { readOnlyHint: true }
5828
+ },
5829
+ async () => guarded(async () => {
5830
+ const { contents, warning } = await store3.load();
5831
+ return withWarning(listTasks(contents), warning);
5832
+ })
5833
+ );
5834
+ server.registerTool(
5835
+ "create_task",
5836
+ {
5837
+ description: "Creates a task: a piece of work with a deadline, distinct from an event. `date` is the due day; add `time` for a timed deadline, `estimate` so auto-schedule can size it.",
5838
+ inputSchema: createTaskFieldsSchema,
5839
+ annotations: { readOnlyHint: false, destructiveHint: false, idempotentHint: false }
5840
+ },
5841
+ async (input) => guarded(
5842
+ () => store3.write((contents) => {
5843
+ const outcome = performCreateTask(contents, input, pin);
5844
+ return { result: outcome.mutated, summary: outcome.message };
5845
+ })
5846
+ )
5847
+ );
5848
+ server.registerTool(
5849
+ "update_task",
5850
+ {
5851
+ description: "Patches an existing task. Every field in `patch` is optional and left alone when omitted; ticking is toggle_done's job and planning is plan_task's.",
5852
+ inputSchema: z.strictObject({ id: z.string(), patch: patchTaskFieldsSchema }),
5853
+ annotations: { destructiveHint: true, idempotentHint: false }
5854
+ },
5855
+ async ({ id, patch }) => guarded(
5856
+ () => store3.write((contents) => {
5857
+ const outcome = performUpdateTask(contents, id, patch);
5858
+ return { result: outcome.mutated, summary: outcome.message };
5859
+ })
5860
+ )
5861
+ );
5862
+ server.registerTool(
5863
+ "plan_task",
5864
+ {
5865
+ description: "Books a block of time for a task on the clock \u2014 the drag onto the dial, performed by the assistant. Length falls back to the task's estimate, then the default duration.",
5866
+ inputSchema: z.strictObject({
5867
+ id: z.string(),
5868
+ date: dateKey("YYYY-MM-DD, local \u2014 the day to work on it."),
5869
+ start: z.string().describe("HH:MM, 24-hour local clock."),
5870
+ end: z.string().describe("HH:MM. Alternative to duration.").optional(),
5871
+ duration: z.number().describe("Minutes \u2014 an alternative to end.").optional()
5872
+ }),
5873
+ annotations: { readOnlyHint: false, destructiveHint: false, idempotentHint: false }
5874
+ },
5875
+ async ({ id, date, start, end, duration }) => guarded(
5876
+ () => store3.write((contents) => {
5877
+ const outcome = performPlanTask(contents, id, date, start, end, duration);
5878
+ return { result: outcome.mutated, summary: outcome.message };
5879
+ })
5880
+ )
5881
+ );
5280
5882
  server.registerTool(
5281
5883
  "create_calendar",
5282
5884
  {
@@ -5295,6 +5897,18 @@ function registerTools(server, store3) {
5295
5897
  })
5296
5898
  )
5297
5899
  );
5900
+ server.registerTool(
5901
+ "sync_status",
5902
+ {
5903
+ description: "The document's envelope and shape \u2014 revision, format version, recorded zone, transport, and record counts (events, tasks, calendars, graves). The first tool to reach for when reasoning about sync, a lost record, or a revision that moved.",
5904
+ inputSchema: z.strictObject({}),
5905
+ annotations: { readOnlyHint: true }
5906
+ },
5907
+ async () => guarded(async () => {
5908
+ const { contents, warning } = await store3.load();
5909
+ return withWarning(syncStatus(contents, store3.transport), warning);
5910
+ })
5911
+ );
5298
5912
  }
5299
5913
 
5300
5914
  // mcp/index.ts
@@ -5304,6 +5918,10 @@ Usage:
5304
5918
  circal-mcp --file <path> Start the server against a mirror file
5305
5919
  circal-mcp --cloud Start the server against the cloud copy
5306
5920
  (sign in first with \`circal-mcp login\`)
5921
+ circal-mcp --calendar <c> Pin this connection's default calendar (an id or
5922
+ unique name prefix): writes that name none land
5923
+ on it instead of the user default. Optional,
5924
+ beside either transport
5307
5925
  circal-mcp login Sign in to the cloud copy and remember the
5308
5926
  derived key on this machine (interactive:
5309
5927
  email, emailed code, passphrase; asks circal.day
@@ -5317,6 +5935,7 @@ Usage:
5317
5935
 
5318
5936
  Environment:
5319
5937
  CIRCAL_FILE Mirror file path, same as --file
5938
+ CIRCAL_CALENDAR Default calendar for writes, same as --calendar
5320
5939
  CIRCAL_CLOUD_CREDENTIALS Where \`login\` keeps its record (default:
5321
5940
  the platform config dir, circal/cloud.json)
5322
5941
  CIRCAL_ALLOW_ZONE_MISMATCH Set to 1 to allow writes when this process's
@@ -5363,9 +5982,11 @@ if (argv.includes("--cloud")) {
5363
5982
  }
5364
5983
  store2 = fileStore({ path, env: process.env });
5365
5984
  }
5985
+ var calendarFlag = argv.indexOf("--calendar");
5986
+ var pinned = (calendarFlag >= 0 ? argv[calendarFlag + 1] : void 0) || process.env.CIRCAL_CALENDAR;
5366
5987
  function createServer() {
5367
5988
  const server = new McpServer({ name: "circal", version: "1.0.0" });
5368
- registerTools(server, store2);
5989
+ registerTools(server, store2, pinned);
5369
5990
  return server;
5370
5991
  }
5371
5992
  void serveStdio(createServer);