@mgcrea/mcp-apple-messages 1.3.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.
@@ -0,0 +1,2137 @@
1
+ import { AppleAutomationError, AppleAutomationError as AppleMessagesError, BaseConfigSchema, CORE_DATA_EPOCH_OFFSET, IndexUnavailableError, IndexUnavailableError as IndexUnavailableError$1, PreconditionError, SchemaDriftError, SchemaDriftError as SchemaDriftError$1, columnsOf, confirmArg, createOsascriptRunner, describeStore, escapeLike, fail, fingerprintSchema, limitArg, ok, openReadOnly, parseBool, parseConfig, parseIntOpt, promptArg, readPackageIdentity, registerSurfaceResources, registerWorkflowPrompt, requiredPromptArg, trimmed, withBusyRetry, wrap, wrapResult } from "@mgcrea/mcp-apple-core";
2
+ import { existsSync, mkdirSync, readFileSync, statSync, writeFileSync } from "node:fs";
3
+ import { homedir } from "node:os";
4
+ import { basename, join, resolve, sep } from "node:path";
5
+ import { AppleContactsClient, emailKey, handleKind, loadConfig, suffixKey } from "@mgcrea/mcp-apple-contacts";
6
+ import { z } from "zod";
7
+ import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
8
+ //#region src/build-info.ts
9
+ const pkg = readPackageIdentity(new URL("../package.json", import.meta.url), {
10
+ name: "@mgcrea/mcp-apple-messages",
11
+ version: "0.0.0"
12
+ });
13
+ const BUILD_INFO = {
14
+ name: pkg.name,
15
+ version: pkg.version,
16
+ gitCommit: "b0f25be",
17
+ gitCommitDate: "2026-08-26T18:45:17+02:00"
18
+ };
19
+ //#endregion
20
+ //#region src/client/dates.ts
21
+ /**
22
+ * Messages' dates, which do not fit in a JavaScript number.
23
+ *
24
+ * `docs/messages.md` measured every populated date column as **nanoseconds since
25
+ * 2001-01-01** — eighteen digits, about 7.9e17, two orders of magnitude past
26
+ * `Number.MAX_SAFE_INTEGER`. `node:sqlite` throws on those rather than
27
+ * truncating, which is correct of it and fatal to a naive `SELECT date`.
28
+ *
29
+ * The failure mode is what makes this worth its own module. Wrapped in the usual
30
+ * try/catch the throw is swallowed and the column reports as EMPTY — the probe
31
+ * did exactly that on its first granted run and announced "no dates present" for
32
+ * all seven columns across 97,414 messages. A section written to catch a silent
33
+ * 31-year error was itself silently wrong.
34
+ *
35
+ * ## The fix: divide in SQL, not in JavaScript
36
+ *
37
+ * `CAST(date AS REAL) / 1e9` never materialises the integer on the JS side, so
38
+ * there is nothing to throw. Precision is fine: 7.9e8 seconds with a fractional
39
+ * part is comfortably inside a double, and this surface has no use for
40
+ * nanosecond resolution anyway.
41
+ *
42
+ * Every query in `store.ts` uses `APPLE_SECONDS_SQL`. Reading one of these
43
+ * columns any other way is the bug.
44
+ */
45
+ /** Seconds between 1970-01-01 and 2001-01-01. Same constant as `packages/core`. */
46
+ /**
47
+ * Below this, a value is seconds rather than nanoseconds.
48
+ *
49
+ * Messages switched around macOS 10.13 and old rows were not rewritten, so a
50
+ * store with history from both eras carries both. 1e12 apple-seconds is the year
51
+ * 33,658 and 1e12 apple-nanoseconds is 2001 — no real timestamp is near it in
52
+ * either reading, which is what makes the split safe.
53
+ */
54
+ const NANOSECOND_FLOOR = 0xe8d4a51000;
55
+ /**
56
+ * SQL that yields apple-SECONDS as a REAL, whichever unit the row holds.
57
+ *
58
+ * Done in SQL rather than in JS because the point is to never let the raw
59
+ * integer reach `node:sqlite`'s value conversion.
60
+ */
61
+ const appleSecondsSql = (column) => `CASE WHEN ${column} IS NULL THEN NULL WHEN ABS(CAST(${column} AS REAL)) > ${NANOSECOND_FLOOR} THEN CAST(${column} AS REAL) / 1000000000.0 ELSE CAST(${column} AS REAL) END`;
62
+ /** Apple-seconds to a JS Date. Null in, null out. */
63
+ const fromAppleSeconds = (value) => {
64
+ if (value === null || !Number.isFinite(value) || value === 0) return null;
65
+ return /* @__PURE__ */ new Date((value + CORE_DATA_EPOCH_OFFSET) * 1e3);
66
+ };
67
+ /** A JS Date to apple-seconds, for range bounds. */
68
+ const toAppleSeconds = (date) => date.getTime() / 1e3 - CORE_DATA_EPOCH_OFFSET;
69
+ /** ISO-8601, or null. What every date field on a result carries. */
70
+ const renderInstant = (value) => fromAppleSeconds(value)?.toISOString() ?? null;
71
+ //#endregion
72
+ //#region src/client/errors.ts
73
+ /**
74
+ * Messages' error surface. The taxonomy lives in `@mgcrea/mcp-apple-core`; what
75
+ * belongs here is the identity those messages are written against.
76
+ */
77
+ const MESSAGES_SURFACE = {
78
+ appName: "Messages",
79
+ envPrefix: "APPLE_MESSAGES"
80
+ };
81
+ /**
82
+ * Used by exactly one code path, and unused by every read.
83
+ *
84
+ * Messages is the one surface with NO Apple Events read lane at all — measured,
85
+ * not assumed. `docs/messages.md` records every attempt failing, and the reason
86
+ * is peculiar enough to write down: Messages answers "Application isn't running"
87
+ * while `NSRunningApplication` reports it running, because it lives as a
88
+ * windowless background process that declines to wake for a script. The liveness
89
+ * check and the app's own answer disagree and neither is lying.
90
+ *
91
+ * The id is the liveness check for `send`, which is the only thing Apple Events
92
+ * can do on this surface — see `client/jxa/core.ts`.
93
+ */
94
+ const MESSAGES_BUNDLE_ID = "com.apple.MobileSMS";
95
+ /** A message ref no longer resolves — deleted, or the chat was cleared. */
96
+ var MessageNotFoundError = class extends AppleAutomationError {
97
+ name = "MessageNotFoundError";
98
+ constructor(ref) {
99
+ super(`No message for ref "${ref}". It was probably deleted since the search ran. Re-run the search to get a current ref.`, { ref });
100
+ }
101
+ };
102
+ /** A chat ref no longer resolves. */
103
+ var ChatNotFoundError = class extends AppleAutomationError {
104
+ name = "ChatNotFoundError";
105
+ constructor(ref) {
106
+ super(`No chat for ref "${ref}". Use apple_messages_list_chats to get a current ref.`, { ref });
107
+ }
108
+ };
109
+ /**
110
+ * The store could not be read.
111
+ *
112
+ * Its own error because this surface fails harder than any other: there is no
113
+ * Apple Events fallback, so without Full Disk Access there is no server at all.
114
+ * `docs/distribution.md`'s "try before you grant" was retired partly because of
115
+ * this surface, and the message says so rather than implying a degraded mode
116
+ * that does not exist.
117
+ */
118
+ var MessagesUnavailableError = class extends AppleAutomationError {
119
+ name = "MessagesUnavailableError";
120
+ constructor(reason) {
121
+ super(reason, {});
122
+ }
123
+ };
124
+ /**
125
+ * Messages would not accept any form of recipient for a send.
126
+ *
127
+ * Its own error because the cause is almost never the recipient. Every rung of
128
+ * the ladder in `client/jxa/core.ts` except the first one enumerates something,
129
+ * and enumeration is exactly what this app refuses — so the usual cause of this
130
+ * error is that the chat is new (no guid in the store to address it by) rather
131
+ * than that the person does not exist. The message says so, because "not found"
132
+ * would send a caller looking for a typo that is not there.
133
+ */
134
+ var SendTargetNotFoundError = class extends AppleAutomationError {
135
+ name = "SendTargetNotFoundError";
136
+ constructor(recipient, attempts) {
137
+ super(`Messages would not resolve "${recipient}" to a chat or participant, so nothing was sent. This usually means there is no existing conversation with them on this Mac: Messages refuses to enumerate participants for a script, so an existing chat is the only handle this server can address reliably. Open the conversation once in Messages.app and retry.`, {
138
+ recipient,
139
+ attempts: [...attempts]
140
+ });
141
+ }
142
+ };
143
+ /** Messages accepted the target and then refused the send itself. */
144
+ var SendFailedError = class extends AppleAutomationError {
145
+ name = "SendFailedError";
146
+ constructor(message, attempts) {
147
+ super(`Messages refused the send: ${message}`, { attempts: [...attempts] });
148
+ }
149
+ };
150
+ //#endregion
151
+ //#region src/client/jxa/core.ts
152
+ /**
153
+ * The Apple Events lane for Messages — which exists for exactly one verb.
154
+ *
155
+ * Every script here is a static constant. None may contain a template
156
+ * interpolation: `assertStaticScript` rejects any script containing a dollar
157
+ * sign followed by a brace, including one written inside the JXA source. Use
158
+ * string concatenation in JXA code.
159
+ *
160
+ * Contract, shared with every other surface:
161
+ * - parameters arrive as `JSON.parse(argv[0])`
162
+ * - success returns `JSON.stringify({ok: true, data})`
163
+ * - an application-level failure returns `{ok: false, error: {code, message}}`
164
+ * and still exits 0, so a non-zero exit always means infrastructure.
165
+ *
166
+ * ## There is no read.ts here, and there never can be
167
+ *
168
+ * Adding a send did not add a read lane, and on this surface that is not a
169
+ * policy choice — it is the measurement in `docs/messages.md`:
170
+ *
171
+ * | attempt | result |
172
+ * | ---------------- | ----------------------------------------- |
173
+ * | `chats()` | `Error: Application isn't running.` |
174
+ * | `chats.id()` | `TypeError: M.chats.id is not a function` |
175
+ * | `participants()` | `Error: Application isn't running.` |
176
+ * | `buddies()` | `Error: Application isn't running.` |
177
+ * | messages of chat | `Error: Application isn't running.` |
178
+ *
179
+ * Messages answers "Application isn't running" while `NSRunningApplication`
180
+ * reports it running, because it lives as a windowless background process that
181
+ * declines to wake for a script. `test/jxa.test.ts` asserts `read.ts` does not
182
+ * exist, so this cannot be re-added out of helpfulness.
183
+ *
184
+ * The consequence for the code below is concrete: **the target resolution steps
185
+ * that enumerate anything are expected to fail**, which is why they are a ladder
186
+ * and why every rung reports itself. The one rung that does not enumerate —
187
+ * `chats.byId(guid)`, with the guid handed over by the file lane — is the one
188
+ * this design is built around.
189
+ *
190
+ * ## What the dictionary actually offers
191
+ *
192
+ * MEASURED from `sdef /System/Applications/Messages.app` on macOS 26.6. Three
193
+ * commands, and only one of them is a write:
194
+ *
195
+ * send text (or a file) to a participant or a chat
196
+ * login log in to all accounts
197
+ * logout log out of all accounts
198
+ *
199
+ * `login`/`logout` are not exposed as tools: logging a user out of iMessage on
200
+ * every device is not something to do behind a tool call, and there is no read
201
+ * to justify logging in.
202
+ *
203
+ * `send`'s direct parameter is typed `file` OR `text`. **This ships text only.**
204
+ * The file form is one branch away and deliberately not taken: a tool that
205
+ * transfers an arbitrary local path to a remote person is an exfiltration
206
+ * primitive, and unlike the text form its blast radius is not bounded by what
207
+ * the model can say. Recorded here so the omission reads as a decision.
208
+ *
209
+ * ## The `to` parameter, and why the file lane picks the target
210
+ *
211
+ * `send` takes a `participant` or a `chat`. Getting one of those normally means
212
+ * enumerating, which is the thing that does not work here. But the `chat` class
213
+ * carries `id` — "A guid identifier for this chat" — and the file lane already
214
+ * holds `chat.guid` for all 1,027 chats on the measured store. So the read lane
215
+ * chooses the target and the write lane addresses it by id, which is the only
216
+ * arrangement where neither lane has to do the thing it cannot.
217
+ *
218
+ * That the two guids are the same string is NOT assumed. It is the exact class
219
+ * of thing this project has been wrong about before — `docs/messages.md` calls
220
+ * the id bridge "unanswerable by construction" because Messages returned no
221
+ * identifier to bridge FROM. So `chats.byId` is rung one of a ladder, every rung
222
+ * records why it failed, and the strategy that worked is reported back to the
223
+ * caller in the tool result.
224
+ */
225
+ /** The bundle id, which does not match the display name: Messages.app is still MobileSMS. */
226
+ const PRELUDE = `
227
+ ObjC.import("AppKit");
228
+
229
+ function ok(data) { return JSON.stringify({ ok: true, data: data }); }
230
+ function err(code, message, extra) {
231
+ var e = { code: code, message: String(message) };
232
+ if (extra) e.detail = extra;
233
+ return JSON.stringify({ ok: false, error: e });
234
+ }
235
+
236
+ /** Read one property defensively — every read on this surface is allowed to fail. */
237
+ function prop(fn, fallback) {
238
+ try {
239
+ var v = fn();
240
+ return v === undefined ? fallback : v;
241
+ } catch (e) {
242
+ return fallback;
243
+ }
244
+ }
245
+
246
+ function isMessagesRunning() {
247
+ var apps = $.NSRunningApplication.runningApplicationsWithBundleIdentifier("com.apple.MobileSMS");
248
+ return apps.count > 0;
249
+ }
250
+
251
+ /** The dictionary's service type enumeration: SMS, iMessage, RCS. */
252
+ function serviceEnum(name) {
253
+ var s = String(name || "").toLowerCase();
254
+ if (s === "sms") return "SMS";
255
+ if (s === "rcs") return "RCS";
256
+ return "iMessage";
257
+ }
258
+
259
+ /**
260
+ * Find something \`send\` will accept as its \`to\`.
261
+ *
262
+ * Ordered cheapest and most-likely-to-work first. Every rung is wrapped, every
263
+ * failure is recorded, and the caller is told which one answered — on a surface
264
+ * whose whole read half is known broken, "it worked" without "how" is not a
265
+ * result anyone can act on later.
266
+ */
267
+ function resolveTarget(M, p, tried) {
268
+ var i;
269
+
270
+ // 1. The chat guid the file lane read out of chat.db. No enumeration.
271
+ if (p.chatGuid) {
272
+ try {
273
+ var chat = M.chats.byId(p.chatGuid);
274
+ chat.id();
275
+ return { target: chat, strategy: "chat-guid", kind: "chat" };
276
+ } catch (e) {
277
+ tried.push("chat-guid: " + (e.message || e));
278
+ }
279
+ }
280
+
281
+ if (!p.handle) return null;
282
+
283
+ // 2. The guid Messages composes for a one-to-one chat, spelled the way the
284
+ // store spells it: "iMessage;-;+15551234567". Constructed rather than read,
285
+ // so it is below the real one and above everything that enumerates.
286
+ var services = p.service ? [serviceEnum(p.service)] : ["iMessage", "SMS", "RCS"];
287
+ for (i = 0; i < services.length; i++) {
288
+ var guess = services[i] + ";-;" + p.handle;
289
+ try {
290
+ var guessed = M.chats.byId(guess);
291
+ guessed.id();
292
+ return { target: guessed, strategy: "chat-guid-guess", kind: "chat", guid: guess };
293
+ } catch (e2) {
294
+ tried.push("chat-guid-guess(" + guess + "): " + (e2.message || e2));
295
+ }
296
+ }
297
+
298
+ // 3. A participant reached through its account. This enumerates, so it is
299
+ // expected to fail with "Application isn't running" — kept because it is
300
+ // the form every AppleScript example on the internet uses, and because if
301
+ // launching the app does wake the scripting interface, this is what works.
302
+ for (i = 0; i < services.length; i++) {
303
+ try {
304
+ var accounts = M.accounts.whose({ serviceType: services[i] })();
305
+ for (var j = 0; j < accounts.length; j++) {
306
+ try {
307
+ var buddy = accounts[j].participants.whose({ handle: p.handle })()[0];
308
+ if (buddy) {
309
+ buddy.id();
310
+ return { target: buddy, strategy: "account-participant", kind: "participant" };
311
+ }
312
+ } catch (e4) {
313
+ tried.push("account-participant(" + services[i] + "): " + (e4.message || e4));
314
+ }
315
+ }
316
+ } catch (e3) {
317
+ tried.push("accounts(" + services[i] + "): " + (e3.message || e3));
318
+ }
319
+ }
320
+
321
+ // 4. The flat participant list, last because it is the widest enumeration.
322
+ try {
323
+ var flat = M.participants.whose({ handle: p.handle })()[0];
324
+ if (flat) {
325
+ flat.id();
326
+ return { target: flat, strategy: "participant", kind: "participant" };
327
+ }
328
+ tried.push("participant: no participant with that handle");
329
+ } catch (e5) {
330
+ tried.push("participant: " + (e5.message || e5));
331
+ }
332
+
333
+ return null;
334
+ }
335
+ `;
336
+ //#endregion
337
+ //#region src/client/jxa/write.ts
338
+ /**
339
+ * One script, one verb.
340
+ *
341
+ * `send` is the only mutating command in the Messages dictionary that this
342
+ * server exposes — see `core.ts` for the full list and for why `login`/`logout`
343
+ * and the file form of `send` are left out.
344
+ *
345
+ * ## What this script deliberately does NOT do
346
+ *
347
+ * It does not report success from its own read-back, because there is nothing to
348
+ * read back: `send` returns no value, and every read this app offers fails. A
349
+ * script that answered `{ok: true}` and stopped would be claiming delivery on
350
+ * the strength of a command that did not throw — the exact shape of "plausible,
351
+ * wrong and silent" this repo keeps designing against.
352
+ *
353
+ * So the script's answer is deliberately narrow: **the send command was accepted
354
+ * by Messages, and here is how the target was addressed.** Whether a row landed
355
+ * is a question for the file lane, and `client/messages.ts` asks it immediately
356
+ * afterwards by polling chat.db for the outgoing row. That split is the answer
357
+ * to the open question `docs/messages.md` left — "whether a send should
358
+ * re-resolve by scanning the store for a recent row on the target chat" — and it
359
+ * is what makes a send reportable at all on a surface with no id bridge.
360
+ */
361
+ const SEND_MESSAGE = `${PRELUDE}
362
+ function run(argv) {
363
+ var p = JSON.parse(argv[0]);
364
+ var M = Application("Messages");
365
+
366
+ var wasRunning = isMessagesRunning();
367
+ if (!wasRunning && !p.allowLaunch) {
368
+ return err("APP_NOT_RUNNING", "Messages is not running.");
369
+ }
370
+
371
+ var tried = [];
372
+ var resolved = resolveTarget(M, p, tried);
373
+ if (!resolved) {
374
+ return err(
375
+ "SEND_TARGET_NOT_FOUND",
376
+ "Messages would not resolve a chat or participant for that recipient.",
377
+ tried
378
+ );
379
+ }
380
+
381
+ try {
382
+ M.send(p.text, { to: resolved.target });
383
+ } catch (e) {
384
+ return err("SEND_FAILED", e.message || e, tried);
385
+ }
386
+
387
+ return ok({
388
+ strategy: resolved.strategy,
389
+ targetKind: resolved.kind,
390
+ // Best effort, and allowed to be null: reading the id back is itself a read.
391
+ targetId: prop(function () { return String(resolved.target.id()); }, null),
392
+ launched: !wasRunning,
393
+ attempts: tried
394
+ });
395
+ }
396
+ `;
397
+ //#endregion
398
+ //#region src/client/locate.ts
399
+ /**
400
+ * Find Messages' store.
401
+ *
402
+ * The easiest locator in the repo: `~/Library/Messages/chat.db` is a constant,
403
+ * with no generated directory name to list (Reminders) and no per-account fan-out
404
+ * (Contacts). `statSync` succeeds on a TCC-protected file while `open` is denied,
405
+ * so this distinguishes "not there" from "not allowed" with no permission at all
406
+ * — which is most of what diagnostics is for on a surface where the grant is
407
+ * mandatory.
408
+ */
409
+ const STORE_RELATIVE = join("Library", "Messages", "chat.db");
410
+ /** Attachment bytes live here, referenced by `attachment.filename`. */
411
+ const ATTACHMENTS_RELATIVE = join("Library", "Messages", "Attachments");
412
+ /**
413
+ * The directory an attachment path is required to sit inside.
414
+ *
415
+ * Deliberately the Messages root rather than `Attachments` itself. Measured
416
+ * shapes of `attachment.filename` include stickers, which Messages keeps in a
417
+ * sibling directory, so confining to `Attachments` would refuse real files —
418
+ * and confining to nothing at all would let a row in a database this server
419
+ * does not write choose which file gets copied out.
420
+ */
421
+ const MESSAGES_ROOT_RELATIVE = join("Library", "Messages");
422
+ const defaultStorePath = (home = homedir()) => join(home, STORE_RELATIVE);
423
+ const FDA_HINT = "Grant Full Disk Access to the app running this server (System Settings > Privacy & Security > Full Disk Access) and restart it. Granting it to Messages.app does nothing — the reader needs the permission. Unlike every other surface here, there is no Apple Events fallback: Messages has no read path in its scripting dictionary at all, so without this grant there is no server.";
424
+ const locateStore = (opts = {}) => {
425
+ const home = opts.home ?? homedir();
426
+ const storePath = opts.storePath ?? defaultStorePath(home);
427
+ const facts = describeStore(storePath);
428
+ return {
429
+ ...facts,
430
+ storePath,
431
+ attachmentsPath: join(home, ATTACHMENTS_RELATIVE),
432
+ messagesRoot: join(home, MESSAGES_ROOT_RELATIVE),
433
+ reason: facts.readable ? null : facts.exists ? `Found the Messages store at ${storePath} but cannot read it. ${FDA_HINT}` : opts.storePath ? `No file at ${storePath}. APPLE_MESSAGES_STORE points at nothing.` : `No Messages store at ${storePath}. Has Messages ever been set up on this account?`
434
+ };
435
+ };
436
+ //#endregion
437
+ //#region src/client/ref.ts
438
+ /**
439
+ * Refs for messages and chats.
440
+ *
441
+ * ## Why a GUID rather than a rowid
442
+ *
443
+ * Both exist, and the rowid is faster to look up. The GUID wins anyway because
444
+ * **rowids are reused.** SQLite hands a deleted row's id to the next insert
445
+ * unless the table is `AUTOINCREMENT`, and Messages deletes constantly — every
446
+ * "delete this conversation" frees a block of them. A ref handed to a model in
447
+ * one turn and used two turns later would then resolve to a DIFFERENT message,
448
+ * with no error anywhere. That is the failure this project keeps designing
449
+ * against: plausible, wrong, and silent.
450
+ *
451
+ * The GUID is also what Apple itself joins on — `associated_message_guid` for
452
+ * reactions, `thread_originator_guid` for replies — so it is the identifier the
453
+ * schema already treats as stable.
454
+ *
455
+ * ## Why `m1:` and `mc1:`
456
+ *
457
+ * `c1:` is Calendar's, `r1:` is Reminders', `k1:` is Contacts'. A ref that
458
+ * decodes under two surfaces would be worse than one that decodes under none,
459
+ * so each prefix is claimed once and the version digit keeps a future scheme
460
+ * change additive.
461
+ */
462
+ const MESSAGE_REF_VERSION = "m1";
463
+ const CHAT_REF_VERSION = "mc1";
464
+ /**
465
+ * A GUID is opaque and not always a UUID — measured shapes include
466
+ * `iMessage;-;+15551234567` on chats, which carries semicolons and a phone
467
+ * number. So the tail is greedy and nothing inside it is parsed.
468
+ */
469
+ const MESSAGE_PATTERN = /^m1:(.+)$/;
470
+ const CHAT_PATTERN = /^mc1:(.+)$/;
471
+ const otherSurface = (raw) => {
472
+ if (raw.startsWith("c1:")) return " That one is a Calendar event ref.";
473
+ if (raw.startsWith("r1:")) return " That one is a Reminders ref.";
474
+ if (raw.startsWith("k1:")) return " That one is a Contacts ref.";
475
+ if (raw.startsWith("mc1:")) return " That one is a CHAT ref — this wants a message ref.";
476
+ if (raw.startsWith("m1:")) return " That one is a MESSAGE ref — this wants a chat ref.";
477
+ return "";
478
+ };
479
+ var InvalidMessageRefError = class extends AppleAutomationError {
480
+ name = "InvalidMessageRefError";
481
+ constructor(raw, want) {
482
+ super(`"${raw}" is not a ${want} ref. Refs come from apple_messages_* results and look like ${want === "message" ? "\"m1:<guid>\"" : "\"mc1:<guid>\""} — they are opaque and must not be constructed by hand.${otherSurface(raw)}`, { ref: raw });
483
+ }
484
+ };
485
+ const encodeMessageRef = (guid) => `m1:${guid}`;
486
+ const encodeChatRef = (guid) => `mc1:${guid}`;
487
+ const decodeMessageRef = (raw) => {
488
+ const m = MESSAGE_PATTERN.exec(raw.trim());
489
+ if (!m?.[1]) throw new InvalidMessageRefError(raw, "message");
490
+ return m[1];
491
+ };
492
+ const decodeChatRef = (raw) => {
493
+ const m = CHAT_PATTERN.exec(raw.trim());
494
+ if (!m?.[1]) throw new InvalidMessageRefError(raw, "chat");
495
+ return m[1];
496
+ };
497
+ //#endregion
498
+ //#region src/client/typedstream.ts
499
+ /**
500
+ * NSArchiver `typedstream` reader — enough of it to pull the text out of a
501
+ * Messages `attributedBody` blob.
502
+ *
503
+ * ## Why this has to exist
504
+ *
505
+ * `docs/messages.md` measured it: 97,092 of 97,414 messages carry a blob and
506
+ * only 94,049 carry `text`. **The blob is the norm and `text` is the redundant
507
+ * copy**, not the other way round. 3,043 messages — one in thirty-two — have an
508
+ * empty `text` and content only in here, so a server that reads the column
509
+ * returns nothing for them, silently and with no error to notice.
510
+ *
511
+ * The archive header is `04 0B streamtyped 81 E8 03`. Not `bplist00`, not gzip,
512
+ * and not the protobuf that Notes turned out to hold, so none of the existing
513
+ * decoders apply.
514
+ *
515
+ * ## The format, as far as this reader needs it
516
+ *
517
+ * A stream of tagged values. Integers are a single signed byte unless prefixed:
518
+ *
519
+ * 0x81 int16 follows (little-endian)
520
+ * 0x82 int32 follows
521
+ * 0x83 float or double follows
522
+ * 0x84 START — a new class or object definition
523
+ * 0x85 nil / empty
524
+ * 0x86 END of the current object
525
+ * >=0x92 a back-reference; index = byte - 0x92
526
+ *
527
+ * Strings arrive length-prefixed. Class names and object contents both use that
528
+ * shape, which is why this walks structurally instead of pattern-matching: the
529
+ * same bytes mean different things depending on where you are.
530
+ *
531
+ * ## Honest failure, never a guess
532
+ *
533
+ * The Notes decoder was documented backwards because a `LIMIT 1` sample landed
534
+ * on an outlier, and this project has now been bitten three times by a heuristic
535
+ * that produced a plausible wrong answer. So there is no "longest printable run"
536
+ * fallback here. If the structure does not parse, `ok` is false and the caller
537
+ * counts it — and `text` remains available as the answer for the 96.5% of rows
538
+ * that have one.
539
+ *
540
+ * Pure and I/O-free.
541
+ *
542
+ * ## Two copies, deliberately
543
+ *
544
+ * `scripts/lib/typedstream.mjs` is the same reader, and the probe uses it to
545
+ * measure this one against a real store. They are kept in step by hand, as
546
+ * `packages/notes/src/client/protobuf.ts` and `scripts/lib/note-protobuf.mjs`
547
+ * already are: a probe that imported from a package would stop being runnable
548
+ * before that package exists, which is the wrong way round.
549
+ */
550
+ const SIGNATURE = "streamtyped";
551
+ const TAG_I16 = 129;
552
+ const TAG_I32 = 130;
553
+ const TAG_DECIMAL = 131;
554
+ const TAG_START = 132;
555
+ const TAG_EMPTY = 133;
556
+ const TAG_END = 134;
557
+ /** Anything at or above this is an index into the table of things already seen. */
558
+ const TAG_REFERENCE = 146;
559
+ /** A blob larger than this is not a message; refuse rather than chew through it. */
560
+ const MAX_BLOB_BYTES = 4194304;
561
+ /**
562
+ * Structural guard for the walk BEFORE the text is reached — class chains and
563
+ * type encodings, which are a few dozen tokens on every blob measured. Reaching
564
+ * this means the stream is not shaped like an archived attributed string.
565
+ */
566
+ const MAX_TOKENS = 2e4;
567
+ const utf8 = new TextDecoder("utf-8", { fatal: true });
568
+ const latin1 = new TextDecoder("latin1");
569
+ var Cursor = class {
570
+ buf;
571
+ pos;
572
+ constructor(buf) {
573
+ this.buf = buf;
574
+ this.pos = 0;
575
+ }
576
+ get done() {
577
+ return this.pos >= this.buf.length;
578
+ }
579
+ get remaining() {
580
+ return this.buf.length - this.pos;
581
+ }
582
+ byte() {
583
+ return this.buf[this.pos++];
584
+ }
585
+ peek() {
586
+ return this.buf[this.pos];
587
+ }
588
+ take(n) {
589
+ const out = this.buf.subarray(this.pos, this.pos + n);
590
+ this.pos += n;
591
+ return out;
592
+ }
593
+ /**
594
+ * A byte at an offset from the cursor. Every caller checks `remaining` first,
595
+ * so the index is in range — this exists to say that once rather than to
596
+ * scatter non-null assertions through the integer readers.
597
+ */
598
+ at(offset) {
599
+ return this.buf[this.pos + offset];
600
+ }
601
+ };
602
+ /**
603
+ * A signed integer, which is also how every length in the stream is written.
604
+ *
605
+ * Returns null rather than throwing on a truncated read — a truncated blob is a
606
+ * finding, not a crash.
607
+ */
608
+ const readInt = (c) => {
609
+ if (c.done) return null;
610
+ const b = c.byte();
611
+ if (b === TAG_I16) {
612
+ if (c.remaining < 2) return null;
613
+ const v = c.at(0) | c.at(1) << 8;
614
+ c.pos += 2;
615
+ return v > 32767 ? v - 65536 : v;
616
+ }
617
+ if (b === TAG_I32) {
618
+ if (c.remaining < 4) return null;
619
+ const v = (c.at(0) | c.at(1) << 8 | c.at(2) << 16 | c.at(3) << 24) >>> 0;
620
+ c.pos += 4;
621
+ return v > 2147483647 ? v - 4294967296 : v;
622
+ }
623
+ if (b === TAG_DECIMAL) return null;
624
+ return b > 127 ? b - 256 : b;
625
+ };
626
+ /** A length-prefixed string. `null` when the bytes are not valid UTF-8. */
627
+ const readString = (c) => {
628
+ const len = readInt(c);
629
+ if (len === null || len < 0 || len > c.remaining) return null;
630
+ const bytes = c.take(len);
631
+ try {
632
+ return utf8.decode(bytes);
633
+ } catch {
634
+ return null;
635
+ }
636
+ };
637
+ /**
638
+ * A length-prefixed UTF-8 payload — the `+` type encoding's value.
639
+ *
640
+ * The length is in BYTES, which is the trap: a 22-character string of accents
641
+ * and kana declares 36. Slicing by character count would desynchronise the walk
642
+ * and corrupt everything after it.
643
+ */
644
+ const readByteArray = (c) => {
645
+ const len = readInt(c);
646
+ if (len === null || len < 0 || len > c.remaining) return null;
647
+ const bytes = c.take(len);
648
+ try {
649
+ return utf8.decode(bytes);
650
+ } catch {
651
+ return null;
652
+ }
653
+ };
654
+ /**
655
+ * Validate the archive header and position the cursor after it.
656
+ *
657
+ * The system-version integer that follows the signature is read and discarded —
658
+ * it is 1000 on every blob measured, and pinning it would reject a future macOS
659
+ * for no reason.
660
+ */
661
+ const readHeader = (c) => {
662
+ if (c.remaining < 16) return "too short to be a typedstream";
663
+ const version = c.byte();
664
+ const sigLen = c.byte();
665
+ if (sigLen !== 11) return `bad signature length ${sigLen}`;
666
+ const sig = latin1.decode(c.take(sigLen));
667
+ if (sig !== SIGNATURE) return `not a typedstream (signature ${JSON.stringify(sig)})`;
668
+ readInt(c);
669
+ return version === 4 ? null : `unexpected streamer version ${version}`;
670
+ };
671
+ const walk = (c) => {
672
+ const strings = [];
673
+ const classes = [];
674
+ let firstPayloadEnd = null;
675
+ let tokens = 0;
676
+ while (!c.done) {
677
+ if (++tokens > MAX_TOKENS) return {
678
+ strings,
679
+ classes,
680
+ firstPayloadEnd,
681
+ error: "token limit exceeded"
682
+ };
683
+ if (c.peek() !== TAG_START) {
684
+ c.pos += 1;
685
+ continue;
686
+ }
687
+ c.pos += 1;
688
+ if (c.peek() === TAG_START) continue;
689
+ const before = c.pos;
690
+ const encoding = readString(c);
691
+ if (encoding === null) {
692
+ c.pos = before;
693
+ continue;
694
+ }
695
+ if (encoding === "+") {
696
+ const value = readByteArray(c);
697
+ if (value === null) return {
698
+ strings,
699
+ classes,
700
+ firstPayloadEnd,
701
+ error: "truncated byte array"
702
+ };
703
+ strings.push(value);
704
+ firstPayloadEnd = c.pos;
705
+ return {
706
+ strings,
707
+ classes,
708
+ firstPayloadEnd,
709
+ error: null
710
+ };
711
+ }
712
+ if (/^[A-Za-z_][A-Za-z0-9_.]*$/.test(encoding) && encoding.length > 2) classes.push(encoding);
713
+ }
714
+ return {
715
+ strings,
716
+ classes,
717
+ firstPayloadEnd,
718
+ error: null
719
+ };
720
+ };
721
+ const decodeAttributedBody = (buffer) => {
722
+ if (!buffer || buffer.length === 0) return {
723
+ ok: false,
724
+ error: "empty blob"
725
+ };
726
+ if (buffer.length > MAX_BLOB_BYTES) return {
727
+ ok: false,
728
+ error: "blob too large"
729
+ };
730
+ const c = new Cursor(buffer);
731
+ const headerError = readHeader(c);
732
+ if (headerError) return {
733
+ ok: false,
734
+ error: headerError
735
+ };
736
+ const { strings, classes, error, firstPayloadEnd } = walk(c);
737
+ if (error) return {
738
+ ok: false,
739
+ error,
740
+ classes
741
+ };
742
+ if (strings.length === 0) return {
743
+ ok: false,
744
+ error: "no content string found",
745
+ classes
746
+ };
747
+ return {
748
+ ok: true,
749
+ text: strings[0],
750
+ classes,
751
+ /** Bytes remained after the backing store — attribute runs this does not decode. */
752
+ hasAttributes: firstPayloadEnd !== null && firstPayloadEnd < buffer.length - 2,
753
+ error: null
754
+ };
755
+ };
756
+ /**
757
+ * A redacted structural outline of one blob.
758
+ *
759
+ * For measuring the format on a machine that has the data, without the report
760
+ * ever carrying a message. Every string is reduced to its length and its
761
+ * character class; only Apple's own constants survive as themselves.
762
+ */
763
+ const outline = (buffer, maxTokens = 60) => {
764
+ const c = new Cursor(buffer);
765
+ const headerError = readHeader(c);
766
+ if (headerError) return [`ERROR ${headerError}`];
767
+ const out = [];
768
+ let tokens = 0;
769
+ while (!c.done && tokens < maxTokens) {
770
+ tokens += 1;
771
+ const b = c.peek();
772
+ if (b === TAG_START) {
773
+ c.pos += 1;
774
+ out.push("START");
775
+ continue;
776
+ }
777
+ if (b === TAG_EMPTY) {
778
+ c.pos += 1;
779
+ out.push("nil");
780
+ continue;
781
+ }
782
+ if (b === TAG_END) {
783
+ c.pos += 1;
784
+ out.push("END");
785
+ continue;
786
+ }
787
+ if (b >= TAG_REFERENCE) {
788
+ c.pos += 1;
789
+ out.push(`ref#${b - TAG_REFERENCE}`);
790
+ continue;
791
+ }
792
+ const before = c.pos;
793
+ const s = readString(c);
794
+ if (s === null) {
795
+ c.pos = before + 1;
796
+ out.push(`byte 0x${b.toString(16).padStart(2, "0")}`);
797
+ continue;
798
+ }
799
+ if (/^(NS|IM|__kIM|CF)[A-Za-z0-9_]*$/.test(s) || s.length <= 2) out.push(`"${s}"`);
800
+ else out.push(`<str len=${s.length} ${/^[\x20-\x7e]*$/.test(s) ? "ascii" : "unicode"}>`);
801
+ }
802
+ if (!c.done) out.push("…");
803
+ return out;
804
+ };
805
+ //#endregion
806
+ //#region src/client/store.ts
807
+ /**
808
+ * Messages' file lane — the only lane there is.
809
+ *
810
+ * `docs/messages.md` measured every Apple Events read attempt failing, so unlike
811
+ * every other surface here there is nothing to fall back to. That shapes two
812
+ * things: `SchemaDriftError` is the only fatal condition, and a missing store is
813
+ * reported as a reason rather than as an empty list.
814
+ *
815
+ * ## Three measurements this file is built on
816
+ *
817
+ * **The blob is the norm, and increasingly the only thing there.** 97,094 rows
818
+ * carry `attributedBody` and 94,043 carry `text`; 3,051 have only the blob. That
819
+ * 3.1% is an average over a decade, and it hides the real shape: measured
820
+ * through this server, 2016-2025 are ~99% plain `text` and everything from
821
+ * MARCH 2026 ONWARD is blob-only. A reader that selects `text` would today
822
+ * report that the conversation stopped in February. Every read here goes through
823
+ * `#body()`, which prefers the column and falls back to the decoder — validated
824
+ * at **100.000% agreement across all 94,043 rows** where both exist, with zero failures
825
+ * across all 97,094.
826
+ *
827
+ * **Dates do not fit in a JavaScript number.** See `dates.ts`. Every date column
828
+ * is projected through `appleSecondsSql`, never selected raw.
829
+ *
830
+ * **Reactions are messages.** 2,788 rows carry `associated_message_type != 0`,
831
+ * and a reader that does not filter them renders `Liked "see you at 8"` as if
832
+ * somebody had typed it. Filtering is table stakes, not an enhancement.
833
+ */
834
+ /** Tables the lane cannot work without. */
835
+ const REQUIRED = [
836
+ "message",
837
+ "chat",
838
+ "handle"
839
+ ];
840
+ const PROBED_FINGERPRINT = "87b01c58a631";
841
+ const PROBED_MACOS = "26.6";
842
+ /**
843
+ * A tapback rather than something somebody typed.
844
+ *
845
+ * 2000–2005 add a reaction, 3000–3005 remove one; 0 is an ordinary message.
846
+ * The ranges are Apple's and are not documented anywhere public, so the code
847
+ * treats "not zero" as the test and only uses the ranges to LABEL — an
848
+ * unrecognised value is still excluded from the conversation, which is the safe
849
+ * direction.
850
+ */
851
+ const REACTION_LABELS = {
852
+ 2e3: "loved",
853
+ 2001: "liked",
854
+ 2002: "disliked",
855
+ 2003: "laughed",
856
+ 2004: "emphasized",
857
+ 2005: "questioned"
858
+ };
859
+ const reactionLabel = (type) => {
860
+ if (REACTION_LABELS[type]) return REACTION_LABELS[type];
861
+ if (type >= 3e3 && type <= 3005) return `removed ${REACTION_LABELS[type - 1e3] ?? "reaction"}`;
862
+ return `reaction ${type}`;
863
+ };
864
+ const num = (v) => typeof v === "number" ? v : null;
865
+ const text = (v) => typeof v === "string" && v.length > 0 ? v : null;
866
+ const bool = (v) => v === 1 || v === true;
867
+ var MessagesStore = class {
868
+ db;
869
+ mode;
870
+ caps;
871
+ constructor(db, mode, caps) {
872
+ this.db = db;
873
+ this.mode = mode;
874
+ this.caps = caps;
875
+ }
876
+ /** Project a column, or a typed NULL when this store does not have it. */
877
+ #col(present, table, name, alias = name) {
878
+ return present.has(name) ? `${table}."${name}" AS ${alias}` : `NULL AS ${alias}`;
879
+ }
880
+ #date(present, table, name, alias) {
881
+ return present.has(name) ? `${appleSecondsSql(`${table}."${name}"`)} AS ${alias}` : `NULL AS ${alias}`;
882
+ }
883
+ /**
884
+ * The text of a message, from whichever source has it.
885
+ *
886
+ * The column first because it is free, then the decoder. `textSource` is on
887
+ * every result so a caller can see which answered — and so the 3.1% that only
888
+ * the decoder can reach are visible rather than indistinguishable from empty.
889
+ */
890
+ #body(row) {
891
+ const column = text(row.text);
892
+ if (column !== null) return {
893
+ text: column,
894
+ source: "column"
895
+ };
896
+ const blob = row.attributedBody;
897
+ if (blob instanceof Uint8Array && blob.length > 0) {
898
+ const decoded = decodeAttributedBody(blob);
899
+ if (decoded.ok && decoded.text.length > 0) return {
900
+ text: decoded.text,
901
+ source: "decoded"
902
+ };
903
+ }
904
+ return {
905
+ text: null,
906
+ source: "none"
907
+ };
908
+ }
909
+ #messageColumns() {
910
+ const m = this.caps.messageColumns;
911
+ return [
912
+ `m."guid" AS guid`,
913
+ `m."text" AS text`,
914
+ m.has("attributedBody") ? `m."attributedBody" AS attributedBody` : `NULL AS attributedBody`,
915
+ this.#col(m, "m", "subject"),
916
+ this.#col(m, "m", "service"),
917
+ this.#col(m, "m", "is_from_me", "isFromMe"),
918
+ this.#col(m, "m", "is_read", "isRead"),
919
+ this.#col(m, "m", "is_sent", "isSent"),
920
+ this.#col(m, "m", "is_delivered", "isDelivered"),
921
+ this.#col(m, "m", "cache_has_attachments", "hasAttachments"),
922
+ this.#col(m, "m", "associated_message_type", "reactionType"),
923
+ this.#col(m, "m", "associated_message_guid", "reactionTarget"),
924
+ this.#col(m, "m", "thread_originator_guid", "threadOriginator"),
925
+ this.#col(m, "m", "item_type", "itemType"),
926
+ this.#date(m, "m", "date", "sentAt"),
927
+ this.#date(m, "m", "date_read", "readAt"),
928
+ this.#date(m, "m", "date_delivered", "deliveredAt"),
929
+ this.#date(m, "m", "date_edited", "editedAt"),
930
+ `h."id" AS handle`,
931
+ `c."guid" AS chatGuid`,
932
+ this.#col(this.caps.chatColumns, "c", "display_name", "chatName")
933
+ ].join(",\n ");
934
+ }
935
+ #joins() {
936
+ return `LEFT JOIN "handle" h ON h."ROWID" = m."handle_id"
937
+ LEFT JOIN "chat_message_join" cmj ON cmj."message_id" = m."ROWID"
938
+ LEFT JOIN "chat" c ON c."ROWID" = cmj."chat_id"`;
939
+ }
940
+ #toRow(r) {
941
+ const body = this.#body(r);
942
+ return {
943
+ guid: String(r.guid),
944
+ chatGuid: text(r.chatGuid),
945
+ chatName: text(r.chatName),
946
+ handle: text(r.handle),
947
+ service: text(r.service),
948
+ isFromMe: bool(r.isFromMe),
949
+ sentAt: num(r.sentAt),
950
+ readAt: num(r.readAt),
951
+ deliveredAt: num(r.deliveredAt),
952
+ editedAt: num(r.editedAt),
953
+ text: body.text,
954
+ textSource: body.source,
955
+ subject: text(r.subject),
956
+ isRead: bool(r.isRead),
957
+ isSent: bool(r.isSent),
958
+ isDelivered: bool(r.isDelivered),
959
+ hasAttachments: bool(r.hasAttachments),
960
+ reactionType: num(r.reactionType) || null,
961
+ reactionTarget: text(r.reactionTarget),
962
+ threadOriginator: text(r.threadOriginator),
963
+ itemType: num(r.itemType)
964
+ };
965
+ }
966
+ /**
967
+ * A window of messages, newest first.
968
+ *
969
+ * Reactions are excluded by default. They are rows in this table like any
970
+ * other, and 2,788 of them would otherwise appear as messages reading
971
+ * `Liked "see you at 8"` — which is not something anybody typed.
972
+ */
973
+ range(q) {
974
+ const m = this.caps.messageColumns;
975
+ const where = [];
976
+ const params = [];
977
+ if (q.chatGuid) {
978
+ where.push(`c."guid" = ?`);
979
+ params.push(q.chatGuid);
980
+ }
981
+ if (q.fromApple !== void 0 && m.has("date")) {
982
+ where.push(`${appleSecondsSql("m.\"date\"")} >= ?`);
983
+ params.push(q.fromApple);
984
+ }
985
+ if (q.toApple !== void 0 && m.has("date")) {
986
+ where.push(`${appleSecondsSql("m.\"date\"")} < ?`);
987
+ params.push(q.toApple);
988
+ }
989
+ if (!q.includeReactions && m.has("associated_message_type")) where.push(`(m."associated_message_type" IS NULL OR m."associated_message_type" = 0)`);
990
+ const sql = `
991
+ SELECT ${this.#messageColumns()}
992
+ FROM "message" m
993
+ ${this.#joins()}
994
+ ${where.length ? `WHERE ${where.join("\n AND ")}` : ""}
995
+ ORDER BY m."date" DESC
996
+ LIMIT ${Math.max(1, Math.trunc(q.limit))}`;
997
+ return this.db.prepare(sql).all(...params).map((r) => this.#toRow(r));
998
+ }
999
+ /**
1000
+ * Text search, in two passes — and the second one is the point.
1001
+ *
1002
+ * Pass 1 is `LIKE` on the column: measured at **16 ms over 97,416 rows** with
1003
+ * 27 existing indexes, so there is no index-vs-scan tradeoff to litigate and
1004
+ * no FTS table to build.
1005
+ *
1006
+ * Pass 2 covers what pass 1 structurally cannot. 3,051 messages have an empty
1007
+ * `text` and content only in `attributedBody`, and no amount of SQL reaches
1008
+ * inside a blob. Decoding them costs about **6 ms** — the decoder runs at
1009
+ * 2 ms per thousand — so completeness here is nearly free, and a search that
1010
+ * silently omitted one message in thirty-two would be the worst kind of wrong.
1011
+ */
1012
+ search(query, limit, includeReactions = false) {
1013
+ const m = this.caps.messageColumns;
1014
+ const needle = `%${escapeLike(query)}%`;
1015
+ const reactionFilter = !includeReactions && m.has("associated_message_type") ? `AND (m."associated_message_type" IS NULL OR m."associated_message_type" = 0)` : "";
1016
+ const cap = Math.max(1, Math.trunc(limit));
1017
+ const results = this.db.prepare(`SELECT ${this.#messageColumns()}
1018
+ FROM "message" m
1019
+ ${this.#joins()}
1020
+ WHERE m."text" LIKE ? ESCAPE '\\' ${reactionFilter}
1021
+ ORDER BY m."date" DESC
1022
+ LIMIT ${cap}`).all(needle).map((r) => this.#toRow(r));
1023
+ if (results.length >= cap || !m.has("attributedBody")) return results;
1024
+ const blobOnly = this.db.prepare(`SELECT ${this.#messageColumns()}
1025
+ FROM "message" m
1026
+ ${this.#joins()}
1027
+ WHERE (m."text" IS NULL OR m."text" = '')
1028
+ AND m."attributedBody" IS NOT NULL ${reactionFilter}
1029
+ ORDER BY m."date" DESC`).all();
1030
+ const lowered = query.toLowerCase();
1031
+ for (const raw of blobOnly) {
1032
+ if (results.length >= cap) break;
1033
+ const row = this.#toRow(raw);
1034
+ if (row.text && row.text.toLowerCase().includes(lowered)) results.push(row);
1035
+ }
1036
+ return results.slice(0, cap);
1037
+ }
1038
+ byGuid(guid) {
1039
+ const rows = this.db.prepare(`SELECT ${this.#messageColumns()}
1040
+ FROM "message" m
1041
+ ${this.#joins()}
1042
+ WHERE m."guid" = ?
1043
+ LIMIT 1`).all(guid);
1044
+ return rows[0] ? this.#toRow(rows[0]) : null;
1045
+ }
1046
+ /** Tapbacks aimed at one message. */
1047
+ reactionsFor(guid) {
1048
+ if (!this.caps.hasReactions) return [];
1049
+ return this.db.prepare(`SELECT m."associated_message_type" AS type, h."id" AS handle
1050
+ FROM "message" m
1051
+ LEFT JOIN "handle" h ON h."ROWID" = m."handle_id"
1052
+ WHERE m."associated_message_guid" LIKE ? ESCAPE '\\'
1053
+ AND m."associated_message_type" > 0
1054
+ ORDER BY m."date" ASC`).all(`%${escapeLike(guid)}`).flatMap((r) => {
1055
+ const type = num(r.type);
1056
+ if (type === null) return [];
1057
+ return [{
1058
+ type,
1059
+ label: reactionLabel(type),
1060
+ handle: text(r.handle)
1061
+ }];
1062
+ });
1063
+ }
1064
+ /**
1065
+ * The attachments on one message.
1066
+ *
1067
+ * `id` is `attachment.guid`, for the reason `ref.ts` gives at length about
1068
+ * messages: the ROWID is faster and gets REUSED. Every "delete this
1069
+ * conversation" frees a block of attachment ids for the next insert, so a
1070
+ * caller that listed attachments in one turn and saved one two turns later
1071
+ * would write out a different file, silently. The guid is `UNIQUE NOT NULL`
1072
+ * in the shipped schema and is what Apple's own sync joins on.
1073
+ *
1074
+ * `path` is the raw `filename` column, reported so a caller can see WHERE the
1075
+ * bytes are before asking for them — it is frequently `~`-prefixed, and it is
1076
+ * empty for an attachment iCloud has offloaded.
1077
+ */
1078
+ attachmentsFor(guid) {
1079
+ if (!this.caps.hasAttachments) return [];
1080
+ const a = this.caps.attachmentColumns;
1081
+ return this.db.prepare(`SELECT ${this.#col(a, "a", "guid", "id")},
1082
+ ${this.#col(a, "a", "filename", "path")},
1083
+ ${this.#col(a, "a", "mime_type", "mimeType")},
1084
+ ${this.#col(a, "a", "transfer_name", "transferName")},
1085
+ ${this.#col(a, "a", "total_bytes", "bytes")},
1086
+ ${this.#col(a, "a", "is_sticker", "isSticker")}
1087
+ FROM "attachment" a
1088
+ JOIN "message_attachment_join" maj ON maj."attachment_id" = a."ROWID"
1089
+ JOIN "message" m ON m."ROWID" = maj."message_id"
1090
+ WHERE m."guid" = ?`).all(guid).map((r) => ({
1091
+ id: text(r.id),
1092
+ path: text(r.path),
1093
+ mimeType: text(r.mimeType),
1094
+ transferName: text(r.transferName),
1095
+ bytes: num(r.bytes),
1096
+ isSticker: num(r.isSticker) === 1
1097
+ }));
1098
+ }
1099
+ /** One attachment by its guid, wherever it hangs. Null when it is gone. */
1100
+ attachmentById(id) {
1101
+ if (!this.caps.hasAttachments) return null;
1102
+ const a = this.caps.attachmentColumns;
1103
+ if (!a.has("guid")) return null;
1104
+ const row = this.db.prepare(`SELECT ${this.#col(a, "a", "guid", "id")},
1105
+ ${this.#col(a, "a", "filename", "path")},
1106
+ ${this.#col(a, "a", "mime_type", "mimeType")},
1107
+ ${this.#col(a, "a", "transfer_name", "transferName")},
1108
+ ${this.#col(a, "a", "total_bytes", "bytes")}
1109
+ FROM "attachment" a WHERE a."guid" = ? LIMIT 1`).get(id);
1110
+ if (!row) return null;
1111
+ return {
1112
+ id: text(row.id),
1113
+ path: text(row.path),
1114
+ mimeType: text(row.mimeType),
1115
+ transferName: text(row.transferName),
1116
+ bytes: num(row.bytes)
1117
+ };
1118
+ }
1119
+ chats(limit) {
1120
+ return this.#chats("", [], limit);
1121
+ }
1122
+ /** One chat, by the guid a ref carries. Null when it has been deleted. */
1123
+ chatByGuid(guid) {
1124
+ return this.#chats(`WHERE c."guid" = ?`, [guid], 1)[0] ?? null;
1125
+ }
1126
+ /**
1127
+ * The chats that already exist with a set of handles, newest first.
1128
+ *
1129
+ * This is what makes a send addressable at all. Messages will not enumerate
1130
+ * participants for a script, so the write lane cannot look a person up — but
1131
+ * it can address a chat by guid, and the guid lives here. The read lane
1132
+ * choosing the target for the write lane is the whole arrangement; see
1133
+ * `client/jxa/core.ts`.
1134
+ *
1135
+ * Handles are matched as given. Suffix matching happens a layer up in
1136
+ * `client/messages.ts`, where `packages/contacts`' measured `suffixKey` is
1137
+ * available and the candidate list is the store's own 1,075 handles.
1138
+ */
1139
+ chatsForHandles(handles, limit = 10) {
1140
+ if (!handles.length) return [];
1141
+ const marks = handles.map(() => "?").join(", ");
1142
+ return this.#chats(`WHERE c."ROWID" IN (
1143
+ SELECT chj."chat_id"
1144
+ FROM "chat_handle_join" chj
1145
+ JOIN "handle" h2 ON h2."ROWID" = chj."handle_id"
1146
+ WHERE h2."id" IN (${marks}))`, [...handles], limit);
1147
+ }
1148
+ /**
1149
+ * Outgoing messages in a set of chats since an instant — the send's receipt.
1150
+ *
1151
+ * `docs/messages.md` recorded that Apple Events returns no chat identifier, so
1152
+ * a send "cannot report what it wrote by id". That is true of the write lane
1153
+ * alone and false of the pair: the row Messages writes for an outgoing message
1154
+ * is an ordinary row in this table, and a narrow window plus the target chat
1155
+ * identifies it. Matching on text as well would be wrong — two identical
1156
+ * messages a minute apart are a normal thing to send — so the caller passes a
1157
+ * `sinceApple` taken immediately BEFORE the send and takes the oldest match.
1158
+ */
1159
+ sentSince(chatGuids, sinceApple, limit = 10) {
1160
+ const m = this.caps.messageColumns;
1161
+ if (!chatGuids.length || !m.has("date")) return [];
1162
+ const marks = chatGuids.map(() => "?").join(", ");
1163
+ const fromMe = m.has("is_from_me") ? `AND m."is_from_me" = 1` : "";
1164
+ return this.db.prepare(`SELECT ${this.#messageColumns()}
1165
+ FROM "message" m
1166
+ ${this.#joins()}
1167
+ WHERE c."guid" IN (${marks})
1168
+ AND ${appleSecondsSql("m.\"date\"")} >= ?
1169
+ ${fromMe}
1170
+ ORDER BY m."date" ASC
1171
+ LIMIT ${Math.max(1, Math.trunc(limit))}`).all(...[...chatGuids, sinceApple]).map((r) => this.#toRow(r));
1172
+ }
1173
+ #chats(where, params, limit) {
1174
+ const c = this.caps.chatColumns;
1175
+ const rows = this.db.prepare(`SELECT c."ROWID" AS rowid,
1176
+ c."guid" AS guid,
1177
+ ${this.#col(c, "c", "chat_identifier", "identifier")},
1178
+ ${this.#col(c, "c", "display_name", "displayName")},
1179
+ ${this.#col(c, "c", "style")},
1180
+ ${this.#col(c, "c", "service_name", "service")},
1181
+ COUNT(cmj."message_id") AS messages,
1182
+ ${appleSecondsSql("MAX(m.\"date\")")} AS lastMessageAt
1183
+ FROM "chat" c
1184
+ LEFT JOIN "chat_message_join" cmj ON cmj."chat_id" = c."ROWID"
1185
+ LEFT JOIN "message" m ON m."ROWID" = cmj."message_id"
1186
+ ${where}
1187
+ GROUP BY c."ROWID"
1188
+ ORDER BY MAX(m."date") DESC
1189
+ LIMIT ${Math.max(1, Math.trunc(limit))}`).all(...params);
1190
+ const participants = this.#participants(rows.map((r) => Number(r.rowid)));
1191
+ return rows.map((r) => {
1192
+ const style = num(r.style);
1193
+ return {
1194
+ guid: String(r.guid),
1195
+ identifier: text(r.identifier),
1196
+ displayName: text(r.displayName),
1197
+ style,
1198
+ isGroup: style === 43 || (participants.get(Number(r.rowid))?.length ?? 0) > 1,
1199
+ service: text(r.service),
1200
+ participants: participants.get(Number(r.rowid)) ?? [],
1201
+ messages: Number(r.messages ?? 0),
1202
+ lastMessageAt: num(r.lastMessageAt)
1203
+ };
1204
+ });
1205
+ }
1206
+ #participants(chatRowIds) {
1207
+ const out = /* @__PURE__ */ new Map();
1208
+ if (!chatRowIds.length) return out;
1209
+ const marks = chatRowIds.map(() => "?").join(", ");
1210
+ const rows = this.db.prepare(`SELECT chj."chat_id" AS chatId, h."id" AS handle
1211
+ FROM "chat_handle_join" chj
1212
+ JOIN "handle" h ON h."ROWID" = chj."handle_id"
1213
+ WHERE chj."chat_id" IN (${marks})`).all(...chatRowIds);
1214
+ for (const r of rows) {
1215
+ const id = Number(r.chatId);
1216
+ const handle = text(r.handle);
1217
+ if (!handle) continue;
1218
+ const bucket = out.get(id);
1219
+ if (bucket) bucket.push(handle);
1220
+ else out.set(id, [handle]);
1221
+ }
1222
+ return out;
1223
+ }
1224
+ /** Every distinct handle in the store, for a bulk resolve. */
1225
+ handles() {
1226
+ return this.db.prepare(`SELECT DISTINCT "id" AS id FROM "handle" WHERE "id" IS NOT NULL AND "id" <> ''`).all().flatMap((r) => {
1227
+ const h = text(r.id);
1228
+ return h ? [h] : [];
1229
+ });
1230
+ }
1231
+ counts() {
1232
+ const one = (sql) => {
1233
+ try {
1234
+ return Number(this.db.prepare(sql).get().c ?? 0);
1235
+ } catch {
1236
+ return 0;
1237
+ }
1238
+ };
1239
+ return {
1240
+ messages: one(`SELECT COUNT(*) AS c FROM "message"`),
1241
+ chats: one(`SELECT COUNT(*) AS c FROM "chat"`),
1242
+ handles: one(`SELECT COUNT(*) AS c FROM "handle"`),
1243
+ attachments: this.caps.hasAttachments ? one(`SELECT COUNT(*) AS c FROM "attachment"`) : 0
1244
+ };
1245
+ }
1246
+ close() {
1247
+ try {
1248
+ this.db.close();
1249
+ } catch {}
1250
+ }
1251
+ };
1252
+ const introspect = (db) => {
1253
+ const messageColumns = new Set(columnsOf(db, "message"));
1254
+ const chatColumns = new Set(columnsOf(db, "chat"));
1255
+ const handleColumns = new Set(columnsOf(db, "handle"));
1256
+ for (const t of REQUIRED) if ((t === "message" ? messageColumns : t === "chat" ? chatColumns : handleColumns).size === 0) throw new SchemaDriftError(`This Messages store has no ${t} table. It was probed on macOS ${PROBED_MACOS} with schema fingerprint ${PROBED_FINGERPRINT} (a PROBE fingerprint — compare it against another probe run, not against the one diagnostics reports); re-run \`pnpm probe:messages\` to see what changed.`);
1257
+ const attachmentColumns = new Set(columnsOf(db, "attachment"));
1258
+ return {
1259
+ fingerprint: fingerprintSchema(db),
1260
+ messageColumns,
1261
+ chatColumns,
1262
+ handleColumns,
1263
+ attachmentColumns,
1264
+ hasAttachments: attachmentColumns.size > 0 && columnsOf(db, "message_attachment_join").length > 0,
1265
+ hasReactions: messageColumns.has("associated_message_type"),
1266
+ hasThreads: messageColumns.has("thread_originator_guid"),
1267
+ hasEdits: messageColumns.has("date_edited")
1268
+ };
1269
+ };
1270
+ const openStore = (path, mode, logger) => {
1271
+ if (!path) return null;
1272
+ const { db, mode: used, validated } = openReadOnly(path, mode, {
1273
+ label: "Messages store",
1274
+ envVar: "APPLE_MESSAGES_INDEX_MODE",
1275
+ validate: introspect,
1276
+ fatal: (err) => err instanceof SchemaDriftError,
1277
+ onFallback: () => logger?.debug?.("opened the Messages store with immutable=1, which skips the write-ahead log — recent messages may be missing until Messages checkpoints. The measured WAL was ~0.5 MB.")
1278
+ });
1279
+ return new MessagesStore(db, used, validated);
1280
+ };
1281
+ //#endregion
1282
+ //#region src/client/messages.ts
1283
+ var AppleMessagesClient = class {
1284
+ #config;
1285
+ #logger;
1286
+ #home;
1287
+ #located = null;
1288
+ #store = null;
1289
+ #storeTried = false;
1290
+ #contacts;
1291
+ #contactsTried;
1292
+ #resolved = /* @__PURE__ */ new Map();
1293
+ /**
1294
+ * Built even when writes are off, and that costs nothing.
1295
+ *
1296
+ * `createOsascriptRunner` spawns no process until something calls it, and with
1297
+ * `allowWrites` off nothing does — the send tool is never registered. So this
1298
+ * server still sends no Apple Event and still asks for no Automation grant in
1299
+ * its default configuration, which is the claim `surfaces.json` makes.
1300
+ */
1301
+ #runner;
1302
+ constructor(opts) {
1303
+ this.#config = opts.config;
1304
+ this.#logger = opts.logger;
1305
+ this.#home = opts.home;
1306
+ this.#contacts = opts.contacts ?? null;
1307
+ this.#contactsTried = opts.contacts !== void 0;
1308
+ this.#runner = opts.osascript ?? createOsascriptRunner({
1309
+ surface: MESSAGES_SURFACE,
1310
+ osascriptPath: opts.config.osascriptPath,
1311
+ timeoutMs: opts.config.osascriptTimeoutMs,
1312
+ ...opts.logger ? { logger: opts.logger } : {}
1313
+ });
1314
+ }
1315
+ get config() {
1316
+ return this.#config;
1317
+ }
1318
+ located() {
1319
+ this.#located ??= locateStore({
1320
+ storePath: this.#config.storePath,
1321
+ ...this.#home ? { home: this.#home } : {}
1322
+ });
1323
+ return this.#located;
1324
+ }
1325
+ store() {
1326
+ if (this.#storeTried) return this.#store;
1327
+ this.#storeTried = true;
1328
+ if (this.#config.indexMode === "off") return null;
1329
+ const located = this.located();
1330
+ if (!located.readable) return null;
1331
+ const mode = this.#config.indexMode === "auto" ? "ro" : this.#config.indexMode;
1332
+ this.#store = openStore(located.storePath, mode, this.#logger);
1333
+ return this.#store;
1334
+ }
1335
+ /**
1336
+ * Never `[]` when the store is missing.
1337
+ *
1338
+ * An empty list is a valid answer to "no messages match", and this surface has
1339
+ * no second lane to produce one from. Callers get a reason instead — and on
1340
+ * this surface the reason is load-bearing, because the fix is a permission.
1341
+ */
1342
+ #require() {
1343
+ const store = this.store();
1344
+ if (store) return store;
1345
+ if (this.#config.indexMode === "off") throw new IndexUnavailableError("The Messages index is disabled (APPLE_MESSAGES_INDEX_MODE=off). This surface has no Apple Events read lane at all, so nothing can be read until it is re-enabled.");
1346
+ throw new MessagesUnavailableError(this.located().reason ?? "No readable Messages store was found.");
1347
+ }
1348
+ #contactsClient() {
1349
+ if (this.#contactsTried) return this.#contacts;
1350
+ this.#contactsTried = true;
1351
+ if (!this.#config.resolveContacts) return null;
1352
+ try {
1353
+ this.#contacts = new AppleContactsClient({
1354
+ config: loadConfig({}),
1355
+ ...this.#logger ? { logger: this.#logger } : {},
1356
+ ...this.#home ? { home: this.#home } : {}
1357
+ });
1358
+ } catch (err) {
1359
+ this.#logger?.debug?.(`contacts resolver unavailable: ${String(err)}`);
1360
+ this.#contacts = null;
1361
+ }
1362
+ return this.#contacts;
1363
+ }
1364
+ /**
1365
+ * Resolve a batch, memoised for the life of the process.
1366
+ *
1367
+ * Batched because building the lookup walks every contact once (3 ms on the
1368
+ * measured store) and every subsequent handle is a map hit. Memoised because a
1369
+ * conversation renders the same correspondent hundreds of times.
1370
+ */
1371
+ #resolve(handles) {
1372
+ const wanted = [...new Set(handles.filter((h) => Boolean(h)))].filter((h) => !this.#resolved.has(h));
1373
+ if (!wanted.length) return;
1374
+ const contacts = this.#contactsClient();
1375
+ if (!contacts) return;
1376
+ try {
1377
+ for (const r of contacts.resolve(wanted).results) this.#resolved.set(r.handle, r);
1378
+ } catch (err) {
1379
+ this.#logger?.debug?.(`resolve failed: ${String(err)}`);
1380
+ this.#contacts = null;
1381
+ }
1382
+ }
1383
+ #correspondent(handle) {
1384
+ if (!handle) return {
1385
+ handle: null,
1386
+ name: null,
1387
+ resolution: "unknown"
1388
+ };
1389
+ const hit = this.#resolved.get(handle);
1390
+ if (!hit) return {
1391
+ handle,
1392
+ name: null,
1393
+ resolution: this.#contacts ? "unknown" : "unavailable"
1394
+ };
1395
+ return {
1396
+ handle,
1397
+ name: hit.name,
1398
+ resolution: hit.status
1399
+ };
1400
+ }
1401
+ #render(rows) {
1402
+ this.#resolve(rows.map((r) => r.handle));
1403
+ return rows.map((r) => ({
1404
+ ref: encodeMessageRef(r.guid),
1405
+ chatRef: r.chatGuid ? encodeChatRef(r.chatGuid) : null,
1406
+ chat: r.chatName,
1407
+ from: r.isFromMe ? {
1408
+ handle: null,
1409
+ name: "me",
1410
+ resolution: "self"
1411
+ } : this.#correspondent(r.handle),
1412
+ fromMe: r.isFromMe,
1413
+ sentAt: renderInstant(r.sentAt),
1414
+ editedAt: renderInstant(r.editedAt),
1415
+ text: r.text,
1416
+ textSource: r.textSource,
1417
+ subject: r.subject,
1418
+ service: r.service,
1419
+ isRead: r.isRead,
1420
+ hasAttachments: r.hasAttachments,
1421
+ ...r.threadOriginator ? { replyToRef: encodeMessageRef(r.threadOriginator) } : {},
1422
+ ...r.itemType ? { itemType: r.itemType } : {}
1423
+ }));
1424
+ }
1425
+ listMessages(opts) {
1426
+ return this.#render(this.#require().range({
1427
+ ...opts.chatRef ? { chatGuid: opts.chatRef } : {},
1428
+ ...opts.fromApple === void 0 ? {} : { fromApple: opts.fromApple },
1429
+ ...opts.toApple === void 0 ? {} : { toApple: opts.toApple },
1430
+ ...opts.includeReactions === void 0 ? {} : { includeReactions: opts.includeReactions },
1431
+ limit: opts.limit ?? this.#config.maxResults
1432
+ }));
1433
+ }
1434
+ searchMessages(query, limit) {
1435
+ return this.#render(this.#require().search(query, limit ?? this.#config.maxResults));
1436
+ }
1437
+ getMessage(guid) {
1438
+ const store = this.#require();
1439
+ const row = store.byGuid(guid);
1440
+ if (!row) return null;
1441
+ const reactions = store.reactionsFor(guid);
1442
+ this.#resolve(reactions.map((r) => r.handle));
1443
+ const [rendered] = this.#render([row]);
1444
+ return {
1445
+ ...rendered,
1446
+ reactions: reactions.map((r) => ({
1447
+ label: r.label,
1448
+ from: this.#correspondent(r.handle)
1449
+ })),
1450
+ attachments: store.attachmentsFor(guid)
1451
+ };
1452
+ }
1453
+ /**
1454
+ * Copy one attachment out of the Messages store onto disk.
1455
+ *
1456
+ * ## Why this is a copy and not an extraction
1457
+ *
1458
+ * Mail's equivalent has to parse MIME out of an `.emlx` because the bytes are
1459
+ * inside the message file. Messages does not work that way: `attachment` rows
1460
+ * point at real files under `~/Library/Messages`, so the work here is finding
1461
+ * the row, deciding the path is one we are willing to read, and copying.
1462
+ *
1463
+ * ## Two boundaries, not one
1464
+ *
1465
+ * The DESTINATION boundary is the same one Mail and Notes enforce:
1466
+ * `attachmentDir` is a confinement, `directory` may only select inside it, and
1467
+ * the leaf name is `basename`d because it comes from whoever sent the message.
1468
+ *
1469
+ * The SOURCE boundary is this surface's own. `filename` comes out of a
1470
+ * database this server never writes, and it is a fully-qualified path: taken
1471
+ * at face value it names any file the process can read. So it is required to
1472
+ * resolve inside the Messages root before a single byte is read. That check
1473
+ * has never fired on a real store and is not expected to — it is here so that
1474
+ * the day the schema surprises us, the surprise is a refusal.
1475
+ */
1476
+ async saveAttachment(attachmentId, opts = {}) {
1477
+ const meta = this.#require().attachmentById(attachmentId);
1478
+ if (!meta) throw new PreconditionError(`No attachment with id "${attachmentId}". Ids come from apple_messages_get_message and are the attachment's guid; the message may have been deleted since it was listed.`);
1479
+ if (!meta.path) throw new PreconditionError("That attachment has no file path in the store, which normally means iCloud has offloaded it. Open the conversation in Messages to download it, then try again.");
1480
+ const home = this.#home ?? homedir();
1481
+ const expanded = meta.path.startsWith("~/") ? join(home, meta.path.slice(2)) : meta.path;
1482
+ const source = resolve(expanded);
1483
+ const root = resolve(this.located().messagesRoot);
1484
+ if (source !== root && !source.startsWith(root + sep)) throw new PreconditionError(`Refusing to read ${source}: it is outside ${root}, and this tool only copies files Messages itself stores.`);
1485
+ if (!existsSync(source) || !statSync(source).isFile()) throw new PreconditionError(`The store names ${source} but there is no file there. Messages prunes attachment bytes while keeping the row, so this is a normal state for an old conversation.`);
1486
+ const dest = resolve(this.#config.attachmentDir);
1487
+ const dir = opts.directory ? resolve(dest, opts.directory) : dest;
1488
+ if (dir !== dest && !dir.startsWith(dest + sep)) throw new PreconditionError(`Refusing to write outside ${dest}. Set APPLE_MESSAGES_ATTACHMENT_DIR to change the destination.`);
1489
+ const name = basename(meta.transferName ?? basename(source));
1490
+ const target = resolve(join(dir, name));
1491
+ if (target !== join(dir, name) || !target.startsWith(dir + sep)) throw new PreconditionError(`Refusing to write outside ${dir}.`);
1492
+ if (existsSync(target) && !opts.overwrite) throw new PreconditionError(`${target} already exists; refusing to overwrite it.`);
1493
+ const bytes = readFileSync(source);
1494
+ mkdirSync(dir, { recursive: true });
1495
+ writeFileSync(target, bytes, { mode: 384 });
1496
+ return {
1497
+ path: target,
1498
+ bytes: bytes.length,
1499
+ source,
1500
+ mimeType: meta.mimeType
1501
+ };
1502
+ }
1503
+ listChats(limit) {
1504
+ const rows = this.#require().chats(limit ?? this.#config.maxResults);
1505
+ this.#resolve(rows.flatMap((c) => c.participants));
1506
+ return rows.map((c) => ({
1507
+ ref: encodeChatRef(c.guid),
1508
+ name: c.displayName ?? (c.participants.length === 1 && c.participants[0] ? this.#correspondent(c.participants[0]).name ?? c.participants[0] : null),
1509
+ isGroup: c.isGroup,
1510
+ service: c.service,
1511
+ participants: c.participants.map((h) => this.#correspondent(h)),
1512
+ messages: c.messages,
1513
+ lastMessageAt: renderInstant(c.lastMessageAt)
1514
+ }));
1515
+ }
1516
+ /**
1517
+ * Pick the chat to address, using the store.
1518
+ *
1519
+ * Handles are matched the way `packages/contacts` measured them rather than by
1520
+ * string equality: a caller who types `06 12 34 56 78` and a store that holds
1521
+ * `+33612345678` are the same person, and `suffixKey`'s last-9 rule is what
1522
+ * says so. The candidate set is the store's own handles — 1,075 on the
1523
+ * measured machine — so this is a scan over a list that is already in memory.
1524
+ */
1525
+ #chatFor(input) {
1526
+ const store = this.store();
1527
+ if (input.chatRef) {
1528
+ const guid = decodeChatRef(input.chatRef);
1529
+ const chat = store?.chatByGuid(guid) ?? null;
1530
+ if (store && !chat) throw new ChatNotFoundError(input.chatRef);
1531
+ return {
1532
+ guid,
1533
+ chat,
1534
+ handle: chat?.participants[0] ?? null
1535
+ };
1536
+ }
1537
+ const to = input.to?.trim();
1538
+ if (!to) return {
1539
+ guid: null,
1540
+ chat: null,
1541
+ handle: null
1542
+ };
1543
+ if (!store) return {
1544
+ guid: null,
1545
+ chat: null,
1546
+ handle: to
1547
+ };
1548
+ const kind = handleKind(to);
1549
+ const wantedSuffix = kind === "phone" ? suffixKey(to) : null;
1550
+ const wantedEmail = kind === "email" ? emailKey(to) : null;
1551
+ const candidates = store.handles().filter((h) => {
1552
+ if (h === to) return true;
1553
+ if (wantedEmail) return emailKey(h) === wantedEmail;
1554
+ if (wantedSuffix) return suffixKey(h) === wantedSuffix;
1555
+ return false;
1556
+ });
1557
+ if (!candidates.length) return {
1558
+ guid: null,
1559
+ chat: null,
1560
+ handle: to
1561
+ };
1562
+ const direct = store.chatsForHandles(candidates, 25).find((c) => !c.isGroup) ?? null;
1563
+ return {
1564
+ guid: direct?.guid ?? null,
1565
+ chat: direct,
1566
+ handle: candidates[0] ?? to
1567
+ };
1568
+ }
1569
+ /**
1570
+ * Find the row Messages wrote, or say plainly that it was not found.
1571
+ *
1572
+ * `docs/messages.md` left this as an open question — "whether a send should
1573
+ * re-resolve by scanning the store for a recent row on the target chat" — and
1574
+ * this is the answer, because the alternative is a send that can report
1575
+ * nothing at all. Apple Events hands back no identifier of any kind.
1576
+ *
1577
+ * Three things make the match safe rather than merely plausible:
1578
+ *
1579
+ * 1. `since` is taken BEFORE the send, so nothing earlier can match.
1580
+ * 2. The window is scoped to the target chat.
1581
+ * 3. Text is compared when it is available, which separates our row from one
1582
+ * the user sent from their phone in the same second. Only when it is not
1583
+ * available does the oldest row in the window win.
1584
+ *
1585
+ * A miss is `pending`, never an error: the send already happened, and iMessage
1586
+ * writes its row asynchronously. Reporting a failure there would be the worst
1587
+ * possible lie — it would invite a retry that sends the message twice.
1588
+ */
1589
+ async #reconcile(guid, since, text) {
1590
+ const deadline = Date.now() + this.#config.sendReconcileMs;
1591
+ for (;;) {
1592
+ this.#invalidate();
1593
+ const store = this.store();
1594
+ if (!store) return null;
1595
+ const rows = store.sentSince([guid], since, 10);
1596
+ const hit = rows.find((r) => r.text !== null && r.text === text) ?? rows[0];
1597
+ if (hit) return this.#render([hit])[0] ?? null;
1598
+ if (Date.now() >= deadline) return null;
1599
+ await new Promise((resolve) => setTimeout(resolve, 250));
1600
+ }
1601
+ }
1602
+ #invalidate() {
1603
+ this.#store?.close();
1604
+ this.#store = null;
1605
+ this.#storeTried = false;
1606
+ }
1607
+ /**
1608
+ * Send one message. A real one, to a real person, immediately.
1609
+ *
1610
+ * Everything difficult about this is in `client/jxa/core.ts`; what is left
1611
+ * here is choosing the target from the file lane and reconciling afterwards.
1612
+ */
1613
+ async sendMessage(input) {
1614
+ const { guid, chat, handle } = this.#chatFor(input);
1615
+ const since = toAppleSeconds(/* @__PURE__ */ new Date(Date.now() - 2e3));
1616
+ let data;
1617
+ try {
1618
+ data = await withBusyRetry(() => this.#runner.run(SEND_MESSAGE, {
1619
+ ...guid ? { chatGuid: guid } : {},
1620
+ ...handle ? { handle } : {},
1621
+ ...input.service ? { service: input.service } : {},
1622
+ text: input.text,
1623
+ allowLaunch: true
1624
+ }));
1625
+ } catch (err) {
1626
+ const details = err?.details;
1627
+ const attempts = Array.isArray(details?.detail) ? details.detail : [];
1628
+ const message = err instanceof Error ? err.message : String(err);
1629
+ if (details?.code === "SEND_TARGET_NOT_FOUND") throw new SendTargetNotFoundError(input.chatRef ?? input.to ?? "", attempts);
1630
+ if (details?.code === "SEND_FAILED") throw new SendFailedError(message, attempts);
1631
+ throw err;
1632
+ }
1633
+ const message = guid ? await this.#reconcile(guid, since, input.text) : null;
1634
+ if (handle) this.#resolve([handle]);
1635
+ return {
1636
+ sent: true,
1637
+ strategy: typeof data.strategy === "string" ? data.strategy : "unknown",
1638
+ targetKind: typeof data.targetKind === "string" ? data.targetKind : "unknown",
1639
+ chatRef: guid ? encodeChatRef(guid) : null,
1640
+ chat: chat?.displayName ?? null,
1641
+ to: handle ? this.#correspondent(handle) : null,
1642
+ launched: data.launched === true,
1643
+ reconciliation: message ? "matched" : guid ? "pending" : "unavailable",
1644
+ message,
1645
+ ...message ? {} : { note: guid ? "Messages accepted the send, but no matching row had appeared in the store yet. That is normal for a slow network — re-run apple_messages_list_messages on this chat rather than sending again." : "Messages accepted the send, but there was no existing chat to reconcile it against, so no ref can be reported for it. Read the chat back once it exists." }
1646
+ };
1647
+ }
1648
+ /** Bounds for a range query, as apple-seconds. */
1649
+ window(from, to) {
1650
+ return {
1651
+ ...from ? { fromApple: toAppleSeconds(from) } : {},
1652
+ ...to ? { toApple: toAppleSeconds(to) } : {}
1653
+ };
1654
+ }
1655
+ status() {
1656
+ const store = this.store();
1657
+ return {
1658
+ located: this.located(),
1659
+ store: {
1660
+ opened: Boolean(store),
1661
+ mode: store?.mode ?? null,
1662
+ fingerprint: store?.caps.fingerprint ?? null
1663
+ },
1664
+ counts: store?.counts() ?? null,
1665
+ contacts: {
1666
+ enabled: this.#config.resolveContacts,
1667
+ available: Boolean(this.#contactsClient()),
1668
+ resolved: this.#resolved.size
1669
+ }
1670
+ };
1671
+ }
1672
+ close() {
1673
+ this.#store?.close();
1674
+ this.#store = null;
1675
+ this.#storeTried = false;
1676
+ this.#contacts?.close();
1677
+ this.#resolved.clear();
1678
+ }
1679
+ };
1680
+ //#endregion
1681
+ //#region src/config.ts
1682
+ /**
1683
+ * Configuration is environment-only — this server holds no secret at all, its
1684
+ * access is the macOS permission the user granted.
1685
+ *
1686
+ * `allowWrites` is inherited from `BaseConfigSchema` and, since 1.2.0, means
1687
+ * something here: it gates the one mutating tool, `send_message`. With it off
1688
+ * this server registers no write tool and therefore sends no Apple Event at all,
1689
+ * which is what keeps its "no Automation grant" claim true by default.
1690
+ *
1691
+ * `docs/messages.md` recorded why sending went unprobed for so long — "probing
1692
+ * it would mean sending a real message to a real person" — and left the id
1693
+ * bridge open, because Apple Events returns no chat identifier to reconcile
1694
+ * against. `sendReconcileMs` is that decision made: the file lane finds the row
1695
+ * instead.
1696
+ */
1697
+ const ConfigSchema = BaseConfigSchema.extend({
1698
+ /** Explicit store path. Bypasses discovery — for tests and forensic copies. */
1699
+ storePath: z.string().optional(),
1700
+ indexMode: z.enum([
1701
+ "auto",
1702
+ "ro",
1703
+ "immutable",
1704
+ "off"
1705
+ ]).default("auto"),
1706
+ /**
1707
+ * Look names up in Contacts.
1708
+ *
1709
+ * On by default: without it this server answers "+15551234567 said …", which
1710
+ * is why `packages/contacts` was built. Off is for a machine where the
1711
+ * Contacts permission has not been granted and the prompt is unwelcome — the
1712
+ * resolver degrades to raw handles either way, so this only decides whether it
1713
+ * is attempted.
1714
+ */
1715
+ resolveContacts: z.boolean().default(true),
1716
+ /**
1717
+ * The only directory `save_attachment` may write into.
1718
+ *
1719
+ * A boundary rather than a default: the tool's `directory` argument selects a
1720
+ * subdirectory of this and cannot escape it. Saving is write-gated for the
1721
+ * same reason it is in Mail and Notes — it puts a file on the user's disk,
1722
+ * even though it changes nothing in Messages.
1723
+ */
1724
+ attachmentDir: z.string().default(join(homedir(), "Downloads")),
1725
+ /** Window for a range query that names only a start. */
1726
+ defaultRangeDays: z.number().int().min(1).max(3660).default(30),
1727
+ /**
1728
+ * How long to wait for a sent message to appear in the store.
1729
+ *
1730
+ * Messages writes the outgoing row asynchronously, so a send that returns
1731
+ * instantly has usually not been written yet. Five seconds is generous for a
1732
+ * local write and short enough that a tool call does not hang on a bad
1733
+ * network; a miss is reported as `pending`, never as a failure. Zero means one
1734
+ * immediate check and no polling, which is what the test suite uses.
1735
+ */
1736
+ sendReconcileMs: z.number().int().min(0).max(6e4).default(5e3)
1737
+ }).strict();
1738
+ const loadConfig$1 = (env = process.env) => parseConfig(ConfigSchema, {
1739
+ allowWrites: parseBool(env.APPLE_MESSAGES_ALLOW_WRITES),
1740
+ exposePrompts: parseBool(env.APPLE_MESSAGES_EXPOSE_PROMPTS),
1741
+ debug: parseBool(env.APPLE_MESSAGES_DEBUG),
1742
+ storePath: trimmed(env.APPLE_MESSAGES_STORE),
1743
+ indexMode: trimmed(env.APPLE_MESSAGES_INDEX_MODE),
1744
+ resolveContacts: parseBool(env.APPLE_MESSAGES_RESOLVE_CONTACTS),
1745
+ attachmentDir: trimmed(env.APPLE_MESSAGES_ATTACHMENT_DIR),
1746
+ defaultRangeDays: parseIntOpt(env.APPLE_MESSAGES_DEFAULT_RANGE_DAYS),
1747
+ sendReconcileMs: parseIntOpt(env.APPLE_MESSAGES_SEND_RECONCILE_MS),
1748
+ osascriptPath: trimmed(env.APPLE_MESSAGES_OSASCRIPT_PATH),
1749
+ osascriptTimeoutMs: parseIntOpt(env.APPLE_MESSAGES_OSASCRIPT_TIMEOUT_MS),
1750
+ maxResults: parseIntOpt(env.APPLE_MESSAGES_MAX_RESULTS)
1751
+ });
1752
+ //#endregion
1753
+ //#region src/guide.ts
1754
+ /**
1755
+ * The Messages operating manual, served as `cupertino://messages/guide` and
1756
+ * embedded ahead of every Messages prompt. Static by design — see the note in
1757
+ * the Mail guide.
1758
+ */
1759
+ const MESSAGES_GUIDE = `# Apple Messages — how to drive this server
1760
+
1761
+ ## Refs are opaque
1762
+
1763
+ Chats come back as \`mc1:<guid>\`, messages as \`m1:<guid>\`. Pass them back
1764
+ verbatim; never construct one.
1765
+
1766
+ ## Full Disk Access is mandatory here, unlike every other surface
1767
+
1768
+ There is no Apple Events read lane to fall back on. Measured: Messages answers
1769
+ "Application isn't running" even while it is running, because it is a windowless
1770
+ background process that declines to wake for a script. Without the grant this
1771
+ server can do **nothing** on the read side — that is a permission problem, not
1772
+ an empty message history.
1773
+
1774
+ ## Which tool, under which constraint
1775
+
1776
+ - **\`apple_messages_list_chats\`** first, when you need to know which
1777
+ conversation you are looking at.
1778
+ - **\`apple_messages_list_messages\`** reads one conversation in order.
1779
+ - **\`apple_messages_search_messages\`** searches across them.
1780
+ - **\`apple_messages_get_message\`** returns one message with its tapbacks and
1781
+ attachments.
1782
+
1783
+ ## Handles are not names
1784
+
1785
+ A chat identifies people by phone number or email address. Names come from
1786
+ Contacts, which has its own separate permission, so \`unknown\` is a normal
1787
+ outcome — about one in six of even the busiest correspondents has no card. When
1788
+ diagnostics reports \`contacts.available: false\`, **nobody looked at all** and
1789
+ every handle is raw. Do not present a raw handle as though the person is
1790
+ unidentified when the truth is that lookup was never possible.
1791
+
1792
+ ## Two things that look like missing messages and are not
1793
+
1794
+ **Recent messages are stored as blobs.** Messages stopped writing the plain
1795
+ \`text\` column between late February and late March 2026; everything since lives
1796
+ only as an archived blob, which this server decodes. \`textSource\` on each result
1797
+ says which lane answered. A reader without the decoder would report that the
1798
+ conversation stopped in February — if you ever see history that appears to end
1799
+ there, that is this, not silence.
1800
+
1801
+ **Tapbacks are rows in the message table.** They are filtered out of
1802
+ conversations and reported on the message they target. A "liked" is not a reply
1803
+ and should not be summarised as one.
1804
+
1805
+ ## Sending
1806
+
1807
+ \`apple_messages_send_message\` exists only when writes are enabled, and it is the
1808
+ **only** thing this server can change — the dictionary has no edit, delete,
1809
+ mark-as-read or reaction command.
1810
+
1811
+ Sending is real and immediate. There is no draft state, no undo, and no
1812
+ confirmation step after the call. Confirm the recipient handle with the user
1813
+ before sending, exactly as it will be used.
1814
+
1815
+ Messages hands back no identifier for what it sent, so the sent row is found by
1816
+ re-reading the store. \`reconciliation: "pending"\` means it has not appeared yet.
1817
+ That is **not a failure and must not be retried** — retrying sends the message
1818
+ twice.
1819
+ `;
1820
+ //#endregion
1821
+ //#region src/prompts.ts
1822
+ const CTX = {
1823
+ surface: "messages",
1824
+ guide: MESSAGES_GUIDE
1825
+ };
1826
+ /**
1827
+ * Messages' workflow prompts.
1828
+ *
1829
+ * The send prompt carries the only genuinely irreversible action in this
1830
+ * bundle. Everywhere else a mistake leaves a draft, an extra reminder or a
1831
+ * wrong-looking event; here it puts words in someone's pocket under the user's
1832
+ * name, with no undo and no draft state to catch it.
1833
+ */
1834
+ const registerPrompts = (server, allowWrites) => {
1835
+ registerWorkflowPrompt(server, CTX, {
1836
+ name: "apple_messages_catch_up",
1837
+ title: "Catch up on a conversation",
1838
+ description: "Read back what has been said in a chat and what is waiting on a reply, with handles resolved to names where that is actually possible. Read-only.",
1839
+ argsSchema: {
1840
+ chat: promptArg("Who or which chat — a name, a handle, or a chat ref. Omit for recent chats."),
1841
+ since: promptArg("How far back, e.g. \"yesterday\", \"last week\". Defaults to recent history.")
1842
+ },
1843
+ build: ({ chat, since }) => `Catch me up on ${chat ? `the conversation with ${chat}` : "my recent messages"}${since ? `, covering ${since}` : ""}.
1844
+
1845
+ 1. \`apple_messages_list_chats\` to find ${chat ? "the right conversation. If several match, ask which rather than picking." : "what has been active."}
1846
+ 2. \`apple_messages_list_messages\` over that chat${since ? " with a date bound" : ""}.
1847
+ 3. Summarise what was actually said and, most importantly, **what is waiting on
1848
+ me** — a question asked and not answered is the thing worth surfacing.
1849
+ 4. Where a handle has no name, say so and show the handle raw. Do not guess who
1850
+ it is from context. If \`contacts.available\` is false in diagnostics, say that
1851
+ names could not be looked up at all — that is different from these people
1852
+ being unknown.
1853
+
1854
+ Ignore tapbacks as messages; they are reactions on the message they target and
1855
+ a "liked" is not a reply. If the history appears to stop in early 2026, that is
1856
+ the blob-storage change described in the guide, not the end of the conversation
1857
+ — check \`textSource\` before reporting a gap.`
1858
+ });
1859
+ if (!allowWrites) return;
1860
+ registerWorkflowPrompt(server, CTX, {
1861
+ name: "apple_messages_send",
1862
+ title: "Send a message",
1863
+ description: "Compose and send an iMessage or SMS. Requires writes. Sending is immediate and cannot be undone — this prompt confirms the recipient before anything goes out.",
1864
+ argsSchema: {
1865
+ to: requiredPromptArg("Who to send to — a name, phone number or email address."),
1866
+ message: promptArg("What to say, or the gist of it. Omit to be asked.")
1867
+ },
1868
+ build: ({ to, message }) => `Send a message to ${to}.${message ? `\n\nWhat it should say: ${message}` : ""}
1869
+
1870
+ 1. Work out the exact handle. \`apple_messages_list_chats\` to find an existing
1871
+ conversation with this person — an established chat is better evidence of the
1872
+ right handle than an address book match, because it is the one they actually
1873
+ reply on.
1874
+ 2. Read the last few messages in that chat. It tells you which language they
1875
+ write in, how formal they are, and whether something is already pending.
1876
+ 3. **Show the user the exact handle and the exact text, and wait for them to
1877
+ confirm.** There is no draft state here and no undo: the call sends. A
1878
+ message to the wrong handle cannot be recalled, and it is sent under the
1879
+ user's name.
1880
+ 4. On confirmation, \`apple_messages_send_message\`.
1881
+ 5. If the result says \`reconciliation: "pending"\`, **the message was sent.**
1882
+ Messages returns no identifier, so the sent row is found by re-reading the
1883
+ store and has not appeared yet. Do not retry — retrying sends it twice. Say
1884
+ it went out and that confirmation is lagging.`
1885
+ });
1886
+ };
1887
+ //#endregion
1888
+ //#region src/tools/util.ts
1889
+ const chatRefArg = z.string().optional().describe("An opaque chat ref from apple_messages_list_chats (looks like \"mc1:<guid>\"). Do not construct one by hand.");
1890
+ const messageRefArg = z.string().min(1).describe("An opaque message ref from a list or search result (looks like \"m1:<guid>\"). Do not construct one by hand.");
1891
+ const fromArg = z.string().optional().describe("Start of the window, ISO-8601 — \"2026-08-01\" or \"2026-08-01T09:00\".");
1892
+ const toArg = z.string().optional().describe("End of the window, ISO-8601. Defaults to now.");
1893
+ const includeReactionsArg = z.boolean().optional().describe("Include tapbacks as if they were messages. Off by default, and you almost never want it on: a tapback renders as `Liked \"see you at 8\"`, which nobody typed. Use apple_messages_get_message to see the reactions on a specific message instead.");
1894
+ //#endregion
1895
+ //#region src/tools/diagnostics.ts
1896
+ /**
1897
+ * Build the report.
1898
+ *
1899
+ * Split out of the tool registration so the `cupertino://messages/diagnostics`
1900
+ * resource can serve the same bytes. Two renderings of one probe: duplicated,
1901
+ * the resource and the tool would drift, and the disagreement would surface as
1902
+ * "the diagnostics lied" — the one thing this file must never do.
1903
+ */
1904
+ const buildDiagnostics = async (client) => {
1905
+ const status = client.status();
1906
+ const writes = client.config.allowWrites;
1907
+ return {
1908
+ server: {
1909
+ name: BUILD_INFO.name,
1910
+ version: BUILD_INFO.version
1911
+ },
1912
+ settings: { exposePrompts: client.config.exposePrompts },
1913
+ lane: {
1914
+ reads: "file lane (read-only SQLite)",
1915
+ writes: writes ? "apple_messages_send_message, over Apple Events. The only mutating command the Messages dictionary offers." : "off — APPLE_MESSAGES_ALLOW_WRITES is not set, so the send tool is not registered and this server sends no Apple Event at all.",
1916
+ appleEvents: "no read path exists, and never will. Measured: every read attempt fails, and Messages answers \"Application isn't running\" even while it is running, because it is a windowless background process that declines to wake for a script. Sending is the one thing that works."
1917
+ },
1918
+ store: {
1919
+ path: status.located.storePath,
1920
+ exists: status.located.exists,
1921
+ readable: status.located.readable,
1922
+ opened: status.store.opened,
1923
+ mode: status.store.mode,
1924
+ fingerprint: status.store.fingerprint,
1925
+ counts: status.counts,
1926
+ reason: status.located.reason
1927
+ },
1928
+ contacts: status.contacts,
1929
+ caveats: [
1930
+ "Full Disk Access is MANDATORY here, unlike every other surface in this bundle. There is no Apple Events read lane to fall back to, so without the grant this server can do nothing at all.",
1931
+ "Messages stopped writing the plain `text` column between late February and late March 2026: every message since is stored only as an archived blob, which is decoded here. `textSource` on each result says which lane answered. Across all history the blob-only share is about 3%, but for anything recent it is ~100%, so a reader without the decoder would report that the conversation stopped in February.",
1932
+ "Names come from Contacts, which has its own separate permission. `unknown` is a normal outcome and not an error — about one in six of even the busiest correspondents has no contact card. When `contacts.available` is false, nobody looked at all and every handle is raw.",
1933
+ "Tapbacks are rows in the message table. They are filtered out of conversations by default and reported on the message they target instead.",
1934
+ writes ? "Sending is real and immediate, and it is the ONLY thing this server can change — the dictionary has no edit, delete, mark-as-read or reaction command. Messages hands back no identifier for what it sent, so the sent row is found by re-reading the store; `reconciliation: \"pending\"` means it has not appeared yet, which is not a failure and must not be retried." : "This server cannot send: APPLE_MESSAGES_ALLOW_WRITES is off. With it on, one tool appears — apple_messages_send_message — and it is the only mutating command the Messages dictionary offers."
1935
+ ]
1936
+ };
1937
+ };
1938
+ const registerDiagnosticsTools = (server, client) => {
1939
+ server.registerTool("apple_messages_diagnostics", {
1940
+ description: "Report whether the Messages store could be opened, how much is in it, whether names are being resolved, and what this server cannot do. Start here when a read returns nothing.",
1941
+ inputSchema: {},
1942
+ annotations: { readOnlyHint: true }
1943
+ }, async () => wrap(() => buildDiagnostics(client)));
1944
+ };
1945
+ //#endregion
1946
+ //#region src/tools/actions.ts
1947
+ const registerActionTools = (server, client) => {
1948
+ server.registerTool("apple_messages_send_message", {
1949
+ description: "Send a message to an existing conversation or to a phone number / email address. This sends a REAL message from the user's own iMessage/SMS account, immediately and irreversibly. There is no unsend, no draft and no preview: the recipient's phone buzzes as soon as this returns. Messages may be launched to do it, which the user will see. Confirm the exact wording and the exact recipient with the user before calling this. Prefer chatRef from apple_messages_list_chats over a raw handle: Messages refuses to enumerate participants for a script, so an existing conversation is the only target this server can address reliably — a handle with no conversation on this Mac will usually fail rather than start a new thread. The result reports which targeting strategy worked, and reconciles against the message store to hand back a real message ref: reconciliation `matched` means the sent row was found and `message` is it, `pending` means Messages accepted the send but has not written the row yet (normal on a slow network — read the chat back, do NOT send again), and `unavailable` means there was no existing chat to look in.",
1950
+ inputSchema: {
1951
+ chatRef: z.string().optional().describe("An opaque chat ref from apple_messages_list_chats (\"mc1:<guid>\"). The reliable way to target a send. Do not construct one by hand."),
1952
+ to: z.string().optional().describe("A phone number or email address, when there is no chat ref. Matched against the store's own handles by last-9-digits, so local and international spellings of the same number both work."),
1953
+ text: z.string().min(1).describe("Exactly what to send. Sent verbatim."),
1954
+ service: z.enum([
1955
+ "imessage",
1956
+ "sms",
1957
+ "rcs"
1958
+ ]).optional().describe("Which service to prefer when addressing a handle with no existing chat. Ignored when chatRef is given, since the chat already knows its service."),
1959
+ confirm: confirmArg
1960
+ },
1961
+ annotations: {
1962
+ readOnlyHint: false,
1963
+ destructiveHint: true,
1964
+ idempotentHint: false
1965
+ }
1966
+ }, async ({ chatRef, to, text, service }) => wrap(async () => {
1967
+ if (Boolean(chatRef) === Boolean(to)) throw new PreconditionError("Give exactly one of chatRef or to. chatRef targets an existing conversation and is the reliable form; to addresses a phone number or email directly.");
1968
+ return client.sendMessage({
1969
+ ...chatRef ? { chatRef } : {},
1970
+ ...to ? { to } : {},
1971
+ ...service ? { service } : {},
1972
+ text
1973
+ });
1974
+ }));
1975
+ };
1976
+ //#endregion
1977
+ //#region src/tools/attachments.ts
1978
+ /**
1979
+ * Saving an attachment out of a conversation.
1980
+ *
1981
+ * ## Why this is not in `actions.ts`
1982
+ *
1983
+ * That file's argument is that Messages' scripting dictionary has exactly one
1984
+ * usable verb, so this server has exactly one mutating tool — and that with
1985
+ * writes off it sends no Apple Event at all. Both remain true with this tool
1986
+ * registered: it opens a file and writes a file, and never speaks to
1987
+ * Messages.app. Filing it next to `send` would quietly weaken a claim
1988
+ * `diagnostics` makes about what the write gate buys.
1989
+ *
1990
+ * ## Why it is behind the write gate anyway
1991
+ *
1992
+ * It puts a file on the user's disk. Mail and Notes made the same call for the
1993
+ * same reason, and the three should not disagree about it: a user who has not
1994
+ * turned writes on has not agreed to this server creating files.
1995
+ */
1996
+ const registerAttachmentTools = (server, client) => {
1997
+ server.registerTool("apple_messages_save_attachment", {
1998
+ description: "Save one attachment from a conversation to disk — a photo, a video, a PDF, a voice memo. Take the `id` from an attachment on apple_messages_get_message. Needs Full Disk Access, like every read here. It can only write into APPLE_MESSAGES_ATTACHMENT_DIR (default ~/Downloads) and will not overwrite an existing file unless you ask it to. Write-gated because it puts a file on the user's disk, even though it changes nothing in Messages. An attachment iCloud has offloaded has no bytes on this Mac and is refused with that reason — open the conversation in Messages to pull it down first.",
1999
+ inputSchema: {
2000
+ attachmentId: z.string().min(1).describe("The `id` of an attachment from apple_messages_get_message. Opaque; do not construct one, and do not pass the file path shown next to it."),
2001
+ directory: z.string().optional().describe("Override the target directory. Must still resolve inside the configured one."),
2002
+ overwrite: z.boolean().optional().describe("Replace an existing file. Default false.")
2003
+ },
2004
+ annotations: {
2005
+ readOnlyHint: false,
2006
+ destructiveHint: false,
2007
+ idempotentHint: false
2008
+ }
2009
+ }, async ({ attachmentId, directory, overwrite }) => wrap(async () => client.saveAttachment(attachmentId, {
2010
+ ...directory ? { directory } : {},
2011
+ ...overwrite ? { overwrite } : {}
2012
+ })));
2013
+ };
2014
+ //#endregion
2015
+ //#region src/tools/chats.ts
2016
+ /**
2017
+ * NOTE ON `async` BELOW: core's `wrap` is typed `() => Promise<T>` because most
2018
+ * surfaces reach Apple Events. Messages reads synchronous SQLite and never
2019
+ * leaves the process, so the thunks are marked async here rather than widening a
2020
+ * shared signature for every surface to accommodate one.
2021
+ */
2022
+ const registerChatTools = (server, client) => {
2023
+ server.registerTool("apple_messages_list_chats", {
2024
+ description: "List conversations, most recently active first, with their participants and message counts. Names come from Contacts where they resolve; where they do not, the raw phone number or email is shown and `resolution` says why. Use the returned ref to read one conversation with apple_messages_list_messages.",
2025
+ inputSchema: { limit: limitArg },
2026
+ annotations: { readOnlyHint: true }
2027
+ }, async ({ limit }) => wrap(async () => client.listChats(limit)));
2028
+ };
2029
+ //#endregion
2030
+ //#region src/tools/messages.ts
2031
+ /** ISO-8601 in, or a clear refusal. No relative grammar on this surface yet. */
2032
+ const parseBound = (raw, field) => {
2033
+ if (raw === void 0) return void 0;
2034
+ const d = new Date(raw);
2035
+ if (Number.isNaN(d.getTime())) throw new Error(`Could not read ${field} from ${JSON.stringify(raw)}. Use ISO-8601: "2026-08-01" or "2026-08-01T09:00".`);
2036
+ return d;
2037
+ };
2038
+ const registerMessageTools = (server, client) => {
2039
+ server.registerTool("apple_messages_list_messages", {
2040
+ description: "Read messages, newest first — the whole store, or one conversation, or a date window. Tapbacks are excluded by default because they are rows in the same table and would otherwise read as messages nobody typed.\n\nEvery message carries `from.name` where Contacts resolves it and `from.handle` always. `from.resolution` is worth reading: `unknown` is COMMON AND NOT AN ERROR — measured on a real store, about one in six of even the busiest correspondents has no contact card.",
2041
+ inputSchema: {
2042
+ chatRef: chatRefArg,
2043
+ from: fromArg,
2044
+ to: toArg,
2045
+ includeReactions: includeReactionsArg,
2046
+ limit: limitArg
2047
+ },
2048
+ annotations: { readOnlyHint: true }
2049
+ }, async ({ chatRef, from, to, includeReactions, limit }) => wrap(async () => {
2050
+ const window = client.window(parseBound(from, "from"), parseBound(to, "to"));
2051
+ return client.listMessages({
2052
+ ...chatRef ? { chatRef: decodeChatRef(chatRef) } : {},
2053
+ ...window,
2054
+ ...includeReactions === void 0 ? {} : { includeReactions },
2055
+ ...limit === void 0 ? {} : { limit }
2056
+ });
2057
+ }));
2058
+ server.registerTool("apple_messages_search_messages", {
2059
+ description: "Search message text across every conversation. Matches are substring and case-insensitive.\n\nThis searches ALL messages, including the roughly 3% whose text lives only in an archived blob rather than in a plain column — those are invisible to any SQL query and are decoded here. `textSource` on each result says which produced it.",
2060
+ inputSchema: {
2061
+ query: z.string().min(1).describe("Text to look for."),
2062
+ limit: limitArg
2063
+ },
2064
+ annotations: { readOnlyHint: true }
2065
+ }, async ({ query, limit }) => wrap(async () => client.searchMessages(query, limit)));
2066
+ server.registerTool("apple_messages_get_message", {
2067
+ description: "One message in full, with its tapbacks and attachments. Reactions are reported here rather than mixed into the conversation, which is the whole reason list_messages filters them out.",
2068
+ inputSchema: { ref: messageRefArg },
2069
+ annotations: { readOnlyHint: true }
2070
+ }, async ({ ref }) => wrapResult(async () => {
2071
+ const message = client.getMessage(decodeMessageRef(ref));
2072
+ if (!message) return fail(`No message for ref "${ref}". It was probably deleted since the search ran. Re-run the search to get a current ref.`);
2073
+ return ok(message);
2074
+ }));
2075
+ };
2076
+ //#endregion
2077
+ //#region src/tools/index.ts
2078
+ /**
2079
+ * Register the Apple Messages tools.
2080
+ *
2081
+ * Five reads, always. Two writes, only when `allowWrites` is on — and on this
2082
+ * surface the flag carries a permission claim as well as a safety one: with it
2083
+ * off no Apple Event is ever sent, so no Automation grant is ever requested.
2084
+ * What is needed either way is Full Disk Access, absolutely — see `diagnostics`.
2085
+ *
2086
+ * The registered set does NOT vary with whether the store is readable. That is a
2087
+ * runtime condition, and MCP clients cache the tool list.
2088
+ */
2089
+ const registerTools = (server, client, ctx) => {
2090
+ registerDiagnosticsTools(server, client);
2091
+ registerChatTools(server, client);
2092
+ registerMessageTools(server, client);
2093
+ if (!ctx.allowWrites) return;
2094
+ registerActionTools(server, client);
2095
+ registerAttachmentTools(server, client);
2096
+ };
2097
+ //#endregion
2098
+ //#region src/server.ts
2099
+ const SERVER_NAME = BUILD_INFO.name;
2100
+ const SERVER_VERSION = BUILD_INFO.version;
2101
+ /**
2102
+ * Build the server. Side-effect free: it opens no database and reads no file,
2103
+ * so a test can construct it freely and every external dependency arrives
2104
+ * through an option.
2105
+ */
2106
+ const createServer = (opts) => {
2107
+ const { config } = opts;
2108
+ const server = new McpServer({
2109
+ name: SERVER_NAME,
2110
+ version: SERVER_VERSION
2111
+ });
2112
+ const client = new AppleMessagesClient({
2113
+ config,
2114
+ ...opts.logger ? { logger: opts.logger } : {},
2115
+ ...opts.osascript ? { osascript: opts.osascript } : {},
2116
+ ...opts.home ? { home: opts.home } : {},
2117
+ ...opts.contacts === void 0 ? {} : { contacts: opts.contacts }
2118
+ });
2119
+ registerTools(server, client, { allowWrites: config.allowWrites });
2120
+ if (config.exposePrompts) {
2121
+ registerPrompts(server, config.allowWrites);
2122
+ registerSurfaceResources(server, {
2123
+ surface: "messages",
2124
+ displayName: "Messages",
2125
+ guide: MESSAGES_GUIDE,
2126
+ diagnostics: () => buildDiagnostics(client)
2127
+ });
2128
+ }
2129
+ return {
2130
+ server,
2131
+ client
2132
+ };
2133
+ };
2134
+ //#endregion
2135
+ export { MESSAGES_SURFACE as A, toAppleSeconds as B, locateStore as C, ChatNotFoundError as D, AppleMessagesError as E, SendTargetNotFoundError as F, CORE_DATA_EPOCH_OFFSET as I, appleSecondsSql as L, MessagesUnavailableError as M, SchemaDriftError$1 as N, IndexUnavailableError$1 as O, SendFailedError as P, fromAppleSeconds as R, defaultStorePath as S, PRELUDE as T, BUILD_INFO as V, decodeMessageRef as _, loadConfig$1 as a, ATTACHMENTS_RELATIVE as b, introspect as c, decodeAttributedBody as d, outline as f, decodeChatRef as g, MESSAGE_REF_VERSION as h, registerTools as i, MessageNotFoundError as j, MESSAGES_BUNDLE_ID as k, openStore as l, InvalidMessageRefError as m, SERVER_VERSION as n, AppleMessagesClient as o, CHAT_REF_VERSION as p, createServer as r, MessagesStore as s, SERVER_NAME as t, reactionLabel as u, encodeChatRef as v, SEND_MESSAGE as w, STORE_RELATIVE as x, encodeMessageRef as y, renderInstant as z };
2136
+
2137
+ //# sourceMappingURL=server-DH4U3LBX.js.map