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 +28 -10
- package/dist/circal-mcp.mjs +284 -33
- package/package.json +1 -1
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
|
|
5
|
-
through the File System Access API
|
|
6
|
-
`src/lib/mirrorLink.ts`
|
|
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
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
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:
|
|
85
|
-
|
|
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
|
|
package/dist/circal-mcp.mjs
CHANGED
|
@@ -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,
|
|
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
|
-
|
|
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
|
-
//
|
|
3384
|
-
// renders as the dot rather than as nothing, so an unknown
|
|
3385
|
-
// keeping — a backup restored into an older build and back
|
|
3386
|
-
// not lose the mark — but an unbounded string
|
|
3387
|
-
|
|
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
|
|
3478
|
-
|
|
3479
|
-
|
|
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
|
-
|
|
3499
|
-
|
|
3500
|
-
|
|
3501
|
-
|
|
3502
|
-
|
|
3503
|
-
|
|
3504
|
-
|
|
3505
|
-
|
|
3506
|
-
|
|
3507
|
-
|
|
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
|
-
|
|
3513
|
-
|
|
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
|
|
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
|
|
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) {
|