@mgcrea/mcp-apple-core 1.15.0 → 1.17.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/dist/index.d.ts +220 -21
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +544 -78
- package/dist/index.js.map +1 -1
- package/package.json +1 -1
package/dist/index.js
CHANGED
|
@@ -1,9 +1,327 @@
|
|
|
1
|
+
import { connect } from "node:net";
|
|
1
2
|
import { accessSync, constants, readFileSync, statSync } from "node:fs";
|
|
2
3
|
import { StdioServerTransport } from "@modelcontextprotocol/sdk/server/stdio.js";
|
|
3
4
|
import { z } from "zod";
|
|
4
5
|
import { execFile } from "node:child_process";
|
|
5
6
|
import { createHash } from "node:crypto";
|
|
6
7
|
import { DatabaseSync } from "node:sqlite";
|
|
8
|
+
//#region src/errors.ts
|
|
9
|
+
/** Base class so `toFailure` can carry structured detail through in one branch. */
|
|
10
|
+
var AppleAutomationError = class extends Error {
|
|
11
|
+
name = "AppleAutomationError";
|
|
12
|
+
details;
|
|
13
|
+
constructor(message, details) {
|
|
14
|
+
super(message);
|
|
15
|
+
this.details = details;
|
|
16
|
+
}
|
|
17
|
+
};
|
|
18
|
+
/** The host process may not send Apple Events to the app (osascript -1743). */
|
|
19
|
+
var TccDeniedError = class extends AppleAutomationError {
|
|
20
|
+
name = "TccDeniedError";
|
|
21
|
+
constructor(surface) {
|
|
22
|
+
super(`Not authorized to control ${surface.appName}. Grant it in System Settings > Privacy & Security > Automation > (the app running this server) > ${surface.appName}, then restart the server. If no entry appears, the first attempt was denied before the prompt could be answered — run \`tccutil reset AppleEvents\` and try again.`);
|
|
23
|
+
}
|
|
24
|
+
};
|
|
25
|
+
/** The app is not running and the operation refuses to launch it. */
|
|
26
|
+
var AppNotRunningError = class extends AppleAutomationError {
|
|
27
|
+
name = "AppNotRunningError";
|
|
28
|
+
constructor(surface) {
|
|
29
|
+
super(`${surface.appName} is not running. Read tools do not launch it, because launching it steals focus and can start a sync. Open ${surface.appName} and retry.`);
|
|
30
|
+
}
|
|
31
|
+
};
|
|
32
|
+
/** The app was busy and refused the Apple Event (-1712). Retried once before surfacing. */
|
|
33
|
+
var AppBusyError = class extends AppleAutomationError {
|
|
34
|
+
name = "AppBusyError";
|
|
35
|
+
constructor(surface) {
|
|
36
|
+
super(`${surface.appName} is busy (probably syncing) and did not answer in time. Retry in a few seconds.`);
|
|
37
|
+
}
|
|
38
|
+
};
|
|
39
|
+
/** osascript exceeded its budget and was killed. */
|
|
40
|
+
var OsascriptTimeoutError = class extends AppleAutomationError {
|
|
41
|
+
name = "OsascriptTimeoutError";
|
|
42
|
+
constructor(timeoutMs, surface) {
|
|
43
|
+
super(`${surface.appName} did not answer within ${timeoutMs}ms. It may be mid-sync, or a permission prompt may be waiting on screen. Raise ${surface.envPrefix}_OSASCRIPT_TIMEOUT_MS if this is routine at your data size.`);
|
|
44
|
+
}
|
|
45
|
+
};
|
|
46
|
+
/**
|
|
47
|
+
* A write was attempted while writes are disabled. Under the house pattern write
|
|
48
|
+
* tools are not registered at all when `allowWrites` is off, so this is a
|
|
49
|
+
* belt-and-braces guard for the library surface, not a path tools can reach.
|
|
50
|
+
*/
|
|
51
|
+
var WritesDisabledError = class extends AppleAutomationError {
|
|
52
|
+
name = "WritesDisabledError";
|
|
53
|
+
constructor(surface) {
|
|
54
|
+
super(`Writes are disabled. Set ${surface.envPrefix}_ALLOW_WRITES=1 to enable the mutating tools.`);
|
|
55
|
+
}
|
|
56
|
+
};
|
|
57
|
+
/** A read-only index could not be opened, so the file lane is unavailable. */
|
|
58
|
+
var IndexUnavailableError = class extends AppleAutomationError {
|
|
59
|
+
name = "IndexUnavailableError";
|
|
60
|
+
};
|
|
61
|
+
/** The store's schema is not the one we know how to read. */
|
|
62
|
+
var SchemaDriftError = class extends AppleAutomationError {
|
|
63
|
+
name = "SchemaDriftError";
|
|
64
|
+
};
|
|
65
|
+
/** The server is not running on macOS, or osascript is missing. */
|
|
66
|
+
var PlatformError = class extends AppleAutomationError {
|
|
67
|
+
name = "PlatformError";
|
|
68
|
+
};
|
|
69
|
+
/** osascript exited 0 but did not produce the JSON envelope we require. */
|
|
70
|
+
var ProtocolError = class extends AppleAutomationError {
|
|
71
|
+
name = "ProtocolError";
|
|
72
|
+
};
|
|
73
|
+
/** A local precondition failed before anything was sent to the app. */
|
|
74
|
+
var PreconditionError = class extends AppleAutomationError {
|
|
75
|
+
name = "PreconditionError";
|
|
76
|
+
};
|
|
77
|
+
/**
|
|
78
|
+
* A date argument could not be read.
|
|
79
|
+
*
|
|
80
|
+
* Refusing is the whole point. The alternative — which is what Mail, Messages
|
|
81
|
+
* and Notes did — is `Date.parse` returning `NaN`, node:sqlite binding it as
|
|
82
|
+
* `NULL`, and the query matching nothing: an empty result that looks like an
|
|
83
|
+
* answer. The message lists the accepted forms because the caller is usually a
|
|
84
|
+
* model that guessed, and the fix is to show it the grammar.
|
|
85
|
+
*/
|
|
86
|
+
var InvalidDateError = class extends AppleAutomationError {
|
|
87
|
+
name = "InvalidDateError";
|
|
88
|
+
constructor(field, raw, reason) {
|
|
89
|
+
super(`Could not read ${field} from ${JSON.stringify(raw)}: ${reason}. Accepted: an ISO-8601 date "2026-08-20" (a whole day, local) or date-time "2026-08-20T09:00" (local unless it carries an offset), a signed offset like "+2d", "-3h", "+45m", "+1w", "today", "yesterday", "tomorrow 09:00", "next monday" or "last friday".`, {
|
|
90
|
+
field,
|
|
91
|
+
raw
|
|
92
|
+
});
|
|
93
|
+
}
|
|
94
|
+
};
|
|
95
|
+
//#endregion
|
|
96
|
+
//#region src/ax.ts
|
|
97
|
+
/**
|
|
98
|
+
* Borrowing the app's Accessibility driver.
|
|
99
|
+
*
|
|
100
|
+
* ## Why this exists at all
|
|
101
|
+
*
|
|
102
|
+
* `AXUIElementCopyAttributeValue` reads an attribute in **0.202 ms**. Reaching
|
|
103
|
+
* the same attribute through `osascript` + JXA + System Events costs **47.4 ms**
|
|
104
|
+
* — a 234x difference that is entirely transport, measured both ways on one
|
|
105
|
+
* machine in `docs/desktop.md`. Every Accessibility number this repo recorded
|
|
106
|
+
* before that document was taken through System Events, which is why the lane
|
|
107
|
+
* was closed three times on figures that were pricing the wrong thing.
|
|
108
|
+
*
|
|
109
|
+
* The native call cannot be made from here. Node has no binding for it, and
|
|
110
|
+
* more importantly the grant does not belong to node: TCC attaches
|
|
111
|
+
* Accessibility to the **responsible GUI ancestor**, so it is Cupertino.app
|
|
112
|
+
* that holds it. Hence a channel rather than a port — the app already runs the
|
|
113
|
+
* driver, and `ServerHost` will lend it for one bundle id.
|
|
114
|
+
*
|
|
115
|
+
* ## The second grant this removes, which matters more than the speed
|
|
116
|
+
*
|
|
117
|
+
* Driving a UI through System Events needs Automation-to-System-Events **on top
|
|
118
|
+
* of** Accessibility. Two grants, given in two different System Settings panes,
|
|
119
|
+
* to reach one window. The native driver needs the first and not the second.
|
|
120
|
+
*
|
|
121
|
+
* ## Absence is the fallback, not an error
|
|
122
|
+
*
|
|
123
|
+
* `CUPERTINO_AX_SOCKET` and `CUPERTINO_AX_FOR` are set by `ServerLocator` and by
|
|
124
|
+
* nothing else. A package installed from npm and run by hand has neither, and
|
|
125
|
+
* that is the supported case: these are published artifacts that must work with
|
|
126
|
+
* no app on the machine. `open()` returns null there, and the caller keeps
|
|
127
|
+
* whatever lane it had. It never throws to say "no app".
|
|
128
|
+
*
|
|
129
|
+
* ## Lazy, deliberately
|
|
130
|
+
*
|
|
131
|
+
* Nothing connects at construction. `SurfaceCatalog` probes servers to learn
|
|
132
|
+
* what they register, and a probe that opened a socket would make every capability
|
|
133
|
+
* scan depend on the host being ready to answer one.
|
|
134
|
+
*/
|
|
135
|
+
/** The handshake `ServerHost` expects, and the reply it sends back. */
|
|
136
|
+
const PROTOCOL = "cupertino/1";
|
|
137
|
+
const LENT_SURFACE = "desktop";
|
|
138
|
+
const OK = "ok";
|
|
139
|
+
/**
|
|
140
|
+
* How long one call may take.
|
|
141
|
+
*
|
|
142
|
+
* A walk carries its own five-second budget and the AX messaging timeout is two
|
|
143
|
+
* seconds per element, so the ceiling that matters is the app's, not this one's.
|
|
144
|
+
* This is only here so a host that stopped answering fails rather than hanging a
|
|
145
|
+
* tool call forever.
|
|
146
|
+
*/
|
|
147
|
+
const CALL_TIMEOUT_MS = 3e4;
|
|
148
|
+
/**
|
|
149
|
+
* The app is there and said no, or stopped answering.
|
|
150
|
+
*
|
|
151
|
+
* Distinct from the null `openAxChannel` returns, which means there is no app
|
|
152
|
+
* to ask — see the note there on why those two must not collapse.
|
|
153
|
+
*/
|
|
154
|
+
var AxChannelError = class extends AppleAutomationError {
|
|
155
|
+
name = "AxChannelError";
|
|
156
|
+
constructor(surface, message) {
|
|
157
|
+
super(`${surface.appName}: ${message}`, { surface: surface.appName });
|
|
158
|
+
}
|
|
159
|
+
};
|
|
160
|
+
/** What the host told us, when it refused the handshake. */
|
|
161
|
+
const refusal = (line) => line.startsWith("err ") ? line.slice(4) : `unexpected handshake reply '${line}'`;
|
|
162
|
+
/**
|
|
163
|
+
* Open the channel, or return null when this server is not hosted by the app.
|
|
164
|
+
*
|
|
165
|
+
* Null and a throw are different answers and the difference is the whole
|
|
166
|
+
* contract: null means "there is no app here, use your other lane", a throw
|
|
167
|
+
* means "there is an app and it said no", which a caller must report rather
|
|
168
|
+
* than paper over.
|
|
169
|
+
*/
|
|
170
|
+
const openAxChannel = (surface, env = process.env) => {
|
|
171
|
+
const socketPath = env.CUPERTINO_AX_SOCKET?.trim();
|
|
172
|
+
const identity = env.CUPERTINO_AX_FOR?.trim();
|
|
173
|
+
if (!socketPath || !identity) return null;
|
|
174
|
+
let socket = null;
|
|
175
|
+
let pending = Promise.resolve();
|
|
176
|
+
let nextId = 1;
|
|
177
|
+
const connectOnce = () => new Promise((resolve, reject) => {
|
|
178
|
+
const s = connect(socketPath);
|
|
179
|
+
let buffer = "";
|
|
180
|
+
const onError = (error) => {
|
|
181
|
+
s.destroy();
|
|
182
|
+
reject(new AxChannelError(surface, `cannot reach Cupertino: ${error.message}`));
|
|
183
|
+
};
|
|
184
|
+
s.once("error", onError);
|
|
185
|
+
s.setEncoding("utf8");
|
|
186
|
+
const onData = (chunk) => {
|
|
187
|
+
buffer += chunk;
|
|
188
|
+
const newline = buffer.indexOf("\n");
|
|
189
|
+
if (newline < 0) return;
|
|
190
|
+
const line = buffer.slice(0, newline).trim();
|
|
191
|
+
s.off("data", onData);
|
|
192
|
+
s.off("error", onError);
|
|
193
|
+
if (line !== OK) {
|
|
194
|
+
s.destroy();
|
|
195
|
+
reject(new AxChannelError(surface, refusal(line)));
|
|
196
|
+
return;
|
|
197
|
+
}
|
|
198
|
+
resolve(s);
|
|
199
|
+
};
|
|
200
|
+
s.on("data", onData);
|
|
201
|
+
s.write(`${PROTOCOL} ${LENT_SURFACE} for=${identity}\n`);
|
|
202
|
+
});
|
|
203
|
+
const request = async (payload) => {
|
|
204
|
+
socket ??= await connectOnce();
|
|
205
|
+
const live = socket;
|
|
206
|
+
const id = nextId++;
|
|
207
|
+
const line = `${JSON.stringify({
|
|
208
|
+
jsonrpc: "2.0",
|
|
209
|
+
id,
|
|
210
|
+
method: "tools/call",
|
|
211
|
+
params: {
|
|
212
|
+
name: payload.tool,
|
|
213
|
+
arguments: payload.args
|
|
214
|
+
}
|
|
215
|
+
})}\n`;
|
|
216
|
+
return new Promise((resolve, reject) => {
|
|
217
|
+
let buffer = "";
|
|
218
|
+
const timer = setTimeout(() => {
|
|
219
|
+
cleanup();
|
|
220
|
+
reject(new AxChannelError(surface, `${payload.tool} did not answer in ${CALL_TIMEOUT_MS}ms`));
|
|
221
|
+
}, CALL_TIMEOUT_MS);
|
|
222
|
+
const cleanup = () => {
|
|
223
|
+
clearTimeout(timer);
|
|
224
|
+
live.off("data", onData);
|
|
225
|
+
live.off("error", onError);
|
|
226
|
+
live.off("close", onClose);
|
|
227
|
+
};
|
|
228
|
+
const onError = (error) => {
|
|
229
|
+
cleanup();
|
|
230
|
+
reject(new AxChannelError(surface, `${payload.tool} failed: ${error.message}`));
|
|
231
|
+
};
|
|
232
|
+
const onClose = () => {
|
|
233
|
+
cleanup();
|
|
234
|
+
socket = null;
|
|
235
|
+
reject(new AxChannelError(surface, `Cupertino closed the connection during ${payload.tool}`));
|
|
236
|
+
};
|
|
237
|
+
const onData = (chunk) => {
|
|
238
|
+
buffer += chunk;
|
|
239
|
+
const newline = buffer.indexOf("\n");
|
|
240
|
+
if (newline < 0) return;
|
|
241
|
+
const raw = buffer.slice(0, newline);
|
|
242
|
+
cleanup();
|
|
243
|
+
let message;
|
|
244
|
+
try {
|
|
245
|
+
message = JSON.parse(raw);
|
|
246
|
+
} catch {
|
|
247
|
+
reject(new AxChannelError(surface, `unparseable reply to ${payload.tool}`));
|
|
248
|
+
return;
|
|
249
|
+
}
|
|
250
|
+
if (message.error) {
|
|
251
|
+
reject(new AxChannelError(surface, message.error.message ?? "unknown error"));
|
|
252
|
+
return;
|
|
253
|
+
}
|
|
254
|
+
const text = message.result?.content?.[0]?.text;
|
|
255
|
+
if (text === void 0) {
|
|
256
|
+
reject(new AxChannelError(surface, `empty reply to ${payload.tool}`));
|
|
257
|
+
return;
|
|
258
|
+
}
|
|
259
|
+
if (message.result?.isError === true) {
|
|
260
|
+
reject(new AxChannelError(surface, `${payload.tool}: ${text}`));
|
|
261
|
+
return;
|
|
262
|
+
}
|
|
263
|
+
try {
|
|
264
|
+
resolve(JSON.parse(text));
|
|
265
|
+
} catch {
|
|
266
|
+
resolve(text);
|
|
267
|
+
}
|
|
268
|
+
};
|
|
269
|
+
live.on("data", onData);
|
|
270
|
+
live.once("error", onError);
|
|
271
|
+
live.once("close", onClose);
|
|
272
|
+
live.write(line);
|
|
273
|
+
});
|
|
274
|
+
};
|
|
275
|
+
return {
|
|
276
|
+
call(payload) {
|
|
277
|
+
const result = pending.then(() => request(payload));
|
|
278
|
+
pending = result.then(() => void 0, () => void 0);
|
|
279
|
+
return result;
|
|
280
|
+
},
|
|
281
|
+
close() {
|
|
282
|
+
socket?.destroy();
|
|
283
|
+
socket = null;
|
|
284
|
+
}
|
|
285
|
+
};
|
|
286
|
+
};
|
|
287
|
+
/**
|
|
288
|
+
* Start watching. `check()` answers for the span since this call.
|
|
289
|
+
*
|
|
290
|
+
* Returns null from `check()` when the question could not be asked at all,
|
|
291
|
+
* which is not the same as "undisturbed" and must not be reported as it.
|
|
292
|
+
*/
|
|
293
|
+
const watchInterference = (channel) => {
|
|
294
|
+
const started = Date.now();
|
|
295
|
+
return { async check() {
|
|
296
|
+
const elapsedSeconds = (Date.now() - started) / 1e3;
|
|
297
|
+
try {
|
|
298
|
+
const secondsSinceInput = (await channel.call({
|
|
299
|
+
tool: "apple_desktop_user_activity",
|
|
300
|
+
args: {}
|
|
301
|
+
})).secondsSinceInput;
|
|
302
|
+
if (typeof secondsSinceInput !== "number") return null;
|
|
303
|
+
return {
|
|
304
|
+
disturbed: secondsSinceInput < elapsedSeconds,
|
|
305
|
+
secondsSinceInput,
|
|
306
|
+
elapsedSeconds
|
|
307
|
+
};
|
|
308
|
+
} catch {
|
|
309
|
+
return null;
|
|
310
|
+
}
|
|
311
|
+
} };
|
|
312
|
+
};
|
|
313
|
+
/**
|
|
314
|
+
* The sentence to append to a failure, or "" when nothing useful can be said.
|
|
315
|
+
*
|
|
316
|
+
* Deliberately says nothing when the machine was quiet: a failure that reads
|
|
317
|
+
* "nobody touched it" invites the reader to stop looking, and the point of this
|
|
318
|
+
* is to send them to the RIGHT place rather than to reassure them.
|
|
319
|
+
*/
|
|
320
|
+
const interferenceNote = (found) => {
|
|
321
|
+
if (!found?.disturbed) return "";
|
|
322
|
+
return ` Someone used this Mac ${found.secondsSinceInput.toFixed(1)}s ago, during the ${found.elapsedSeconds.toFixed(1)}s this took — a keystroke or click lands wherever the focus is, so that alone can explain this. Retry with the machine idle before looking further.`;
|
|
323
|
+
};
|
|
324
|
+
//#endregion
|
|
7
325
|
//#region src/build-info.ts
|
|
8
326
|
/**
|
|
9
327
|
* Read a package's own name and version at startup, so they are always accurate
|
|
@@ -521,74 +839,216 @@ const parseConfig = (schema, raw) => {
|
|
|
521
839
|
return parsed.data;
|
|
522
840
|
};
|
|
523
841
|
//#endregion
|
|
524
|
-
//#region src/
|
|
525
|
-
/**
|
|
526
|
-
|
|
527
|
-
|
|
528
|
-
|
|
529
|
-
|
|
530
|
-
|
|
531
|
-
|
|
532
|
-
|
|
842
|
+
//#region src/dates.ts
|
|
843
|
+
/**
|
|
844
|
+
* The one grammar every surface reads its date arguments with.
|
|
845
|
+
*
|
|
846
|
+
* ## Why it is here
|
|
847
|
+
*
|
|
848
|
+
* This shipped as three copies. Reminders wrote it, Calendar repeated it
|
|
849
|
+
* verbatim, and Safari repeated it again with signed offsets and `yesterday`
|
|
850
|
+
* added — that third copy's header says outright that three is the trigger for
|
|
851
|
+
* hoisting and that the copies had already diverged. Meanwhile Mail, Messages
|
|
852
|
+
* and Notes never got a grammar at all: they called `Date.parse` or
|
|
853
|
+
* `new Date()` directly, which is where the bugs were.
|
|
854
|
+
*
|
|
855
|
+
* They were not small bugs, and they were all the same shape — a date argument
|
|
856
|
+
* that produced a confident, plausible, wrong answer:
|
|
857
|
+
*
|
|
858
|
+
* - `Date.parse("last week")` is `NaN`, node:sqlite binds `NaN` as `NULL`,
|
|
859
|
+
* and `date_received >= NULL` matches nothing. A model that wrote a date in
|
|
860
|
+
* words got zero results and no error.
|
|
861
|
+
* - `new Date("2026-08-20")` is UTC midnight but `new Date("2026-08-20T00:00")`
|
|
862
|
+
* is LOCAL midnight, so two spellings a tool description treated as the
|
|
863
|
+
* same thing bounded a query an hour or five apart.
|
|
864
|
+
* - `"2026-02-30T09:00"` silently became 2 March, because the rollover guard
|
|
865
|
+
* existed on the date branch and not on the date-time one.
|
|
866
|
+
*
|
|
867
|
+
* ## The rule
|
|
868
|
+
*
|
|
869
|
+
* 1. `YYYY-MM-DD` names a **local calendar day**. As a start bound it is local
|
|
870
|
+
* 00:00 of that day; as an end bound it is local 00:00 of the NEXT day, and
|
|
871
|
+
* every consumer compares with `<`. So `to: "2026-08-20"` includes all of
|
|
872
|
+
* the 20th and nothing of the 21st. Resolving it to midnight of the same day
|
|
873
|
+
* is how an end bound quietly excludes the day it names.
|
|
874
|
+
* 2. `YYYY-MM-DD[T ]HH:MM[:SS]` is local wall-clock; with `Z` or `±HH:MM` it is
|
|
875
|
+
* the instant named. A timed bound is used as given, and is exclusive at the
|
|
876
|
+
* end edge like everything else.
|
|
877
|
+
* 3. Relative forms: `±N m|h|d|w`, `today` / `yesterday` / `tomorrow` with an
|
|
878
|
+
* optional `HH:MM`, and `next|last <weekday>`. Days and weeks are calendar
|
|
879
|
+
* arithmetic; hours and minutes are elapsed time. That split is not
|
|
880
|
+
* cosmetic — reversing it drifts every bound by an hour twice a year.
|
|
881
|
+
* 4. Anything else, an empty string, or an instant that does not exist
|
|
882
|
+
* (`2026-02-30`, `T25:00`, `T09:60`) throws `InvalidDateError`. **Nothing
|
|
883
|
+
* here ever returns `NaN`**, because a `NaN` reaches SQLite as `NULL` and a
|
|
884
|
+
* `NULL` bound is an empty result rather than a complaint.
|
|
885
|
+
*
|
|
886
|
+
* The forward-only surfaces inherit the backward forms too. A reminder cannot
|
|
887
|
+
* be due `-7d`, but accepting it costs nothing and refusing it in one surface
|
|
888
|
+
* while accepting it in another is exactly the drift this file replaces.
|
|
889
|
+
*/
|
|
890
|
+
const DAY_NAMES = [
|
|
891
|
+
"sunday",
|
|
892
|
+
"monday",
|
|
893
|
+
"tuesday",
|
|
894
|
+
"wednesday",
|
|
895
|
+
"thursday",
|
|
896
|
+
"friday",
|
|
897
|
+
"saturday"
|
|
898
|
+
];
|
|
899
|
+
const ISO_DATE = /^(\d{4})-(\d{2})-(\d{2})$/;
|
|
900
|
+
const ISO_DATETIME = /^(\d{4})-(\d{2})-(\d{2})[T ](\d{2}):(\d{2})(?::(\d{2}))?(?:\.\d+)?(Z|[+-]\d{2}:?\d{2})?$/;
|
|
901
|
+
const OFFSET = /^([+-])(\d+)\s*(m|min|mins|minute|minutes|h|hr|hrs|hour|hours|d|day|days|w|week|weeks)$/;
|
|
902
|
+
const DAY_WORD = /^(today|tomorrow|yesterday)(?:\s+(\d{1,2}):(\d{2}))?$/;
|
|
903
|
+
const RELATIVE_DAY = /^(next|last)\s+([a-z]+)(?:\s+(\d{1,2}):(\d{2}))?$/;
|
|
904
|
+
/** `+02:00` / `-05:00` / `Z` for a given instant, in the system zone. */
|
|
905
|
+
const offsetOf = (d) => {
|
|
906
|
+
const mins = -d.getTimezoneOffset();
|
|
907
|
+
if (mins === 0) return "Z";
|
|
908
|
+
const sign = mins < 0 ? "-" : "+";
|
|
909
|
+
const abs = Math.abs(mins);
|
|
910
|
+
return `${sign}${String(Math.floor(abs / 60)).padStart(2, "0")}:${String(abs % 60).padStart(2, "0")}`;
|
|
533
911
|
};
|
|
534
|
-
|
|
535
|
-
|
|
536
|
-
|
|
537
|
-
|
|
538
|
-
|
|
539
|
-
|
|
912
|
+
const pad = (n, w = 2) => String(n).padStart(w, "0");
|
|
913
|
+
/**
|
|
914
|
+
* Local wall-clock time rendered with its offset.
|
|
915
|
+
*
|
|
916
|
+
* Deliberately not `toISOString()`, which converts to UTC and would report a
|
|
917
|
+
* 09:00 bound as `07:00Z` — correct as an instant, but unreadable in a tool
|
|
918
|
+
* result whose purpose is confirming what the caller asked for.
|
|
919
|
+
*/
|
|
920
|
+
const toLocalIso = (d) => `${d.getFullYear()}-${pad(d.getMonth() + 1)}-${pad(d.getDate())}T${pad(d.getHours())}:${pad(d.getMinutes())}:${pad(d.getSeconds())}${offsetOf(d)}`;
|
|
921
|
+
/** Local midnight on the given calendar day. */
|
|
922
|
+
const startOfLocalDay = (d) => new Date(d.getFullYear(), d.getMonth(), d.getDate(), 0, 0, 0, 0);
|
|
923
|
+
/**
|
|
924
|
+
* The last representable instant of the given calendar day, local.
|
|
925
|
+
*
|
|
926
|
+
* Kept for surfaces that genuinely want an inclusive edge. It is NOT what an
|
|
927
|
+
* end bound resolves to — see `parseBound`.
|
|
928
|
+
*/
|
|
929
|
+
const endOfLocalDay = (d) => new Date(d.getFullYear(), d.getMonth(), d.getDate(), 23, 59, 59, 999);
|
|
930
|
+
/** Calendar-aware day arithmetic: same wall-clock time, n days later. */
|
|
931
|
+
const addLocalDays = (d, n) => {
|
|
932
|
+
const out = new Date(d.getTime());
|
|
933
|
+
out.setDate(out.getDate() + n);
|
|
934
|
+
return out;
|
|
540
935
|
};
|
|
541
|
-
|
|
542
|
-
|
|
543
|
-
|
|
544
|
-
|
|
545
|
-
|
|
546
|
-
|
|
936
|
+
const at = (day, hours, minutes) => new Date(day.getFullYear(), day.getMonth(), day.getDate(), hours, minutes, 0, 0);
|
|
937
|
+
const isLeap = (y) => y % 4 === 0 && y % 100 !== 0 || y % 400 === 0;
|
|
938
|
+
const daysInMonth = (y, mo) => [
|
|
939
|
+
31,
|
|
940
|
+
isLeap(y) ? 29 : 28,
|
|
941
|
+
31,
|
|
942
|
+
30,
|
|
943
|
+
31,
|
|
944
|
+
30,
|
|
945
|
+
31,
|
|
946
|
+
31,
|
|
947
|
+
30,
|
|
948
|
+
31,
|
|
949
|
+
30,
|
|
950
|
+
31
|
|
951
|
+
][mo - 1] ?? 0;
|
|
952
|
+
/**
|
|
953
|
+
* Reject an instant that does not exist, BEFORE building a Date from it.
|
|
954
|
+
*
|
|
955
|
+
* Checking afterwards does not work, for two reasons found the hard way. An
|
|
956
|
+
* out-of-range hour rolls the DAY over, so `2026-08-20T25:00` comes back as the
|
|
957
|
+
* 21st at 01:00 and a naive date comparison blames the day the caller got
|
|
958
|
+
* right. And a zoned string does not answer `NaN` at all:
|
|
959
|
+
* `new Date("2026-02-30T09:00Z")` is 2 March, so there is nothing to catch.
|
|
960
|
+
*
|
|
961
|
+
* Arithmetic on the components has neither problem and does not depend on the
|
|
962
|
+
* zone. The order matters — time first, because that is what rolled the date.
|
|
963
|
+
*/
|
|
964
|
+
const assertReal = (field, text, y, mo, d, hh = 0, mm = 0, ss = 0) => {
|
|
965
|
+
if (hh > 23 || mm > 59 || ss > 59) throw new InvalidDateError(field, text, `${pad(hh)}:${pad(mm)} is not a time`);
|
|
966
|
+
if (mo < 1 || mo > 12) throw new InvalidDateError(field, text, `there is no month ${pad(mo)}`);
|
|
967
|
+
if (d < 1 || d > daysInMonth(y, mo)) throw new InvalidDateError(field, text, `there is no day ${pad(d)} in month ${pad(mo)}`);
|
|
547
968
|
};
|
|
548
|
-
|
|
549
|
-
|
|
550
|
-
|
|
551
|
-
|
|
552
|
-
|
|
969
|
+
const result = (kind, when, raw) => ({
|
|
970
|
+
kind,
|
|
971
|
+
at: when,
|
|
972
|
+
iso: toLocalIso(when),
|
|
973
|
+
raw
|
|
974
|
+
});
|
|
975
|
+
/**
|
|
976
|
+
* Parse one date argument.
|
|
977
|
+
*
|
|
978
|
+
* @param field Named in the error, so a failure says *which* argument was bad.
|
|
979
|
+
* @param raw The caller's string.
|
|
980
|
+
* @param now Injected for hermetic tests, mirroring `loadConfig(env)`.
|
|
981
|
+
*/
|
|
982
|
+
const parseDate = (field, raw, now = /* @__PURE__ */ new Date()) => {
|
|
983
|
+
const text = String(raw ?? "").trim();
|
|
984
|
+
if (!text) throw new InvalidDateError(field, String(raw), "it is empty");
|
|
985
|
+
const lower = text.toLowerCase();
|
|
986
|
+
const dt = ISO_DATETIME.exec(text);
|
|
987
|
+
if (dt) {
|
|
988
|
+
const [, y, mo, d, hh, mm, ss, zone] = dt;
|
|
989
|
+
assertReal(field, text, Number(y), Number(mo), Number(d), Number(hh), Number(mm), Number(ss ?? "0"));
|
|
990
|
+
const when = zone ? new Date(text.replace(" ", "T")) : new Date(Number(y), Number(mo) - 1, Number(d), Number(hh), Number(mm), Number(ss ?? "0"), 0);
|
|
991
|
+
if (Number.isNaN(when.getTime())) throw new InvalidDateError(field, text, "it is not a real date");
|
|
992
|
+
return result("timed", when, text);
|
|
553
993
|
}
|
|
554
|
-
|
|
555
|
-
|
|
556
|
-
|
|
557
|
-
|
|
558
|
-
|
|
559
|
-
|
|
994
|
+
const only = ISO_DATE.exec(text);
|
|
995
|
+
if (only) {
|
|
996
|
+
const [, y, mo, d] = only;
|
|
997
|
+
assertReal(field, text, Number(y), Number(mo), Number(d));
|
|
998
|
+
const when = new Date(Number(y), Number(mo) - 1, Number(d), 0, 0, 0, 0);
|
|
999
|
+
if (Number.isNaN(when.getTime())) throw new InvalidDateError(field, text, "it is not a real date");
|
|
1000
|
+
return result("allDay", when, text);
|
|
1001
|
+
}
|
|
1002
|
+
const off = OFFSET.exec(lower);
|
|
1003
|
+
if (off) {
|
|
1004
|
+
const sign = off[1] === "-" ? -1 : 1;
|
|
1005
|
+
const n = Number(off[2]) * sign;
|
|
1006
|
+
const unit = String(off[3]);
|
|
1007
|
+
if (!Number.isFinite(n)) throw new InvalidDateError(field, text, "the amount is not a number");
|
|
1008
|
+
if (unit.startsWith("d")) return result("timed", addLocalDays(now, n), text);
|
|
1009
|
+
if (unit.startsWith("w")) return result("timed", addLocalDays(now, n * 7), text);
|
|
1010
|
+
const ms = unit.startsWith("h") ? n * 36e5 : n * 6e4;
|
|
1011
|
+
return result("timed", new Date(now.getTime() + ms), text);
|
|
560
1012
|
}
|
|
1013
|
+
const word = DAY_WORD.exec(lower);
|
|
1014
|
+
if (word) {
|
|
1015
|
+
const shift = word[1] === "tomorrow" ? 1 : word[1] === "yesterday" ? -1 : 0;
|
|
1016
|
+
const day = addLocalDays(now, shift);
|
|
1017
|
+
if (word[2] === void 0) return result("allDay", startOfLocalDay(day), text);
|
|
1018
|
+
const [hh, mm] = [Number(word[2]), Number(word[3])];
|
|
1019
|
+
if (hh > 23 || mm > 59) throw new InvalidDateError(field, text, `${hh}:${word[3]} is not a time`);
|
|
1020
|
+
return result("timed", at(day, hh, mm), text);
|
|
1021
|
+
}
|
|
1022
|
+
const rel = RELATIVE_DAY.exec(lower);
|
|
1023
|
+
if (rel) {
|
|
1024
|
+
const idx = DAY_NAMES.findIndex((n) => n === rel[2] || n.slice(0, 3) === rel[2]);
|
|
1025
|
+
if (idx === -1) throw new InvalidDateError(field, text, `"${rel[2]}" is not a day of the week`);
|
|
1026
|
+
const delta = rel[1] === "next" ? (idx - now.getDay() + 7) % 7 || 7 : -((now.getDay() - idx + 7) % 7 || 7);
|
|
1027
|
+
const day = addLocalDays(now, delta);
|
|
1028
|
+
if (rel[3] === void 0) return result("allDay", startOfLocalDay(day), text);
|
|
1029
|
+
const [hh, mm] = [Number(rel[3]), Number(rel[4])];
|
|
1030
|
+
if (hh > 23 || mm > 59) throw new InvalidDateError(field, text, `${hh}:${rel[4]} is not a time`);
|
|
1031
|
+
return result("timed", at(day, hh, mm), text);
|
|
1032
|
+
}
|
|
1033
|
+
throw new InvalidDateError(field, text, "it matches none of the accepted forms");
|
|
561
1034
|
};
|
|
562
1035
|
/**
|
|
563
|
-
*
|
|
564
|
-
*
|
|
565
|
-
*
|
|
1036
|
+
* Parse a bound for a range filter.
|
|
1037
|
+
*
|
|
1038
|
+
* A bare day names the WHOLE day, so which instant it resolves to depends on
|
|
1039
|
+
* which edge it is. The start edge is that day's local midnight. The end edge
|
|
1040
|
+
* is the NEXT day's local midnight, and every caller compares with `<` — which
|
|
1041
|
+
* makes the bound exclusive and the named day fully included.
|
|
1042
|
+
*
|
|
1043
|
+
* The alternative, 23:59:59.999, was what two of the three copies did, and it
|
|
1044
|
+
* is wrong twice: it drops the last millisecond of the day, and more to the
|
|
1045
|
+
* point it invites the `<=` that then drops the whole day when somebody writes
|
|
1046
|
+
* midnight instead.
|
|
566
1047
|
*/
|
|
567
|
-
|
|
568
|
-
|
|
569
|
-
|
|
570
|
-
|
|
571
|
-
}
|
|
572
|
-
};
|
|
573
|
-
/** A read-only index could not be opened, so the file lane is unavailable. */
|
|
574
|
-
var IndexUnavailableError = class extends AppleAutomationError {
|
|
575
|
-
name = "IndexUnavailableError";
|
|
576
|
-
};
|
|
577
|
-
/** The store's schema is not the one we know how to read. */
|
|
578
|
-
var SchemaDriftError = class extends AppleAutomationError {
|
|
579
|
-
name = "SchemaDriftError";
|
|
580
|
-
};
|
|
581
|
-
/** The server is not running on macOS, or osascript is missing. */
|
|
582
|
-
var PlatformError = class extends AppleAutomationError {
|
|
583
|
-
name = "PlatformError";
|
|
584
|
-
};
|
|
585
|
-
/** osascript exited 0 but did not produce the JSON envelope we require. */
|
|
586
|
-
var ProtocolError = class extends AppleAutomationError {
|
|
587
|
-
name = "ProtocolError";
|
|
588
|
-
};
|
|
589
|
-
/** A local precondition failed before anything was sent to the app. */
|
|
590
|
-
var PreconditionError = class extends AppleAutomationError {
|
|
591
|
-
name = "PreconditionError";
|
|
1048
|
+
const parseBound = (field, raw, edge, now = /* @__PURE__ */ new Date()) => {
|
|
1049
|
+
const parsed = parseDate(field, raw, now);
|
|
1050
|
+
if (parsed.kind !== "allDay") return parsed.at;
|
|
1051
|
+
return edge === "end" ? startOfLocalDay(addLocalDays(parsed.at, 1)) : startOfLocalDay(parsed.at);
|
|
592
1052
|
};
|
|
593
1053
|
//#endregion
|
|
594
1054
|
//#region src/tools.ts
|
|
@@ -1476,28 +1936,34 @@ const openReadOnly = (path, mode, opts = {}) => {
|
|
|
1476
1936
|
if (mode === "off") throw new IndexUnavailableError(`The index lane is disabled${opts.envVar ? ` (${opts.envVar}=off)` : ""}.`);
|
|
1477
1937
|
const attempts = mode === "auto" ? ["ro", "immutable"] : [mode === "ro" ? "ro" : "immutable"];
|
|
1478
1938
|
let lastError = null;
|
|
1479
|
-
for (const attempt of attempts)
|
|
1480
|
-
|
|
1481
|
-
|
|
1482
|
-
|
|
1483
|
-
|
|
1484
|
-
|
|
1485
|
-
|
|
1486
|
-
|
|
1487
|
-
|
|
1488
|
-
|
|
1489
|
-
|
|
1490
|
-
|
|
1491
|
-
|
|
1492
|
-
|
|
1493
|
-
|
|
1494
|
-
|
|
1495
|
-
|
|
1939
|
+
for (const attempt of attempts) {
|
|
1940
|
+
let db;
|
|
1941
|
+
try {
|
|
1942
|
+
const uri = toFileUri(path, attempt === "ro" ? "mode=ro" : "immutable=1");
|
|
1943
|
+
db = new DatabaseSync(uri, {
|
|
1944
|
+
readOnly: true,
|
|
1945
|
+
allowExtension: false
|
|
1946
|
+
});
|
|
1947
|
+
db.exec("PRAGMA query_only = 1");
|
|
1948
|
+
const validated = opts.validate?.(db);
|
|
1949
|
+
if (attempt === "immutable") opts.onFallback?.();
|
|
1950
|
+
return {
|
|
1951
|
+
db,
|
|
1952
|
+
mode: attempt,
|
|
1953
|
+
validated
|
|
1954
|
+
};
|
|
1955
|
+
} catch (err) {
|
|
1956
|
+
try {
|
|
1957
|
+
db?.close();
|
|
1958
|
+
} catch {}
|
|
1959
|
+
if (opts.fatal?.(err)) throw err;
|
|
1960
|
+
lastError = err;
|
|
1961
|
+
}
|
|
1496
1962
|
}
|
|
1497
1963
|
const message = lastError instanceof Error ? lastError.message : String(lastError);
|
|
1498
1964
|
throw new IndexUnavailableError(`Could not open ${opts.label ?? path} at ${path}: ${message}.${opts.hint ? ` ${opts.hint}` : ""}`);
|
|
1499
1965
|
};
|
|
1500
1966
|
//#endregion
|
|
1501
|
-
export { AppBusyError, AppNotRunningError, AppleAutomationError, BaseConfigSchema, CORE_DATA_EPOCH_OFFSET, IndexUnavailableError, OsascriptTimeoutError, PlatformError, PreconditionError, ProtocolError, RESOURCE_SCHEME, SchemaDriftError, TccDeniedError, WritesDisabledError, assertStaticScript, columnsOf, compact, confirmArg, createOsascriptRunner, describeAggregation, describeStore, detectEpoch, escapeLike, extractCode, fail, fingerprintSchema, groupByArg, inspectFile, limitArg, mapOsaError, ok, okText, openReadOnly, parseBool, parseConfig, parseIntOpt, parseList, project, promptArg, readPackageIdentity, registerSurfaceResources, registerWorkflowPrompt, requiredPromptArg, resolveLimit, runStdioServer, selectArg, surfaceUri, tableMap, toFailure, toFileUri, trimToolListing, trimmed, withBusyRetry, withLazyTools, withTrimmedListing, wrap, wrapResult };
|
|
1967
|
+
export { AppBusyError, AppNotRunningError, AppleAutomationError, AxChannelError, BaseConfigSchema, CORE_DATA_EPOCH_OFFSET, IndexUnavailableError, InvalidDateError, OsascriptTimeoutError, PlatformError, PreconditionError, ProtocolError, RESOURCE_SCHEME, SchemaDriftError, TccDeniedError, WritesDisabledError, addLocalDays, assertStaticScript, columnsOf, compact, confirmArg, createOsascriptRunner, describeAggregation, describeStore, detectEpoch, endOfLocalDay, escapeLike, extractCode, fail, fingerprintSchema, groupByArg, inspectFile, interferenceNote, limitArg, mapOsaError, ok, okText, openAxChannel, openReadOnly, parseBool, parseBound, parseConfig, parseDate, parseIntOpt, parseList, project, promptArg, readPackageIdentity, registerSurfaceResources, registerWorkflowPrompt, requiredPromptArg, resolveLimit, runStdioServer, selectArg, startOfLocalDay, surfaceUri, tableMap, toFailure, toFileUri, toLocalIso, trimToolListing, trimmed, watchInterference, withBusyRetry, withLazyTools, withTrimmedListing, wrap, wrapResult };
|
|
1502
1968
|
|
|
1503
1969
|
//# sourceMappingURL=index.js.map
|