circal-mcp 0.1.0 → 0.2.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -1,9 +1,10 @@
1
1
  # circal-mcp
2
2
 
3
3
  A stdio [MCP](https://modelcontextprotocol.io) server that reads and writes
4
- circal's own mirror file — the `.json` a browser tab keeps in sync on disk
5
- through the File System Access API (see `src/lib/mirror.ts` and
6
- `src/lib/mirrorLink.ts`). It imports circal's own domain layer
4
+ circal's own mirror file — the `.json` a browser tab or the macOS app keeps
5
+ in sync on disk, through the File System Access API on the web and the
6
+ shell's own bridge in the app (see `src/lib/mirror.ts`, `src/lib/mirrorLink.ts`
7
+ and `mac/main.swift`'s `MirrorFile`). It imports circal's own domain layer
7
8
  (`src/lib/events.ts`, `recur.ts`, `quickadd.ts`, `mutate.ts`, `backup.ts`, …),
8
9
  so an agent and the app can never disagree about what a legal event, an
9
10
  occurrence, or a recurrence rule is.
@@ -18,10 +19,11 @@ again, the same as any second writer would.
18
19
 
19
20
  ## Quick start
20
21
 
21
- A `FileSystemFileHandle` (what the browser hands circal when the mirror is
22
- turned on) never exposes the full path it came from: that is deliberate
23
- browser privacy design, so circal cannot print it for you. `--find` searches
24
- the places a mirror file usually lands and does that work instead.
22
+ A `FileSystemFileHandle` (what a browser hands circal when the mirror is turned on there) never
23
+ exposes the full path it came from: that is deliberate browser privacy design, so circal cannot print
24
+ it for you. The macOS app does not have this problem — it mints and adopts a mirror at a fixed path
25
+ on first launch, no gesture required so `--find` checks that path first, by name, then falls back
26
+ to the browser case and searches the places a mirror file usually lands.
25
27
 
26
28
  ```sh
27
29
  npx -y circal-mcp --find
@@ -32,6 +34,22 @@ zone, revision) plus a ready-to-paste config block. Paste that block into
32
34
  your client below, swap the path if it picked up the wrong file, then
33
35
  restart the client.
34
36
 
37
+ > **Use a locally built bundle, not `npx circal-mcp`.** The published
38
+ > `circal-mcp@0.1.0` predates several fields in `src/lib` (`free`,
39
+ > `remindLead`, `organizer` and the guest pair on an event; `backupNudge`,
40
+ > `backupInterval`, `deviceZone`, `stampedZones` in settings). Every write
41
+ > round-trips the whole document through its reader, so it silently drops
42
+ > those nine keys — measured, same fixture, two bundles. A current server
43
+ > refuses to write a file whose format is newer than its own, so this cannot
44
+ > happen unnoticed any more, but the published bundle is still stale.
45
+ >
46
+ > ```sh
47
+ > pnpm build:mcp # -> mcp/dist/circal-mcp.mjs
48
+ > ```
49
+ >
50
+ > Then use `node /absolute/path/to/circal/mcp/dist/circal-mcp.mjs` wherever
51
+ > the examples below say `npx -y circal-mcp`.
52
+
35
53
  ### Claude Desktop
36
54
 
37
55
  Add to `claude_desktop_config.json`:
@@ -81,9 +99,9 @@ Add to `.cursor/mcp.json` (project) or `~/.cursor/mcp.json` (global):
81
99
  }
82
100
  ```
83
101
 
84
- No mirror file yet: turn it on from circal's Settings panel, then run
85
- `--find` again. Run `npx -y circal-mcp --help` for the full flag and
86
- environment variable list.
102
+ No mirror file yet: on the Mac app there is almost always one already (`--find` should have caught
103
+ it); in a browser, turn the mirror on from circal's Settings panel, then run `--find` again. Run
104
+ `npx -y circal-mcp --help` for the full flag and environment variable list.
87
105
 
88
106
  ## From source (contributors)
89
107
 
@@ -1460,8 +1460,9 @@ import { serveStdio } from "@modelcontextprotocol/server/stdio";
1460
1460
 
1461
1461
  // mcp/file.ts
1462
1462
  import { randomBytes } from "node:crypto";
1463
- import { readFile, rename, writeFile } from "node:fs/promises";
1463
+ import { open, readFile, rename, rm, stat } from "node:fs/promises";
1464
1464
  import { dirname, join } from "node:path";
1465
+ import { setTimeout as sleep } from "node:timers/promises";
1465
1466
 
1466
1467
  // src/lib/time.ts
1467
1468
  var MINUTES_PER_DAY = 1440;
@@ -2162,6 +2163,78 @@ function fuzzyScore(text, query) {
2162
2163
 
2163
2164
  // src/lib/events.ts
2164
2165
  var ICON_MAX = 16;
2166
+ var ICON_NAME_MAX = 48;
2167
+ function isIconName(icon) {
2168
+ return /^[a-z0-9-]+$/.test(icon);
2169
+ }
2170
+ function firstGrapheme(value) {
2171
+ const text = value.trim();
2172
+ if (!text) return "";
2173
+ if (typeof Intl.Segmenter === "function") {
2174
+ const [first] = new Intl.Segmenter(void 0, { granularity: "grapheme" }).segment(text);
2175
+ return first ? first.segment.slice(0, ICON_MAX) : "";
2176
+ }
2177
+ return [...text][0]?.slice(0, ICON_MAX) ?? "";
2178
+ }
2179
+ function readMark(value) {
2180
+ if (typeof value !== "string") return "";
2181
+ const mark = value.trim();
2182
+ if (mark === "") return "";
2183
+ if (isIconName(mark)) return mark.length <= ICON_NAME_MAX ? mark : "";
2184
+ return firstGrapheme(mark);
2185
+ }
2186
+ var REMIND_INHERIT = -1;
2187
+ var REMIND_SILENT = -2;
2188
+ var REMIND_LEAD_MAX = 40320;
2189
+ function readRemindLead(value) {
2190
+ if (value === REMIND_SILENT) return REMIND_SILENT;
2191
+ if (typeof value !== "number" || !Number.isInteger(value)) return REMIND_INHERIT;
2192
+ if (value < 0 || value > REMIND_LEAD_MAX) return REMIND_INHERIT;
2193
+ return value;
2194
+ }
2195
+ var PARTSTATS = ["needs-action", "accepted", "declined", "tentative"];
2196
+ var ATTENDEE_MAX = 32;
2197
+ var GUEST_NAME_MAX = 64;
2198
+ var GUEST_ADDRESS_MAX = 254;
2199
+ var ATTENDEE_COUNT_MAX = 9999;
2200
+ function readText(value, max) {
2201
+ return typeof value === "string" ? value.trim().slice(0, max) : "";
2202
+ }
2203
+ function readPartstat(value) {
2204
+ if (typeof value !== "string") return "needs-action";
2205
+ const lower = value.trim().toLowerCase();
2206
+ return PARTSTATS.includes(lower) ? lower : "needs-action";
2207
+ }
2208
+ function readPerson(value) {
2209
+ if (value === null || typeof value !== "object") return null;
2210
+ const record = value;
2211
+ const address = readText(record.address, GUEST_ADDRESS_MAX);
2212
+ if (!address) return null;
2213
+ return { name: readText(record.name, GUEST_NAME_MAX), address };
2214
+ }
2215
+ function readGuests(list, count) {
2216
+ const seen = /* @__PURE__ */ new Set();
2217
+ const people = [];
2218
+ if (Array.isArray(list)) {
2219
+ for (const entry of list) {
2220
+ const person = readPerson(entry);
2221
+ if (!person) continue;
2222
+ const key = person.address.toLowerCase();
2223
+ if (seen.has(key)) continue;
2224
+ seen.add(key);
2225
+ people.push({
2226
+ ...person,
2227
+ status: readPartstat(entry.status)
2228
+ });
2229
+ }
2230
+ }
2231
+ const attendees = people.slice(0, ATTENDEE_MAX);
2232
+ const claimed = typeof count === "number" && Number.isInteger(count) && count > 0 ? count : people.length;
2233
+ return {
2234
+ attendees,
2235
+ attendeeCount: Math.min(Math.max(claimed, people.length), ATTENDEE_COUNT_MAX)
2236
+ };
2237
+ }
2165
2238
  function overridesIn(events) {
2166
2239
  const out = /* @__PURE__ */ new Map();
2167
2240
  for (const event of events) {
@@ -2249,7 +2322,7 @@ function toggleDoneKey(keys, at) {
2249
2322
  }
2250
2323
  var DEFAULT_WAKING = { from: 8 * 60, to: 22 * 60 };
2251
2324
  function freeGaps(occurrences, minMinutes, from, to) {
2252
- const busy = occurrences.filter((o) => !o.event.allDay).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);
2325
+ 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);
2253
2326
  const gaps = [];
2254
2327
  let cursor = from;
2255
2328
  for (const block of busy) {
@@ -3098,6 +3171,13 @@ var DEFAULT_SETTINGS = {
3098
3171
  // a day here, not half of one, so an hour has exactly one place on it and
3099
3172
  // `13` is where `13` is. AM/PM is a preference, and it is one click away.
3100
3173
  clock: "24h",
3174
+ // Unknown until the first launch writes it, and written silently there: a
3175
+ // browser with nothing stored has no "before", and the question this field
3176
+ // exists to ask is only ever about a change.
3177
+ deviceZone: "",
3178
+ // Nothing stamped, which is every browser that has never travelled and
3179
+ // every one that answered "move".
3180
+ stampedZones: [],
3101
3181
  secondZone: "",
3102
3182
  showZoneName: true,
3103
3183
  showZoneOffset: true,
@@ -3178,6 +3258,14 @@ var DEFAULT_SETTINGS = {
3178
3258
  // Ten minutes. Long enough to walk somewhere, short enough that the thing
3179
3259
  // is still the next thing when it lands.
3180
3260
  remindLead: 10,
3261
+ // On. This is the one interruption in the app that defends against losing
3262
+ // everything rather than missing one thing, and the browsers it is aimed at
3263
+ // have no other warning — so unlike `remind` it earns the aggressive default.
3264
+ backupNudge: true,
3265
+ // Thirty days. A month is long enough that a working calendar has changed
3266
+ // enough to be worth saving again, short enough that a lost month is a loss
3267
+ // and not a catastrophe.
3268
+ backupInterval: 30,
3181
3269
  // An hour, which is what `DEFAULT_DURATION` has always been and what a
3182
3270
  // VEVENT with no DTEND is read as. This setting exists to be *changed* — a
3183
3271
  // day of thirty-minute meetings is a real working life — not to have a new
@@ -3231,7 +3319,19 @@ function draftEvent(day, startMin, endMin, calendarId, repeat = "never") {
3231
3319
  color: "",
3232
3320
  icon: "",
3233
3321
  task: false,
3234
- doneKeys: []
3322
+ doneKeys: [],
3323
+ // Busy: a gesture that named a time meant that time to be spoken for.
3324
+ free: false,
3325
+ // Inheriting, which is the only answer a gesture gives: the drag named a
3326
+ // time, not how much warning it wants about it.
3327
+ remindLead: REMIND_INHERIT,
3328
+ // Nobody, and no way to make it anybody: circal has no channel to send an
3329
+ // invitation on, so a draft has no guest list and the editor offers no
3330
+ // control that would make one. These fields only ever arrive from a file
3331
+ // somebody else wrote.
3332
+ organizer: null,
3333
+ attendees: [],
3334
+ attendeeCount: 0
3235
3335
  };
3236
3336
  }
3237
3337
 
@@ -3245,6 +3345,17 @@ function readSettings(value) {
3245
3345
  // version that has ever had the setting.
3246
3346
  clock: value.clock === "12h" || value.clock === "24h" ? value.clock : DEFAULT_SETTINGS.clock,
3247
3347
  secondZone: typeof value.secondZone === "string" ? value.secondZone : "",
3348
+ // The zone the browser that wrote this backup was in. Carried rather than
3349
+ // dropped, and deliberately not replaced with the reader's own: a Berlin
3350
+ // calendar restored in New York is exactly the journey the travel notice
3351
+ // exists to ask about, and the events in the file are floating. `sane()`
3352
+ // refuses a zone `Intl` cannot resolve; absent reads as a first run, which
3353
+ // asks nothing.
3354
+ deviceZone: typeof value.deviceZone === "string" ? value.deviceZone : "",
3355
+ // The zones that browser's "keep" answer stamped, so the row that offers
3356
+ // to take them off survives a restore with the events it is about. Strings
3357
+ // only here; `sane()` is what refuses one `Intl` cannot resolve.
3358
+ stampedZones: Array.isArray(value.stampedZones) ? value.stampedZones.filter((zone) => typeof zone === "string") : [],
3248
3359
  // The dial switches read absent-as-on: a backup written before they
3249
3360
  // existed came from a dial that was showing all of them.
3250
3361
  showZoneName: value.showZoneName !== false,
@@ -3309,6 +3420,12 @@ function readSettings(value) {
3309
3420
  moon: value.moon === true || typeof value.moonPhase === "string" && value.moonPhase !== "off",
3310
3421
  remind: value.remind === true,
3311
3422
  remindLead: typeof value.remindLead === "number" ? value.remindLead : DEFAULT_SETTINGS.remindLead,
3423
+ // Absent-as-on, like the dial switches: a backup written before the nudge
3424
+ // existed came from a build that could not have turned this data-safety
3425
+ // warning off, so it reads as the default rather than as a choice.
3426
+ backupNudge: value.backupNudge !== false,
3427
+ // Types only: `sane()` pins an interval off the list to the default.
3428
+ backupInterval: typeof value.backupInterval === "number" ? value.backupInterval : DEFAULT_SETTINGS.backupInterval,
3312
3429
  // What a new event starts as. `sane()` clamps the length; the calendar is
3313
3430
  // an id and is deliberately not checked against the backup's own
3314
3431
  // calendars — `defaultCalendarId` resolves it at the moment it is used,
@@ -3363,12 +3480,33 @@ function readEvent(value) {
3363
3480
  seriesId: typeof value.seriesId === "string" ? value.seriesId : "",
3364
3481
  seriesKey: typeof value.seriesKey === "string" ? value.seriesKey : "",
3365
3482
  color: typeof value.color === "string" ? value.color : "",
3366
- icon: typeof value.icon === "string" ? value.icon.slice(0, ICON_MAX) : "",
3483
+ // What a legal mark is lives in `events.ts`, next to the two vocabularies
3484
+ // it has to tell apart. This used to slice at one shared cap, which turned
3485
+ // ten of the 247 shipped names into strings matching no path.
3486
+ icon: readMark(value.icon),
3367
3487
  task,
3368
3488
  // The same instant-key check EXDATE gets, and the same reason: a key this
3369
3489
  // does not spell matches no occurrence, so it is a row of dead weight that
3370
3490
  // survives every future export.
3371
- doneKeys: readDoneKeys(value.doneKeys)
3491
+ doneKeys: readDoneKeys(value.doneKeys),
3492
+ // Anything other than a literal `true` is busy, which is what an event
3493
+ // exported before this field existed was. A truthy string in a
3494
+ // hand-edited blob must not quietly take a meeting off the day's total.
3495
+ free: value.free === true,
3496
+ // What a legal lead is lives in `remind.ts`, beside the three states it
3497
+ // has to distinguish: an unreadable one comes back inheriting, which is
3498
+ // what an event exported before this field existed already did.
3499
+ remindLead: readRemindLead(value.remindLead),
3500
+ // The one field on this model that arrives from a stranger with no natural
3501
+ // length, so this is where the caps are enforced rather than where they are
3502
+ // chosen: `readGuests` in `events.ts` owns the numbers, because a
3503
+ // subscription's ATTENDEE lines never pass through this file and the two
3504
+ // paths must not disagree about how big a guest list may be. The count
3505
+ // comes through so a restore prints the same "and 40 others" the import
3506
+ // did — clamped up to the list it labels, since a blob understating it
3507
+ // would print a negative remainder.
3508
+ organizer: readPerson(value.organizer),
3509
+ ...readGuests(value.attendees, value.attendeeCount)
3372
3510
  };
3373
3511
  }
3374
3512
  function readCalendar(value) {
@@ -3380,11 +3518,12 @@ function readCalendar(value) {
3380
3518
  id,
3381
3519
  name: typeof name === "string" && name ? name : id,
3382
3520
  color: typeof color === "string" && color ? color : "#8ecbff",
3383
- // Length is the whole check. An icon name this build does not carry
3384
- // renders as the dot rather than as nothing, so an unknown one is worth
3385
- // keeping — a backup restored into an older build and back again should
3386
- // not lose the mark — but an unbounded string here is a paste.
3387
- icon: typeof icon === "string" ? icon.slice(0, ICON_MAX) : "",
3521
+ // Same validator as an event's, same reason. An icon name this build does
3522
+ // not carry still renders as the dot rather than as nothing, so an unknown
3523
+ // one is worth keeping — a backup restored into an older build and back
3524
+ // again should not lose the mark — but an unbounded string is a paste, and
3525
+ // an over-long *name* is now dropped rather than cut into junk.
3526
+ icon: readMark(icon),
3388
3527
  visible: value.visible !== false,
3389
3528
  ...feedUrl ? { feed: { url: feedUrl, fetchedAt: 0, error: "" } } : {}
3390
3529
  };
@@ -3447,7 +3586,11 @@ function readMirrorFile(text) {
3447
3586
  // A plain `.json` backup has no zone. Absent means "written by something
3448
3587
  // that did not say", which the reader treats as agreeing with it — the
3449
3588
  // alternative is refusing to open every backup ever exported.
3450
- zone: typeof envelope.zone === "string" ? envelope.zone : ""
3589
+ zone: typeof envelope.zone === "string" ? envelope.zone : "",
3590
+ // Absent reads as this build's own, for the reason `zone` does: a plain
3591
+ // `.json` backup handed to the mirror is a case the shared format exists
3592
+ // to allow, and refusing every export ever taken would be the wrong trade.
3593
+ version: typeof envelope.version === "number" && Number.isFinite(envelope.version) ? envelope.version : MIRROR_VERSION
3451
3594
  };
3452
3595
  }
3453
3596
 
@@ -3474,9 +3617,30 @@ async function readMirror(path2) {
3474
3617
  return contents;
3475
3618
  }
3476
3619
  async function atomicWrite(path2, body) {
3477
- const tmp = join(dirname(path2), `.circal-mcp-${randomBytes(6).toString("hex")}.tmp`);
3478
- await writeFile(tmp, body, "utf8");
3479
- await rename(tmp, path2);
3620
+ const dir = dirname(path2);
3621
+ const tmp = join(dir, `.circal-mcp-${randomBytes(6).toString("hex")}.tmp`);
3622
+ try {
3623
+ const file = await open(tmp, "w");
3624
+ try {
3625
+ await file.writeFile(body, "utf8");
3626
+ await file.sync();
3627
+ } finally {
3628
+ await file.close();
3629
+ }
3630
+ await rename(tmp, path2);
3631
+ } catch (error) {
3632
+ await rm(tmp, { force: true });
3633
+ throw error;
3634
+ }
3635
+ try {
3636
+ const entry = await open(dir, "r");
3637
+ try {
3638
+ await entry.sync();
3639
+ } finally {
3640
+ await entry.close();
3641
+ }
3642
+ } catch {
3643
+ }
3480
3644
  }
3481
3645
  function zoneWarning(contents) {
3482
3646
  const here = localZone();
@@ -3491,28 +3655,78 @@ function assertZoneAllowsMutation(contents, env) {
3491
3655
  `Refusing to write: the file's zone (${contents.zone}) does not match this server's (${here}) \u2014 a write here could land on the wrong day there. Set CIRCAL_ALLOW_ZONE_MISMATCH=1 to write anyway.`
3492
3656
  );
3493
3657
  }
3658
+ function assertVersionAllowsMutation(contents) {
3659
+ if (contents.version <= MIRROR_VERSION) return;
3660
+ throw new RefusalError(
3661
+ `Refusing to write: the file's format is version ${contents.version} and this server understands ${MIRROR_VERSION}. Writing it would drop every field this build has no name for. Rebuild circal-mcp from the matching source (pnpm build:mcp) and retry.`
3662
+ );
3663
+ }
3494
3664
  async function loadForRead(ctx2) {
3495
3665
  const contents = await readMirror(ctx2.path);
3496
3666
  return { contents, warning: zoneWarning(contents) };
3497
3667
  }
3498
- async function casWriteOnce(ctx2, mutate) {
3499
- for (let attempt = 0; attempt < 2; attempt++) {
3500
- const before = await readMirror(ctx2.path);
3501
- assertZoneAllowsMutation(before, ctx2.env);
3502
- const { result, summary } = mutate(before);
3503
- const after = await readMirror(ctx2.path);
3504
- if (after.rev !== before.rev) {
3505
- if (attempt === 1) {
3506
- throw new Error(
3507
- `The file moved under this write: read at rev ${before.rev}, still found rev ${after.rev} after retrying once. Something else is writing it \u2014 try again.`
3508
- );
3668
+ var LOCK_STALE_MS = 1e4;
3669
+ var LOCK_WAIT_MS = 2e3;
3670
+ var LOCK_POLL_MS = 100;
3671
+ async function acquireLock(path2) {
3672
+ const lock = `${path2}.lock`;
3673
+ const deadline = Date.now() + LOCK_WAIT_MS;
3674
+ for (; ; ) {
3675
+ try {
3676
+ const file = await open(lock, "wx");
3677
+ try {
3678
+ await file.writeFile(`${process.pid} ${(/* @__PURE__ */ new Date()).toISOString()}
3679
+ `, "utf8");
3680
+ } finally {
3681
+ await file.close();
3682
+ }
3683
+ return lock;
3684
+ } catch (error) {
3685
+ if (error.code !== "EEXIST") throw error;
3686
+ }
3687
+ try {
3688
+ if (Date.now() - (await stat(lock)).mtimeMs > LOCK_STALE_MS) {
3689
+ await rm(lock, { force: true });
3690
+ continue;
3509
3691
  }
3692
+ } catch {
3510
3693
  continue;
3511
3694
  }
3512
- await atomicWrite(ctx2.path, serialiseMirror(result, after.rev + 1, after.zone || localZone()));
3513
- return summary;
3695
+ if (Date.now() >= deadline) {
3696
+ throw new Error(
3697
+ `Another writer holds ${lock}. If nothing else is writing this calendar, delete that file and retry.`
3698
+ );
3699
+ }
3700
+ await sleep(LOCK_POLL_MS);
3701
+ }
3702
+ }
3703
+ async function casWriteOnce(ctx2, mutate) {
3704
+ const lock = await acquireLock(ctx2.path);
3705
+ try {
3706
+ for (let attempt = 0; attempt < 2; attempt++) {
3707
+ const before = await readMirror(ctx2.path);
3708
+ assertVersionAllowsMutation(before);
3709
+ assertZoneAllowsMutation(before, ctx2.env);
3710
+ const { result, summary } = mutate(before);
3711
+ const after = await readMirror(ctx2.path);
3712
+ if (after.rev !== before.rev) {
3713
+ if (attempt === 1) {
3714
+ throw new Error(
3715
+ `The file moved under this write: read at rev ${before.rev}, still found rev ${after.rev} after retrying once. Something else is writing it \u2014 try again.`
3716
+ );
3717
+ }
3718
+ continue;
3719
+ }
3720
+ await atomicWrite(
3721
+ ctx2.path,
3722
+ serialiseMirror(result, after.rev + 1, after.zone || localZone())
3723
+ );
3724
+ return summary;
3725
+ }
3726
+ throw new Error("unreachable");
3727
+ } finally {
3728
+ await rm(lock, { force: true });
3514
3729
  }
3515
- throw new Error("unreachable");
3516
3730
  }
3517
3731
  var writeQueue = Promise.resolve();
3518
3732
  function casWrite(ctx2, mutate) {
@@ -3525,7 +3739,7 @@ function casWrite(ctx2, mutate) {
3525
3739
  }
3526
3740
 
3527
3741
  // mcp/find.ts
3528
- import { readFile as readFile2, readdir, stat } from "node:fs/promises";
3742
+ import { readFile as readFile2, readdir, stat as stat2 } from "node:fs/promises";
3529
3743
  import { homedir } from "node:os";
3530
3744
  import { join as join2 } from "node:path";
3531
3745
  var SKIP_DIR_NAMES = { node_modules: true, Library: true, ".git": true };
@@ -3542,6 +3756,9 @@ function roots(home) {
3542
3756
  join2(home, "Sync")
3543
3757
  ];
3544
3758
  }
3759
+ function appDefault(home) {
3760
+ return join2(home, "Library", "Application Support", "circal", "calendar.json");
3761
+ }
3545
3762
  async function jsonFilesUnder(root) {
3546
3763
  const found = [];
3547
3764
  const top = await listDir(root);
@@ -3569,7 +3786,7 @@ async function listDir(path2) {
3569
3786
  }
3570
3787
  async function asMirror(path2) {
3571
3788
  try {
3572
- const info = await stat(path2);
3789
+ const info = await stat2(path2);
3573
3790
  if (!info.isFile() || info.size > MAX_FILE_BYTES) return null;
3574
3791
  const text = await readFile2(path2, "utf8");
3575
3792
  const contents = readMirrorFile(text);
@@ -3581,6 +3798,7 @@ async function asMirror(path2) {
3581
3798
  async function runFind(home = homedir()) {
3582
3799
  const searchRoots = roots(home);
3583
3800
  const candidates = /* @__PURE__ */ new Set();
3801
+ candidates.add(appDefault(home));
3584
3802
  for (const root of searchRoots) {
3585
3803
  for (const path2 of await jsonFilesUnder(root)) candidates.add(path2);
3586
3804
  }
@@ -3592,6 +3810,7 @@ async function runFind(home = homedir()) {
3592
3810
  if (hits.length === 0) {
3593
3811
  console.log("No circal mirror file found.\n");
3594
3812
  console.log("Searched these locations (their own files, and one level of subdirectories):");
3813
+ console.log(` ${appDefault(home)}`);
3595
3814
  for (const root of searchRoots) console.log(` ${root}`);
3596
3815
  console.log(
3597
3816
  "\nTurn the mirror on from circal's Settings panel to create a file, then run this again."
@@ -3755,7 +3974,8 @@ function resolveSpanForCreate(input, day, defaultDuration) {
3755
3974
  const at = atMinutes(day, parseTime(input.start, "start")).getTime();
3756
3975
  return { start: at, end: at, allDay: false, task: true };
3757
3976
  }
3758
- const noTimeGiven = input.start === void 0 && input.end === void 0 && input.duration === void 0;
3977
+ const endIsDay = input.end !== void 0 && DATE_KEY_RE.test(input.end);
3978
+ const noTimeGiven = input.start === void 0 && (input.end === void 0 || endIsDay) && input.duration === void 0;
3759
3979
  const allDay = input.allDay === true || input.allDay === void 0 && noTimeGiven;
3760
3980
  if (allDay) {
3761
3981
  const days = input.end ? Math.max(1, daysBetween(day, fromDayKey(input.end)) + 1) : 1;
@@ -3836,7 +4056,20 @@ function buildEventRecord(input, calendars, settings) {
3836
4056
  color: input.color ?? "",
3837
4057
  icon: input.icon ?? "",
3838
4058
  task: span.task,
3839
- doneKeys: []
4059
+ doneKeys: [],
4060
+ // Busy. There is no tool argument for this yet, and an agent asked to put
4061
+ // an hour in the diary means the hour to be spoken for.
4062
+ free: false,
4063
+ // Inheriting, for the same reason: no tool argument names a lead, and an
4064
+ // event created with one of its own would be an agent quietly deciding
4065
+ // how much warning its user wants.
4066
+ remindLead: REMIND_INHERIT,
4067
+ // Nobody, for the reason there is no tool argument and never will be: an
4068
+ // agent has no channel to invite anyone on either, so a guest list it
4069
+ // assembled would be a list of people who were never asked.
4070
+ organizer: null,
4071
+ attendees: [],
4072
+ attendeeCount: 0
3840
4073
  });
3841
4074
  }
3842
4075
  function applyPatchToEvent(base, input, calendars, settings, anchorMs = base.start) {
@@ -3858,7 +4091,25 @@ function applyPatchToEvent(base, input, calendars, settings, anchorMs = base.sta
3858
4091
  color: input.color ?? base.color,
3859
4092
  icon: input.icon ?? base.icon,
3860
4093
  task: span.task,
3861
- doneKeys: base.doneKeys
4094
+ doneKeys: base.doneKeys,
4095
+ // Carried, like every other field a patch does not name: `validate` reads
4096
+ // the draft through `backup.readEvent`, which defaults an absent `free`
4097
+ // to busy — so leaving it out would quietly rebook a free event the
4098
+ // moment its title was edited.
4099
+ free: base.free,
4100
+ // Carried for exactly the reason above, and this is the field where it
4101
+ // costs most: an event the user silenced would start speaking again the
4102
+ // moment an assistant fixed a typo in its title, because `readEvent`
4103
+ // reads an absent lead as inheriting.
4104
+ remindLead: base.remindLead,
4105
+ // Carried, and this is the field where dropping it would be least
4106
+ // recoverable: the guest list is a record of a message somebody else sent,
4107
+ // and `readEvent` reads an absent one as no invitation at all — so an
4108
+ // assistant fixing a typo in the title would erase the only copy of who
4109
+ // had asked and who was coming.
4110
+ organizer: base.organizer,
4111
+ attendees: base.attendees,
4112
+ attendeeCount: base.attendeeCount
3862
4113
  });
3863
4114
  }
3864
4115
  function calendarName(calendars, id) {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "circal-mcp",
3
- "version": "0.1.0",
3
+ "version": "0.2.1",
4
4
  "type": "module",
5
5
  "description": "MCP server for circal — read and write your calendar through its mirror file",
6
6
  "license": "MIT",