circal-mcp 0.4.0 → 0.5.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -46,16 +46,25 @@ zone, revision) plus a ready-to-paste config block. Paste that block into
46
46
  your client below, swap the path if it picked up the wrong file, then
47
47
  restart the client.
48
48
 
49
- > **Use `circal-mcp@0.3.0` or newer** (`npx -y circal-mcp` resolves it).
50
- > The cautionary tale behind that floor: `0.1.0` predated nine fields in
51
- > `src/lib` (`free`, `remindLead`, `organizer` and the guest pair on an
52
- > event; `backupNudge`, `backupInterval`, `deviceZone`, `stampedZones` in
53
- > settings), and every write round-trips the whole document through its
54
- > reader, so it silently dropped all nine — measured, same fixture, two
55
- > bundles. A current server refuses to write a document whose format is
56
- > newer than its own, so a stale bundle can no longer damage one unnoticed;
57
- > it will simply refuse. Contributors run against their own build with
58
- > `pnpm build:mcp` → `node mcp/dist/circal-mcp.mjs`.
49
+ > **Use `circal-mcp@0.5.0` or newer** (`npx -y circal-mcp` resolves it).
50
+ > The cautionary tale behind that floor: `0.1.0` predated ten fields in
51
+ > `src/lib` (`free`, `travel`, `remindLead`, `organizer` and the guest pair
52
+ > on an event; `backupNudge`, `backupInterval`, `deviceZone`, `stampedZones`
53
+ > in settings), and every write round-trips the whole document through its
54
+ > reader, so it silently dropped all ten — measured, same fixture, two
55
+ > bundles.
56
+ >
57
+ > **The version gate does not save you from this, and the floor is why.** It
58
+ > refuses a document whose _envelope_ is newer than the server's, and an
59
+ > envelope version is bumped only when a whole key changes meaning — the
60
+ > `tasks` list going missing was one. A new field on an event is not: `travel`
61
+ > shipped at `MIRROR_VERSION` 3 like `free` before it, so a `0.4.1` bundle
62
+ > reads `3 <= 3`, writes happily, and erases the field from every event in the
63
+ > file. The gates stop only what they can see coming (`src/lib/mirror.ts`).
64
+ > Reads are always safe; it is writes that lose data. So: **republish and
65
+ > reinstall the server whenever a `CalEvent` or `Settings` field lands**, and
66
+ > raise this floor in the same commit. Contributors run against their own
67
+ > build with `pnpm build:mcp` → `node mcp/dist/circal-mcp.mjs`.
59
68
 
60
69
  ### Claude Desktop
61
70
 
@@ -2217,6 +2217,12 @@ function readRemindLead(value) {
2217
2217
  if (value < 0 || value > REMIND_LEAD_MAX) return REMIND_INHERIT;
2218
2218
  return value;
2219
2219
  }
2220
+ var TRAVEL_MAX = MINUTES_PER_DAY;
2221
+ function readTravel(value) {
2222
+ if (typeof value !== "number" || !Number.isInteger(value)) return 0;
2223
+ if (value < 0 || value > TRAVEL_MAX) return 0;
2224
+ return value;
2225
+ }
2220
2226
  var PARTSTATS = ["needs-action", "accepted", "declined", "tentative"];
2221
2227
  var ATTENDEE_MAX = 32;
2222
2228
  var GUEST_NAME_MAX = 64;
@@ -2279,6 +2285,10 @@ function readGraves(value) {
2279
2285
  }
2280
2286
  return [...latest].map(([id, at]) => ({ id, at })).sort((a, b) => b.at - a.at).slice(0, GRAVE_MAX);
2281
2287
  }
2288
+ function travelMinutes(occ) {
2289
+ if (occ.task) return 0;
2290
+ return Math.min(occ.event.travel, occ.startMin);
2291
+ }
2282
2292
  function overridesIn(events) {
2283
2293
  const out = /* @__PURE__ */ new Map();
2284
2294
  for (const event of events) {
@@ -2347,7 +2357,9 @@ function packLanes(occurrences, pad2 = 6) {
2347
2357
  return occurrences.map((occ) => {
2348
2358
  if (occ.event.allDay) return occ;
2349
2359
  if (occ.task && occ.startMin === occ.endMin) return occ;
2350
- let lane = laneEnds.findIndex((end) => end <= occ.startMin + pad2);
2360
+ const travel = travelMinutes(occ);
2361
+ const from = occ.startMin - travel;
2362
+ let lane = laneEnds.findIndex((end) => end <= from + (travel > 0 ? 0 : pad2));
2351
2363
  if (lane === -1) lane = laneEnds.length;
2352
2364
  laneEnds[lane] = occ.endMin;
2353
2365
  return { ...occ, lane };
@@ -2378,6 +2390,8 @@ function taskEvent(task, start, end) {
2378
2390
  color: task.color,
2379
2391
  icon: task.icon,
2380
2392
  free: false,
2393
+ // You do not commute to a deadline, and a plan is the work itself.
2394
+ travel: 0,
2381
2395
  remindLead: REMIND_INHERIT,
2382
2396
  organizer: null,
2383
2397
  attendees: [],
@@ -2441,8 +2455,13 @@ function taskOccurrences(tasks, calendars, day) {
2441
2455
  return out;
2442
2456
  }
2443
2457
  var DEFAULT_WAKING = { from: 8 * 60, to: 22 * 60 };
2458
+ function bookedBlock(occ) {
2459
+ if (occ.event.allDay || occ.event.free) return null;
2460
+ const start = occ.startMin - travelMinutes(occ);
2461
+ return occ.endMin > start ? { start, end: occ.endMin } : null;
2462
+ }
2444
2463
  function freeGaps(occurrences, minMinutes, from, to) {
2445
- const busy = occurrences.filter((o) => !o.event.allDay && !o.event.free).map((o) => ({ start: Math.max(o.startMin, from), end: Math.min(o.endMin, to) })).filter((b) => b.end > b.start).sort((a, b) => a.start - b.start);
2464
+ const busy = occurrences.map(bookedBlock).filter((block) => block !== null).map((block) => ({ start: Math.max(block.start, from), end: Math.min(block.end, to) })).filter((b) => b.end > b.start).sort((a, b) => a.start - b.start);
2446
2465
  const gaps = [];
2447
2466
  let cursor = from;
2448
2467
  for (const block of busy) {
@@ -3561,6 +3580,9 @@ function draftEvent(day, startMin, endMin, calendarId, repeat = "never") {
3561
3580
  icon: "",
3562
3581
  // Busy: a gesture that named a time meant that time to be spoken for.
3563
3582
  free: false,
3583
+ // None: a drag named when to be somewhere, never how long getting there
3584
+ // takes. Travel is typed, so a gesture cannot have meant one.
3585
+ travel: 0,
3564
3586
  // Inheriting, which is the only answer a gesture gives: the drag named a
3565
3587
  // time, not how much warning it wants about it.
3566
3588
  remindLead: REMIND_INHERIT,
@@ -3745,6 +3767,10 @@ function readEvent(value) {
3745
3767
  // exported before this field existed was. A truthy string in a
3746
3768
  // hand-edited blob must not quietly take a meeting off the day's total.
3747
3769
  free: value.free === true,
3770
+ // What a legal travel is lives in `events.ts`, beside the clipping and the
3771
+ // ceiling it shares bounds with: a hand-edited blob's `-5` or `"20"` comes
3772
+ // back `0`, which is what an event exported before this field existed was.
3773
+ travel: readTravel(value.travel),
3748
3774
  // What a legal lead is lives in `remind.ts`, beside the three states it
3749
3775
  // has to distinguish: an unreadable one comes back inheriting, which is
3750
3776
  // what an event exported before this field existed already did.
@@ -4890,6 +4916,9 @@ function buildEventRecord(input, calendars, settings, pin) {
4890
4916
  // Busy. There is no tool argument for this yet, and an agent asked to put
4891
4917
  // an hour in the diary means the hour to be spoken for.
4892
4918
  free: false,
4919
+ // No run-up unless the caller named one — an invented commute would be an
4920
+ // agent deciding when its user has to leave the house.
4921
+ travel: input.travel !== void 0 ? readTravel(input.travel) : 0,
4893
4922
  // Inheriting unless the caller named a lead of its own — an event created
4894
4923
  // with one it was not asked for would be an agent quietly deciding how
4895
4924
  // much warning its user wants.
@@ -4932,6 +4961,10 @@ function applyPatchToEvent(base, input, calendars, settings, anchorMs = base.sta
4932
4961
  // to busy — so leaving it out would quietly rebook a free event the
4933
4962
  // moment its title was edited.
4934
4963
  free: base.free,
4964
+ // Carried for `free`'s reason, one field over: `readEvent` defaults an
4965
+ // absent travel to none, so leaving it out would erase a commute the
4966
+ // moment an assistant fixed a typo in the title.
4967
+ travel: input.travel !== void 0 ? readTravel(input.travel) : base.travel,
4935
4968
  // Carried unless the patch names one — `readEvent` reads an absent lead
4936
4969
  // as inheriting, so leaving it out would un-silence an event the user
4937
4970
  // silenced the moment an assistant fixed a typo in its title.
@@ -5182,7 +5215,13 @@ function performQuickAdd(contents, text, date, pin) {
5182
5215
  };
5183
5216
  }
5184
5217
  var KEYED_ID_PREFIX = "x-";
5218
+ var UPSERT_KEY_RE = /^[A-Za-z0-9._:-]{1,64}$/;
5185
5219
  function keyedId(key) {
5220
+ if (!UPSERT_KEY_RE.test(key)) {
5221
+ throw new RefusalError(
5222
+ `"key" must be 1-64 characters of letters, digits and . _ : - \u2014 it becomes the record's id`
5223
+ );
5224
+ }
5186
5225
  return `${KEYED_ID_PREFIX}${key}`;
5187
5226
  }
5188
5227
  function performCreateEvent(contents, input, key, pin) {
@@ -5563,14 +5602,17 @@ function eventFieldsShape() {
5563
5602
  zone: z.string().describe(
5564
5603
  `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
5604
  ).optional(),
5605
+ travel: z.number().int().min(0).max(TRAVEL_MAX).describe(
5606
+ `Minutes of travel before the start, drawn as a run-up ahead of the event and booked as time spent, so nothing else is offered that window. 0 = none (the default), up to ${TRAVEL_MAX}.`
5607
+ ).optional(),
5566
5608
  remindLead: z.union([z.number().int().min(0).max(REMIND_LEAD_MAX), z.enum(["inherit", "silent"])]).describe(
5567
5609
  '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
5610
  ).optional(),
5569
5611
  recurrence: recurrenceSchema.optional()
5570
5612
  };
5571
5613
  }
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."
5614
+ var upsertKeySchema = z.string().regex(UPSERT_KEY_RE, "letters, digits, . _ : -, up to 64").describe(
5615
+ "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. A keyed create also overrides a prior deletion \u2014 the source of truth re-asserts \u2014 so to drop a record for good, remove it from the source, not just the calendar. Omitted, every call creates a fresh event."
5574
5616
  );
5575
5617
  var createEventFieldsSchema = z.strictObject({
5576
5618
  ...eventFieldsShape(),
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "circal-mcp",
3
- "version": "0.4.0",
3
+ "version": "0.5.0",
4
4
  "type": "module",
5
5
  "description": "MCP server for circal — read and write your calendar through its mirror file or its encrypted cloud copy",
6
6
  "license": "AGPL-3.0-only",