@elizaos/plugin-imessage 2.0.0-alpha.9 → 2.0.0-beta.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (59) hide show
  1. package/README.md +184 -0
  2. package/auto-enable.ts +21 -0
  3. package/dist/api/bluebubbles-routes.d.ts +10 -0
  4. package/dist/api/bluebubbles-routes.d.ts.map +1 -0
  5. package/dist/api/bluebubbles-routes.js +132 -0
  6. package/dist/api/bluebubbles-routes.js.map +1 -0
  7. package/dist/api/imessage-routes.d.ts +80 -0
  8. package/dist/api/imessage-routes.d.ts.map +1 -0
  9. package/dist/api/imessage-routes.js +230 -0
  10. package/dist/api/imessage-routes.js.map +1 -0
  11. package/dist/chatdb-reader.d.ts +240 -0
  12. package/dist/chatdb-reader.d.ts.map +1 -0
  13. package/dist/chatdb-reader.js +647 -0
  14. package/dist/chatdb-reader.js.map +1 -0
  15. package/dist/connector-account-provider.d.ts +18 -0
  16. package/dist/connector-account-provider.d.ts.map +1 -0
  17. package/dist/connector-account-provider.js +83 -0
  18. package/dist/connector-account-provider.js.map +1 -0
  19. package/dist/contacts-reader.d.ts +147 -0
  20. package/dist/contacts-reader.d.ts.map +1 -0
  21. package/dist/contacts-reader.js +481 -0
  22. package/dist/contacts-reader.js.map +1 -0
  23. package/dist/index.d.ts +11 -11
  24. package/dist/index.d.ts.map +1 -1
  25. package/dist/index.js +48 -16
  26. package/dist/index.js.map +1 -1
  27. package/dist/providers/index.d.ts +1 -2
  28. package/dist/providers/index.d.ts.map +1 -1
  29. package/dist/providers/index.js +2 -2
  30. package/dist/providers/index.js.map +1 -1
  31. package/dist/rpc.d.ts +16 -4
  32. package/dist/rpc.d.ts.map +1 -1
  33. package/dist/rpc.js +103 -11
  34. package/dist/rpc.js.map +1 -1
  35. package/dist/service.d.ts +203 -8
  36. package/dist/service.d.ts.map +1 -1
  37. package/dist/service.js +1368 -107
  38. package/dist/service.js.map +1 -1
  39. package/dist/setup-routes.d.ts +38 -0
  40. package/dist/setup-routes.d.ts.map +1 -0
  41. package/dist/setup-routes.js +322 -0
  42. package/dist/setup-routes.js.map +1 -0
  43. package/dist/types.d.ts +29 -0
  44. package/dist/types.d.ts.map +1 -1
  45. package/dist/types.js +2 -0
  46. package/dist/types.js.map +1 -1
  47. package/package.json +19 -10
  48. package/dist/actions/index.d.ts +0 -5
  49. package/dist/actions/index.d.ts.map +0 -1
  50. package/dist/actions/index.js +0 -5
  51. package/dist/actions/index.js.map +0 -1
  52. package/dist/actions/sendMessage.d.ts +0 -6
  53. package/dist/actions/sendMessage.d.ts.map +0 -1
  54. package/dist/actions/sendMessage.js +0 -178
  55. package/dist/actions/sendMessage.js.map +0 -1
  56. package/dist/providers/chatContext.d.ts +0 -6
  57. package/dist/providers/chatContext.d.ts.map +0 -1
  58. package/dist/providers/chatContext.js +0 -60
  59. package/dist/providers/chatContext.js.map +0 -1
package/dist/service.js CHANGED
@@ -1,15 +1,261 @@
1
1
  /**
2
- * iMessage service implementation for ElizaOS.
2
+ * iMessage service implementation for elizaOS.
3
3
  */
4
- import { exec } from "node:child_process";
4
+ import { execFile } from "node:child_process";
5
5
  import { existsSync } from "node:fs";
6
6
  import { platform } from "node:os";
7
7
  import { promisify } from "node:util";
8
- import { logger, Service } from "@elizaos/core";
9
- import { DEFAULT_POLL_INTERVAL_MS, formatPhoneNumber, IMESSAGE_SERVICE_NAME, IMessageCliError, IMessageConfigurationError, IMessageEventTypes, IMessageNotSupportedError, isPhoneNumber, splitMessageForIMessage, } from "./types.js";
10
- const execAsync = promisify(exec);
8
+ import { ChannelType, ContentType, createUniqueUuid, EventType, lifeOpsPassiveConnectorsEnabled, logger, MemoryType, Service, } from "@elizaos/core";
9
+ import { DEFAULT_ACCOUNT_ID as IMESSAGE_LOCAL_ACCOUNT_ID, normalizeAccountId as normalizeIMessageAccountId, } from "./accounts.js";
10
+ import { DEFAULT_CHAT_DB_PATH, getLastChatDbAccessIssue, openChatDb, } from "./chatdb-reader.js";
11
+ import { addContact, deleteContact, listAllContacts, loadContacts, normalizeContactHandle, updateContact, } from "./contacts-reader.js";
12
+ import { DEFAULT_POLL_INTERVAL_MS, formatPhoneNumber, IMESSAGE_SERVICE_NAME, IMessageCliError, IMessageConfigurationError, IMessageEventTypes, IMessageNotSupportedError, isEmail, isPhoneNumber, isValidIMessageTarget, normalizeIMessageTarget, splitMessageForIMessage, } from "./types.js";
13
+ const execFileAsync = promisify(execFile);
14
+ function appleScriptStringLiteral(value) {
15
+ return `"${value.replace(/\\/g, "\\\\").replace(/"/g, '\\"')}"`;
16
+ }
17
+ function registerMessageConnectorIfAvailable(runtime, registration) {
18
+ const withRegistry = runtime;
19
+ if (typeof withRegistry.registerMessageConnector === "function") {
20
+ withRegistry.registerMessageConnector(registration);
21
+ return;
22
+ }
23
+ if (!registration.sendHandler) {
24
+ throw new Error("iMessage connector registration requires a send handler");
25
+ }
26
+ runtime.registerSendHandler(registration.source, registration.sendHandler);
27
+ }
28
+ function hasTaskLookup(runtime) {
29
+ return "getTasksByName" in runtime && typeof runtime.getTasksByName === "function";
30
+ }
31
+ function readTargetAccountId(target) {
32
+ return target?.accountId;
33
+ }
34
+ function readContextAccountId(context) {
35
+ return context?.accountId;
36
+ }
37
+ function targetWithAccount(target, accountId) {
38
+ return { ...target, accountId };
39
+ }
40
+ function normalizeIMessageConnectorHandle(value) {
41
+ const stripped = value
42
+ .trim()
43
+ .replace(/^(?:messages?|sms|text):/i, "")
44
+ .trim();
45
+ const normalizedTarget = normalizeIMessageTarget(stripped) ?? stripped;
46
+ if (!normalizedTarget)
47
+ return "";
48
+ if (normalizedTarget.startsWith("chat_id:"))
49
+ return normalizedTarget;
50
+ if (/^(?:imessage|sms|rcs);/i.test(normalizedTarget)) {
51
+ return `chat_id:${normalizedTarget}`;
52
+ }
53
+ if (isEmail(normalizedTarget))
54
+ return normalizedTarget.toLowerCase();
55
+ if (isPhoneNumber(normalizedTarget))
56
+ return formatPhoneNumber(normalizedTarget);
57
+ return normalizeContactHandle(normalizedTarget) || normalizedTarget;
58
+ }
59
+ function firstAttachmentUrl(content) {
60
+ const attachment = content.attachments?.find((item) => typeof item?.url === "string" && item.url.trim().length > 0);
61
+ return attachment?.url?.trim();
62
+ }
63
+ function statusMetadata(status) {
64
+ return {
65
+ available: status.available,
66
+ connected: status.connected,
67
+ chatDbAvailable: status.chatDbAvailable,
68
+ sendOnly: status.sendOnly,
69
+ chatDbPath: status.chatDbPath,
70
+ reason: status.reason,
71
+ permissionAction: status.permissionAction?.label ?? null,
72
+ };
73
+ }
74
+ function normalizedSearchText(value) {
75
+ return (value ?? "")
76
+ .toLowerCase()
77
+ .replace(/[^a-z0-9@+._-]+/g, " ")
78
+ .trim();
79
+ }
80
+ function matchesQuery(query, ...values) {
81
+ const normalizedQuery = normalizedSearchText(query);
82
+ if (!normalizedQuery)
83
+ return true;
84
+ const normalizedHandleQuery = normalizedSearchText(normalizeIMessageConnectorHandle(query));
85
+ return values.some((value) => {
86
+ const normalizedValue = normalizedSearchText(value);
87
+ return (normalizedValue.includes(normalizedQuery) ||
88
+ (normalizedHandleQuery.length > 0 && normalizedValue.includes(normalizedHandleQuery)));
89
+ });
90
+ }
91
+ function resolveLocalIMessageAccountId(accountId) {
92
+ return normalizeIMessageAccountId(accountId ?? IMESSAGE_LOCAL_ACCOUNT_ID);
93
+ }
94
+ function assertLocalIMessageAccount(accountId) {
95
+ const normalized = resolveLocalIMessageAccountId(accountId);
96
+ if (normalized !== IMESSAGE_LOCAL_ACCOUNT_ID) {
97
+ throw new Error(`iMessage uses the single local macOS Messages account; unsupported accountId: ${normalized}`);
98
+ }
99
+ return normalized;
100
+ }
101
+ function normalizeConnectorLimit(limit, fallback = 50) {
102
+ if (!Number.isFinite(limit) || !limit || limit <= 0) {
103
+ return fallback;
104
+ }
105
+ return Math.min(Math.floor(limit), 200);
106
+ }
107
+ function filterMemoriesByQuery(memories, query, limit) {
108
+ const normalized = query.trim().toLowerCase();
109
+ if (!normalized) {
110
+ return memories.slice(0, limit);
111
+ }
112
+ return memories
113
+ .filter((memory) => {
114
+ const text = typeof memory.content?.text === "string" ? memory.content.text : "";
115
+ return text.toLowerCase().includes(normalized);
116
+ })
117
+ .slice(0, limit);
118
+ }
119
+ function publicIMessageToMemory(runtime, message, roomId, accountId = IMESSAGE_LOCAL_ACCOUNT_ID) {
120
+ const normalizedAccountId = assertLocalIMessageAccount(accountId);
121
+ const handle = normalizeIMessageConnectorHandle(message.handle ?? "");
122
+ const entityId = message.isFromMe
123
+ ? runtime.agentId
124
+ : createUniqueUuid(runtime, handle || message.chatId || message.id);
125
+ const channelType = message.chatId?.includes(";+;") ? ChannelType.GROUP : ChannelType.DM;
126
+ return {
127
+ id: createUniqueUuid(runtime, `imessage-public:${message.id}`),
128
+ entityId,
129
+ agentId: runtime.agentId,
130
+ roomId,
131
+ createdAt: message.timestamp,
132
+ content: {
133
+ text: message.text,
134
+ source: "imessage",
135
+ channelType,
136
+ ...(message.attachmentPaths?.length
137
+ ? {
138
+ attachments: message.attachmentPaths.map((path) => ({
139
+ id: path,
140
+ url: path,
141
+ title: path.split("/").pop() ?? path,
142
+ source: "imessage",
143
+ description: "iMessage attachment",
144
+ })),
145
+ }
146
+ : {}),
147
+ },
148
+ metadata: {
149
+ type: MemoryType.MESSAGE,
150
+ source: "imessage",
151
+ provider: "imessage",
152
+ accountId: normalizedAccountId,
153
+ timestamp: message.timestamp,
154
+ entityUserName: handle || undefined,
155
+ fromBot: message.isFromMe,
156
+ fromId: message.isFromMe ? runtime.agentId : handle,
157
+ sourceId: entityId,
158
+ chatType: channelType,
159
+ messageIdFull: message.id,
160
+ sender: {
161
+ id: message.isFromMe ? runtime.agentId : handle,
162
+ username: handle || undefined,
163
+ },
164
+ imessage: {
165
+ accountId: normalizedAccountId,
166
+ id: handle,
167
+ userId: handle,
168
+ username: handle,
169
+ chatId: message.chatId,
170
+ rowId: message.id,
171
+ },
172
+ },
173
+ };
174
+ }
175
+ function contactKind(handle) {
176
+ if (isEmail(handle))
177
+ return "email";
178
+ if (isPhoneNumber(handle))
179
+ return "phone";
180
+ return "contact";
181
+ }
182
+ function contactTarget(handle, label, score) {
183
+ const normalized = normalizeIMessageConnectorHandle(handle);
184
+ const displayLabel = label ? `${label} (${normalized})` : normalized;
185
+ return {
186
+ target: targetWithAccount({
187
+ source: "imessage",
188
+ channelId: normalized,
189
+ entityId: normalized,
190
+ }, IMESSAGE_LOCAL_ACCOUNT_ID),
191
+ label: displayLabel,
192
+ kind: contactKind(normalized),
193
+ score,
194
+ metadata: {
195
+ accountId: IMESSAGE_LOCAL_ACCOUNT_ID,
196
+ handle: normalized,
197
+ contactName: label,
198
+ },
199
+ };
200
+ }
201
+ function chatTarget(chat, contacts) {
202
+ const participants = chat.participants.map((participant) => normalizeIMessageConnectorHandle(participant.handle));
203
+ const primaryHandle = participants[0];
204
+ const isGroup = chat.chatType === "group";
205
+ const contactName = primaryHandle
206
+ ? contacts.get(normalizeContactHandle(primaryHandle))?.name
207
+ : undefined;
208
+ const label = chat.displayName ??
209
+ contactName ??
210
+ (isGroup ? participants.filter(Boolean).join(", ") : primaryHandle) ??
211
+ chat.chatId;
212
+ const target = targetWithAccount({
213
+ source: "imessage",
214
+ channelId: isGroup ? `chat_id:${chat.chatId}` : (primaryHandle ?? `chat_id:${chat.chatId}`),
215
+ }, IMESSAGE_LOCAL_ACCOUNT_ID);
216
+ if (!isGroup && primaryHandle) {
217
+ target.entityId = primaryHandle;
218
+ }
219
+ return {
220
+ target,
221
+ label,
222
+ kind: isGroup ? "group" : contactKind(primaryHandle ?? chat.chatId),
223
+ description: isGroup ? "iMessage group chat" : "iMessage direct chat",
224
+ score: isGroup ? 0.76 : 0.72,
225
+ metadata: {
226
+ accountId: IMESSAGE_LOCAL_ACCOUNT_ID,
227
+ chatId: chat.chatId,
228
+ chatType: chat.chatType,
229
+ participants: participants.filter(Boolean).join(", "),
230
+ },
231
+ };
232
+ }
233
+ async function resolveIMessageSendTarget(runtime, target) {
234
+ assertLocalIMessageAccount(readTargetAccountId(target));
235
+ if (target.channelId?.trim()) {
236
+ return normalizeIMessageConnectorHandle(target.channelId);
237
+ }
238
+ if (target.entityId?.trim()) {
239
+ return normalizeIMessageConnectorHandle(target.entityId);
240
+ }
241
+ if (target.roomId) {
242
+ const room = await runtime.getRoom(target.roomId);
243
+ if (room?.channelId) {
244
+ return normalizeIMessageConnectorHandle(room.channelId);
245
+ }
246
+ }
247
+ return null;
248
+ }
249
+ async function resolveIMessageChatId(runtime, target) {
250
+ assertLocalIMessageAccount(readTargetAccountId(target));
251
+ const channelId = target.channelId ??
252
+ (target.roomId ? (await runtime.getRoom(target.roomId))?.channelId : undefined);
253
+ if (!channelId)
254
+ return null;
255
+ return channelId.startsWith("chat_id:") ? channelId.slice("chat_id:".length) : channelId;
256
+ }
11
257
  /**
12
- * iMessage service for ElizaOS agents.
258
+ * iMessage service for Eliza agents.
13
259
  * Note: This only works on macOS.
14
260
  */
15
261
  export class IMessageService extends Service {
@@ -18,7 +264,61 @@ export class IMessageService extends Service {
18
264
  settings = null;
19
265
  connected = false;
20
266
  pollInterval = null;
21
- lastMessageId = null;
267
+ /**
268
+ * Highest `message.ROWID` we've already dispatched to the agent. The
269
+ * polling loop asks chat.db for rows strictly greater than this value,
270
+ * then advances the cursor to the largest row it actually processed.
271
+ * Initialized to 0 on start; bumped on every successful dispatch so
272
+ * we skip backlog on a fresh launch without ever double-delivering.
273
+ */
274
+ lastRowId = 0;
275
+ /**
276
+ * Reentrancy gate for the polling loop. Dispatch calls
277
+ * `messageService.handleMessage` which invokes the LLM and typically
278
+ * takes several seconds per message. With a 2-second poll interval
279
+ * that means multiple ticks can land on top of each other, each
280
+ * seeing the same stale cursor and re-dispatching the same rows.
281
+ * This flag ensures at most one poll is in flight at a time; ticks
282
+ * that arrive while a poll is active are dropped (the next scheduled
283
+ * tick will pick up where the current one left off).
284
+ */
285
+ pollInFlight = false;
286
+ /**
287
+ * Room keys we've already emitted a WORLD_JOINED event for in this
288
+ * service lifetime. Not persisted — on restart every room is
289
+ * re-greeted, which is correct: a fresh Eliza process doesn't know
290
+ * what previous processes already synced, and the bootstrap plugin's
291
+ * WORLD_JOINED handler is idempotent (handleServerSync tolerates
292
+ * already-known rooms via upsert semantics).
293
+ */
294
+ seenWorlds = new Set();
295
+ /**
296
+ * Entity keys we've already emitted an ENTITY_JOINED event for.
297
+ * Same lifetime + rationale as seenWorlds.
298
+ */
299
+ seenEntities = new Set();
300
+ /**
301
+ * Live chat.db handle, bound to the lifetime of this service. Non-null
302
+ * means inbound polling is active. Null means either (a) not running
303
+ * under Bun, or (b) chat.db couldn't be opened — in both cases the
304
+ * service remains send-only and logs a one-time warning on start.
305
+ */
306
+ chatDb = null;
307
+ chatDbPath = DEFAULT_CHAT_DB_PATH;
308
+ /**
309
+ * Cached handle → display name map from the user's Apple Contacts.
310
+ * Populated lazily on first inbound message via AppleScript against
311
+ * Contacts.app, NOT at service start. Loading at boot would trigger
312
+ * the macOS Contacts TCC dialog at app launch, even though the user
313
+ * may never receive an inbound iMessage. We defer the AppleScript
314
+ * call (and its TCC prompt) until the first message that actually
315
+ * needs handle→name resolution. Empty map means either the user
316
+ * hasn't authorized Contacts access yet, the address book is empty,
317
+ * or no inbound message has triggered the lazy load yet.
318
+ */
319
+ contacts = new Map();
320
+ /** Whether the lazy contact load has been attempted this session. */
321
+ contactsLoadAttempted = false;
22
322
  /**
23
323
  * Start the iMessage service.
24
324
  */
@@ -32,10 +332,47 @@ export class IMessageService extends Service {
32
332
  // Load settings
33
333
  service.settings = service.loadSettings();
34
334
  await service.validateSettings();
35
- // Start polling for new messages
36
- if (service.settings.pollIntervalMs > 0) {
335
+ // Open chat.db for inbound polling. A null return here is non-fatal —
336
+ // the service degrades to send-only and logs its own warning. We seed
337
+ // the polling cursor from the current tip of the database so a freshly
338
+ // started agent doesn't re-process its entire message backlog.
339
+ service.chatDbPath = service.settings.dbPath || DEFAULT_CHAT_DB_PATH;
340
+ service.chatDb = await openChatDb(service.chatDbPath);
341
+ if (service.chatDb) {
342
+ const tip = service.chatDb.getLatestRowId();
343
+ // Resolve IMESSAGE_BACKFILL from every plausible source — character
344
+ // settings (runtime.getSetting), the raw process env, and the
345
+ // character's settings object. Whichever arrives first wins.
346
+ const settingFromRuntime = typeof service.runtime?.getSetting === "function"
347
+ ? service.runtime.getSetting("IMESSAGE_BACKFILL")
348
+ : undefined;
349
+ const settingFromEnv = process.env.IMESSAGE_BACKFILL;
350
+ const resolvedRaw = (typeof settingFromRuntime === "string" && settingFromRuntime) || settingFromEnv || "";
351
+ const backfill = Math.max(0, Number(resolvedRaw) || 0);
352
+ service.lastRowId = Math.max(0, tip - backfill);
353
+ logger.debug(`[imessage][boot] dbPath=${service.chatDbPath} tip=${tip} backfillRaw=${JSON.stringify(resolvedRaw)} backfillResolved=${backfill} lastRowId=${service.lastRowId}`);
354
+ logger.info(`[imessage] chat.db opened, inbound polling ready (cursor starts at ROWID ${service.lastRowId}, backfilled ${backfill} from tip ${tip})`);
355
+ }
356
+ // NOTE: We intentionally do NOT call loadContacts() here. Loading
357
+ // contacts at service start runs an AppleScript against Contacts.app,
358
+ // which triggers the macOS Contacts TCC dialog the first time. App
359
+ // launch must not trigger TCC dialogs implicitly — the dialog is
360
+ // deferred to the first inbound message that actually needs name
361
+ // resolution (see ensureContactsLoaded() below). Outbound-only users
362
+ // never see the prompt.
363
+ // Start polling only when chat.db is available. When the database cannot
364
+ // be opened, the service is intentionally send-only until the next start.
365
+ if (service.chatDb && service.settings.pollIntervalMs > 0) {
37
366
  service.startPolling();
38
367
  }
368
+ else if (!service.chatDb && service.settings.pollIntervalMs > 0) {
369
+ logger.debug("[imessage] inbound polling not started because chat.db is unavailable");
370
+ }
371
+ // Register the heartbeat task worker + create a recurring task.
372
+ // See registerHeartbeat for what it actually does. We gate on the
373
+ // existence of runtime.registerTaskWorker to stay compatible with
374
+ // older cores that predate the task system.
375
+ await service.registerHeartbeat();
39
376
  service.connected = true;
40
377
  logger.info("iMessage service started");
41
378
  // Emit connection ready event
@@ -45,6 +382,216 @@ export class IMessageService extends Service {
45
382
  });
46
383
  return service;
47
384
  }
385
+ static registerSendHandlers(runtime, service) {
386
+ const registration = {
387
+ source: IMESSAGE_SERVICE_NAME,
388
+ label: "iMessage",
389
+ capabilities: ["send_message", "attachments", "contact_resolution", "chat_context"],
390
+ supportedTargetKinds: ["phone", "email", "contact", "user", "group", "room"],
391
+ contexts: ["phone", "social", "connectors"],
392
+ description: "Send SMS/iMessage through macOS Messages using phone numbers, emails, contacts, or chat ids.",
393
+ metadata: {
394
+ aliases: ["imessage", "sms", "text", "messages"],
395
+ accountId: IMESSAGE_LOCAL_ACCOUNT_ID,
396
+ bridge: "macos-messages",
397
+ accountSemantics: "local-macos-messages-single-account",
398
+ status: statusMetadata(service.getStatus()),
399
+ },
400
+ sendHandler: async (_runtime, target, content) => {
401
+ const accountId = assertLocalIMessageAccount(readTargetAccountId(target));
402
+ const text = typeof content.text === "string" ? content.text : "";
403
+ const mediaUrl = firstAttachmentUrl(content);
404
+ if (!text.trim() && !mediaUrl) {
405
+ return;
406
+ }
407
+ const resolvedTarget = await resolveIMessageSendTarget(runtime, target);
408
+ if (!resolvedTarget) {
409
+ throw new Error("iMessage target is missing a phone, email, or chat id");
410
+ }
411
+ const result = await service.sendMessage(resolvedTarget, text, mediaUrl ? { mediaUrl, accountId } : { accountId });
412
+ if (!result.success) {
413
+ throw new Error(result.error ?? "iMessage send failed");
414
+ }
415
+ },
416
+ resolveTargets: async (query) => {
417
+ const candidates = [];
418
+ const contacts = service.getContacts();
419
+ for (const [handle, contact] of contacts) {
420
+ if (matchesQuery(query, contact.name, handle)) {
421
+ candidates.push(contactTarget(handle, contact.name, contact.name.toLowerCase() === query.toLowerCase() ? 0.9 : 0.82));
422
+ }
423
+ }
424
+ const normalized = normalizeIMessageConnectorHandle(query);
425
+ if (normalized && (isValidIMessageTarget(normalized) || isEmail(normalized))) {
426
+ candidates.push(contactTarget(normalized, contacts.get(normalizeContactHandle(normalized))?.name, 0.8));
427
+ }
428
+ const chats = await service.getChats();
429
+ for (const chat of chats) {
430
+ const candidate = chatTarget(chat, contacts);
431
+ if (matchesQuery(query, candidate.label, chat.chatId, chat.displayName, ...chat.participants.map((participant) => participant.handle))) {
432
+ candidates.push({ ...candidate, score: Math.max(candidate.score ?? 0, 0.78) });
433
+ }
434
+ }
435
+ return candidates;
436
+ },
437
+ listRecentTargets: async () => {
438
+ const contacts = service.getContacts();
439
+ const byKey = new Map();
440
+ for (const message of await service.getRecentMessages(50)) {
441
+ const handle = normalizeIMessageConnectorHandle(message.handle);
442
+ const target = message.chatId
443
+ ? {
444
+ target: targetWithAccount({
445
+ source: "imessage",
446
+ channelId: message.chatId.startsWith("chat_id:")
447
+ ? message.chatId
448
+ : `chat_id:${message.chatId}`,
449
+ entityId: handle ? handle : undefined,
450
+ }, IMESSAGE_LOCAL_ACCOUNT_ID),
451
+ label: contacts.get(normalizeContactHandle(handle))?.name ??
452
+ message.handle ??
453
+ message.chatId,
454
+ kind: message.chatId?.includes(";+;") ? "group" : contactKind(handle),
455
+ score: 0.68,
456
+ metadata: {
457
+ accountId: IMESSAGE_LOCAL_ACCOUNT_ID,
458
+ handle,
459
+ chatId: message.chatId,
460
+ lastMessageAt: message.timestamp,
461
+ },
462
+ }
463
+ : contactTarget(handle, contacts.get(normalizeContactHandle(handle))?.name, 0.66);
464
+ byKey.set(`${target.target.channelId ?? ""}|${target.target.entityId ?? ""}`, target);
465
+ }
466
+ return Array.from(byKey.values());
467
+ },
468
+ listRooms: async () => {
469
+ const contacts = service.getContacts();
470
+ return (await service.getChats()).map((chat) => chatTarget(chat, contacts));
471
+ },
472
+ fetchMessages: async (context, params) => {
473
+ const limit = normalizeConnectorLimit(params?.limit);
474
+ const target = params?.target ?? context.target;
475
+ const accountId = assertLocalIMessageAccount(readTargetAccountId(target) ?? readContextAccountId(context));
476
+ const chatId = target ? await resolveIMessageChatId(context.runtime, target) : null;
477
+ const platformMessages = await service
478
+ .getMessages({ ...(chatId ? { chatId } : {}), limit })
479
+ .catch(() => []);
480
+ if (platformMessages.length > 0) {
481
+ const roomId = target?.roomId ??
482
+ createUniqueUuid(context.runtime, `imessage-read:${chatId ?? "recent"}`);
483
+ return platformMessages
484
+ .map((message) => publicIMessageToMemory(context.runtime, message, roomId, accountId))
485
+ .sort((left, right) => (right.createdAt ?? 0) - (left.createdAt ?? 0))
486
+ .slice(0, limit);
487
+ }
488
+ if (target?.roomId) {
489
+ return context.runtime.getMemories({
490
+ tableName: "messages",
491
+ roomId: target.roomId,
492
+ limit,
493
+ orderBy: "createdAt",
494
+ orderDirection: "desc",
495
+ });
496
+ }
497
+ return [];
498
+ },
499
+ searchMessages: async (context, params) => {
500
+ const limit = normalizeConnectorLimit(params?.limit);
501
+ const target = params?.target ?? context.target;
502
+ const accountId = assertLocalIMessageAccount(readTargetAccountId(target) ?? readContextAccountId(context));
503
+ const chatId = target ? await resolveIMessageChatId(context.runtime, target) : null;
504
+ const platformMessages = await service
505
+ .getMessages({ ...(chatId ? { chatId } : {}), limit: Math.max(limit, 100) })
506
+ .catch(() => []);
507
+ const roomId = target?.roomId ??
508
+ createUniqueUuid(context.runtime, `imessage-read:${chatId ?? "recent"}`);
509
+ const memories = platformMessages.length > 0
510
+ ? platformMessages.map((message) => publicIMessageToMemory(context.runtime, message, roomId, accountId))
511
+ : target?.roomId
512
+ ? await context.runtime.getMemories({
513
+ tableName: "messages",
514
+ roomId: target.roomId,
515
+ limit: Math.max(limit, 100),
516
+ orderBy: "createdAt",
517
+ orderDirection: "desc",
518
+ })
519
+ : [];
520
+ return filterMemoriesByQuery(memories, params.query, limit);
521
+ },
522
+ getChatContext: async (target, context) => {
523
+ const accountId = assertLocalIMessageAccount(readTargetAccountId(target) ?? readContextAccountId(context));
524
+ const chatId = await resolveIMessageChatId(context.runtime, target);
525
+ const messages = chatId ? await service.getMessages({ chatId, limit: 10 }) : [];
526
+ return {
527
+ target: targetWithAccount(target, accountId),
528
+ label: chatId ?? target.channelId ?? target.entityId ?? "iMessage target",
529
+ summary: service.getStatus().chatDbAvailable
530
+ ? "iMessage chat context from local Messages database."
531
+ : "iMessage is available in send-only mode; chat database context is unavailable.",
532
+ recentMessages: messages.map((message) => ({
533
+ name: service.getContacts().get(normalizeContactHandle(message.handle))?.name ??
534
+ message.handle,
535
+ text: message.text,
536
+ timestamp: message.timestamp,
537
+ metadata: {
538
+ accountId,
539
+ messageId: message.id,
540
+ handle: normalizeIMessageConnectorHandle(message.handle),
541
+ isFromMe: message.isFromMe,
542
+ },
543
+ })),
544
+ metadata: {
545
+ accountId,
546
+ chatId,
547
+ status: statusMetadata(service.getStatus()),
548
+ },
549
+ };
550
+ },
551
+ getUserContext: async (entityId) => {
552
+ const handle = normalizeIMessageConnectorHandle(String(entityId));
553
+ if (!handle)
554
+ return null;
555
+ const contact = service.getContacts().get(normalizeContactHandle(handle));
556
+ return {
557
+ entityId,
558
+ label: contact?.name ?? handle,
559
+ aliases: contact?.name ? [contact.name, handle] : [handle],
560
+ handles: {
561
+ imessage: handle,
562
+ ...(isEmail(handle) ? { email: handle } : { phone: handle }),
563
+ },
564
+ metadata: {
565
+ accountId: IMESSAGE_LOCAL_ACCOUNT_ID,
566
+ normalizedHandle: handle,
567
+ },
568
+ };
569
+ },
570
+ getUser: async (_handlerRuntime, params) => {
571
+ const lookupParams = params;
572
+ const handle = normalizeIMessageConnectorHandle(String(lookupParams.entityId ??
573
+ lookupParams.userId ??
574
+ lookupParams.username ??
575
+ lookupParams.handle ??
576
+ ""));
577
+ if (!handle)
578
+ return null;
579
+ const contact = service.getContacts().get(normalizeContactHandle(handle));
580
+ return {
581
+ id: createUniqueUuid(_handlerRuntime, `imessage:${handle}`),
582
+ names: contact?.name ? [contact.name, handle] : [handle],
583
+ agentId: _handlerRuntime.agentId,
584
+ metadata: {
585
+ accountId: IMESSAGE_LOCAL_ACCOUNT_ID,
586
+ normalizedHandle: handle,
587
+ imessage: handle,
588
+ ...(isEmail(handle) ? { email: handle } : { phone: handle }),
589
+ },
590
+ };
591
+ },
592
+ };
593
+ registerMessageConnectorIfAvailable(runtime, registration);
594
+ }
48
595
  /**
49
596
  * Stop the iMessage service.
50
597
  */
@@ -55,8 +602,12 @@ export class IMessageService extends Service {
55
602
  clearInterval(this.pollInterval);
56
603
  this.pollInterval = null;
57
604
  }
605
+ if (this.chatDb) {
606
+ this.chatDb.close();
607
+ this.chatDb = null;
608
+ }
58
609
  this.settings = null;
59
- this.lastMessageId = null;
610
+ this.lastRowId = 0;
60
611
  logger.info("iMessage service stopped");
61
612
  }
62
613
  /**
@@ -65,6 +616,19 @@ export class IMessageService extends Service {
65
616
  isConnected() {
66
617
  return this.connected;
67
618
  }
619
+ getStatus() {
620
+ const chatDbAvailable = this.chatDb !== null;
621
+ const accessIssue = chatDbAvailable ? null : getLastChatDbAccessIssue(this.chatDbPath);
622
+ return {
623
+ available: true,
624
+ connected: this.connected,
625
+ chatDbAvailable,
626
+ sendOnly: this.connected && !chatDbAvailable,
627
+ chatDbPath: this.chatDbPath,
628
+ reason: accessIssue?.reason ?? (chatDbAvailable ? null : "chat.db reader not available"),
629
+ permissionAction: accessIssue?.permissionAction ?? null,
630
+ };
631
+ }
68
632
  /**
69
633
  * Check if running on macOS.
70
634
  */
@@ -75,6 +639,7 @@ export class IMessageService extends Service {
75
639
  * Send a message via iMessage.
76
640
  */
77
641
  async sendMessage(to, text, options) {
642
+ const accountId = assertLocalIMessageAccount(options?.accountId);
78
643
  if (!this.settings) {
79
644
  return { success: false, error: "Service not initialized" };
80
645
  }
@@ -88,14 +653,41 @@ export class IMessageService extends Service {
88
653
  return result;
89
654
  }
90
655
  }
91
- // Emit sent event
656
+ // Emit sent events — both the plugin-namespaced form (for iMessage-
657
+ // specific listeners) and the generic core EventType.MESSAGE_SENT
658
+ // so trajectory loggers, analytics, and any plugin hooking into
659
+ // the standard event bus see outbound iMessage sends the same way
660
+ // they see Telegram / Discord / Slack sends.
92
661
  if (this.runtime) {
93
662
  this.runtime.emitEvent(IMessageEventTypes.MESSAGE_SENT, {
94
663
  runtime: this.runtime,
664
+ source: "imessage",
665
+ accountId,
95
666
  to: target,
96
667
  text,
97
668
  hasMedia: Boolean(options?.mediaUrl),
98
669
  });
670
+ this.runtime.emitEvent(EventType.MESSAGE_SENT, {
671
+ runtime: this.runtime,
672
+ source: "imessage",
673
+ message: {
674
+ id: createUniqueUuid(this.runtime, `imessage-outbound-${Date.now()}`),
675
+ entityId: this.runtime.agentId,
676
+ agentId: this.runtime.agentId,
677
+ roomId: createUniqueUuid(this.runtime, target),
678
+ content: { text, source: "imessage" },
679
+ metadata: {
680
+ accountId,
681
+ source: "imessage",
682
+ provider: "imessage",
683
+ imessage: {
684
+ accountId,
685
+ chatId: target,
686
+ },
687
+ },
688
+ createdAt: Date.now(),
689
+ },
690
+ });
99
691
  }
100
692
  return {
101
693
  success: true,
@@ -104,66 +696,56 @@ export class IMessageService extends Service {
104
696
  };
105
697
  }
106
698
  /**
107
- * Get recent messages.
699
+ * Get recent messages by reading chat.db. Returns the most recent
700
+ * `limit` messages (any sender, any chat) in chronological order.
701
+ *
702
+ * Returns an empty array if the chat.db reader is unavailable (plugin
703
+ * running under plain Node without bun:sqlite, or Full Disk Access not
704
+ * granted, etc.).
108
705
  */
109
706
  async getRecentMessages(limit = 50) {
110
- if (!this.settings) {
111
- return [];
112
- }
113
- // Use CLI or AppleScript to get recent messages
114
- const script = `
115
- tell application "Messages"
116
- set AppleScript's text item delimiters to tab
117
- set outputLines to {}
118
- repeat with i from 1 to ${limit}
119
- try
120
- set msg to item i of (get messages)
121
- set msgLine to (id of msg) & tab & (text of msg) & tab & ((date of msg) as string) & tab & (is_from_me of msg as string) & tab & (chat_identifier of msg as string) & tab & (handle of sender of msg)
122
- set end of outputLines to msgLine
123
- end try
124
- end repeat
125
- set AppleScript's text item delimiters to linefeed
126
- set outputText to outputLines as string
127
- set AppleScript's text item delimiters to ""
128
- return outputText
129
- end tell
130
- `;
131
- try {
132
- const result = await this.runAppleScript(script);
133
- // Parse result and return messages
134
- return this.parseMessagesResult(result);
135
- }
136
- catch (error) {
137
- logger.warn(`Failed to get recent messages: ${error}`);
707
+ return this.getMessages({ limit });
708
+ }
709
+ /**
710
+ * Return the newest messages in chronological order, optionally scoped
711
+ * to a single chat identifier. Returns an empty array if chat.db is not
712
+ * available and the connector is currently running in send-only mode.
713
+ */
714
+ async getMessages(options = {}) {
715
+ if (!this.chatDb) {
138
716
  return [];
139
717
  }
718
+ const rows = this.chatDb.listMessages(options);
719
+ return rows.map(chatDbMessageToPublicShape);
140
720
  }
141
721
  /**
142
- * Get chats.
722
+ * List every chat the Messages.app database knows about, joined with
723
+ * participant handles. Returns an empty list if the chat.db reader is
724
+ * unavailable (Node runtime, missing FDA, etc.).
725
+ *
726
+ * Previously this method used an AppleScript stub against
727
+ * Messages.app's `chats` collection. That verb works but is slow and
728
+ * returns a coarser view (no participant handles, no style field), so
729
+ * the chat.db path is strictly better when it's available.
143
730
  */
144
731
  async getChats() {
145
- if (!this.settings) {
146
- return [];
147
- }
148
- const script = `
149
- tell application "Messages"
150
- set chatList to {}
151
- repeat with c in chats
152
- set chatId to id of c
153
- set chatName to name of c
154
- set end of chatList to {chatId, chatName}
155
- end repeat
156
- return chatList
157
- end tell
158
- `;
159
- try {
160
- const result = await this.runAppleScript(script);
161
- return this.parseChatsResult(result);
162
- }
163
- catch (error) {
164
- logger.warn(`Failed to get chats: ${error}`);
732
+ if (!this.chatDb) {
165
733
  return [];
166
734
  }
735
+ // Convert the reader's richer ChatDbChatSummary into the plugin's
736
+ // public IMessageChat shape for backwards-compat with consumers of
737
+ // the existing IIMessageService interface. Callers that want the
738
+ // richer fields (serviceName, last read timestamp) can go straight
739
+ // to the reader via `chatDb.listChats()`.
740
+ return this.chatDb.listChats().map((c) => ({
741
+ chatId: c.chatId,
742
+ chatType: c.chatType,
743
+ displayName: c.displayName ?? undefined,
744
+ participants: c.participants.map((handle) => ({
745
+ handle,
746
+ isPhoneNumber: /^\+?\d{7,}$/.test(handle),
747
+ })),
748
+ }));
167
749
  }
168
750
  /**
169
751
  * Get current settings.
@@ -171,6 +753,90 @@ export class IMessageService extends Service {
171
753
  getSettings() {
172
754
  return this.settings;
173
755
  }
756
+ /**
757
+ * Get the cached Apple Contacts map loaded at service start.
758
+ *
759
+ * Keys are normalized handles (phones in digits-only + optional leading `+`,
760
+ * emails lowercased). Values carry the contact's display name.
761
+ *
762
+ * Exposed for providers that want to inject contact lookups into agent
763
+ * state so the LLM can resolve a person's name ("text Shaw") to a handle
764
+ * it can pass to `sendMessage`.
765
+ *
766
+ * Returns an empty map if Contacts access was denied, failed to load,
767
+ * or the service hasn't finished starting.
768
+ */
769
+ getContacts() {
770
+ return this.contacts;
771
+ }
772
+ /**
773
+ * Lazy-load the Apple Contacts map on first call. Subsequent calls
774
+ * are no-ops. We split this out from `start()` so the macOS Contacts
775
+ * TCC dialog only fires when the runtime actually needs handle→name
776
+ * resolution — typically the first inbound message — instead of at
777
+ * app launch. Failure is non-fatal; the cached map stays empty and
778
+ * the service falls back to raw handles.
779
+ */
780
+ async ensureContactsLoaded() {
781
+ if (this.contactsLoadAttempted) {
782
+ return;
783
+ }
784
+ this.contactsLoadAttempted = true;
785
+ try {
786
+ this.contacts = await loadContacts();
787
+ }
788
+ catch (err) {
789
+ logger.warn(`[imessage] Lazy contact load failed: ${err instanceof Error ? err.message : String(err)}`);
790
+ }
791
+ }
792
+ /**
793
+ * List every contact in the user's address book as a full record with
794
+ * id, name, and all phones/emails. Delegates to contacts-reader's
795
+ * `listAllContacts` which goes through Contacts.app's AppleScript
796
+ * dump. Returns `[]` on failure (permission denied, etc.).
797
+ */
798
+ async listAllContacts() {
799
+ return listAllContacts();
800
+ }
801
+ /**
802
+ * Create a new contact in Contacts.app. Requires Contacts WRITE
803
+ * permission (macOS prompts on first call). Returns the new person's
804
+ * id on success, or null on failure.
805
+ *
806
+ * After a successful create we refresh the cached handle→name map so
807
+ * inbound messages from the new contact resolve to their name on the
808
+ * very next poll, without requiring a service restart.
809
+ */
810
+ async addContact(input) {
811
+ const id = await addContact(input);
812
+ if (id) {
813
+ this.contacts = await loadContacts();
814
+ }
815
+ return id;
816
+ }
817
+ /**
818
+ * Patch an existing contact (name fields, add/remove phones, add/remove
819
+ * emails). Returns true on success, false on failure. Refreshes the
820
+ * cached map on success so name resolution reflects the change.
821
+ */
822
+ async updateContact(personId, patch) {
823
+ const ok = await updateContact(personId, patch);
824
+ if (ok) {
825
+ this.contacts = await loadContacts();
826
+ }
827
+ return ok;
828
+ }
829
+ /**
830
+ * Delete a contact by Contacts.app id. Returns true on success,
831
+ * false on failure. Refreshes the cached map on success.
832
+ */
833
+ async deleteContact(personId) {
834
+ const ok = await deleteContact(personId);
835
+ if (ok) {
836
+ this.contacts = await loadContacts();
837
+ }
838
+ return ok;
839
+ }
174
840
  // Private methods
175
841
  loadSettings() {
176
842
  if (!this.runtime) {
@@ -184,8 +850,11 @@ export class IMessageService extends Service {
184
850
  };
185
851
  const cliPath = getStringSetting("IMESSAGE_CLI_PATH", "IMESSAGE_CLI_PATH", "imsg");
186
852
  const dbPath = getStringSetting("IMESSAGE_DB_PATH", "IMESSAGE_DB_PATH") || undefined;
187
- const pollIntervalMs = Number(getStringSetting("IMESSAGE_POLL_INTERVAL_MS", "IMESSAGE_POLL_INTERVAL_MS")) ||
188
- DEFAULT_POLL_INTERVAL_MS;
853
+ const pollIntervalRaw = getStringSetting("IMESSAGE_POLL_INTERVAL_MS", "IMESSAGE_POLL_INTERVAL_MS");
854
+ const parsedPollIntervalMs = Number(pollIntervalRaw);
855
+ const pollIntervalMs = pollIntervalRaw.trim() === "" || !Number.isFinite(parsedPollIntervalMs)
856
+ ? DEFAULT_POLL_INTERVAL_MS
857
+ : Math.max(0, parsedPollIntervalMs);
189
858
  const dmPolicy = getStringSetting("IMESSAGE_DM_POLICY", "IMESSAGE_DM_POLICY", "pairing");
190
859
  const groupPolicy = getStringSetting("IMESSAGE_GROUP_POLICY", "IMESSAGE_GROUP_POLICY", "allowlist");
191
860
  const allowFromRaw = getStringSetting("IMESSAGE_ALLOW_FROM", "IMESSAGE_ALLOW_FROM");
@@ -247,7 +916,7 @@ export class IMessageService extends Service {
247
916
  args.push("--attachment", options.mediaUrl);
248
917
  }
249
918
  try {
250
- await execAsync(`"${this.settings.cliPath}" ${args.map((a) => `"${a}"`).join(" ")}`);
919
+ await execFileAsync(this.settings.cliPath, args);
251
920
  return { success: true, messageId: Date.now().toString(), chatId: to };
252
921
  }
253
922
  catch (error) {
@@ -255,41 +924,63 @@ export class IMessageService extends Service {
255
924
  throw new IMessageCliError(err.message || "CLI command failed", err.code);
256
925
  }
257
926
  }
258
- async sendViaAppleScript(to, text, _options) {
259
- // Escape text for AppleScript
260
- const escapedText = text.replace(/\\/g, "\\\\").replace(/"/g, '\\"');
261
- let script;
262
- if (to.startsWith("chat_id:")) {
263
- // Send to existing chat
264
- const chatId = to.slice(8);
265
- script = `
927
+ async sendViaAppleScript(to, text, options) {
928
+ const isChatTarget = to.startsWith("chat_id:");
929
+ const chatId = isChatTarget ? to.slice(8) : null;
930
+ const targetLiteral = appleScriptStringLiteral(chatId ?? to);
931
+ // Build the `set target...` clause once — used by both the text
932
+ // send and the (optional) attachment send below.
933
+ const targetBlock = isChatTarget
934
+ ? `set targetRef to chat id ${targetLiteral}`
935
+ : `
936
+ set targetService to 1st account whose service type = iMessage
937
+ set targetRef to participant ${targetLiteral} of targetService
938
+ `;
939
+ // Text body (possibly empty if caller is attachment-only).
940
+ if (text && text.length > 0) {
941
+ const textScript = `
266
942
  tell application "Messages"
267
- set targetChat to chat id "${chatId}"
268
- send "${escapedText}" to targetChat
943
+ ${targetBlock}
944
+ send ${appleScriptStringLiteral(text)} to targetRef
269
945
  end tell
270
946
  `;
947
+ try {
948
+ await this.runAppleScript(textScript);
949
+ }
950
+ catch (error) {
951
+ return { success: false, error: `AppleScript error: ${error}` };
952
+ }
271
953
  }
272
- else {
273
- // Send to buddy (phone/email)
274
- script = `
954
+ // Attachment, if any. Messages.app's scripting dictionary accepts a
955
+ // file as the direct-parameter of `send`, so we resolve the media
956
+ // path to a POSIX file and hand it to the same `send` verb. Works
957
+ // for images, video, audio, PDFs, anything Messages.app would let
958
+ // you drag into the compose area.
959
+ if (options?.mediaUrl) {
960
+ const mediaPath = options.mediaUrl.startsWith("file://")
961
+ ? options.mediaUrl.slice(7)
962
+ : options.mediaUrl;
963
+ const attachmentScript = `
275
964
  tell application "Messages"
276
- set targetService to 1st account whose service type = iMessage
277
- set targetBuddy to participant "${to}" of targetService
278
- send "${escapedText}" to targetBuddy
965
+ ${targetBlock}
966
+ send (POSIX file ${appleScriptStringLiteral(mediaPath)}) to targetRef
279
967
  end tell
280
968
  `;
969
+ try {
970
+ await this.runAppleScript(attachmentScript);
971
+ }
972
+ catch (error) {
973
+ return {
974
+ success: false,
975
+ error: `AppleScript attachment error: ${error}`,
976
+ };
977
+ }
281
978
  }
282
- try {
283
- await this.runAppleScript(script);
284
- return { success: true, messageId: Date.now().toString(), chatId: to };
285
- }
286
- catch (error) {
287
- return { success: false, error: `AppleScript error: ${error}` };
288
- }
979
+ return { success: true, messageId: Date.now().toString(), chatId: to };
289
980
  }
290
981
  async runAppleScript(script) {
291
982
  try {
292
- const { stdout } = await execAsync(`osascript -e '${script.replace(/'/g, "'\"'\"'")}'`);
983
+ const { stdout } = await execFileAsync("osascript", ["-e", script]);
293
984
  return stdout.trim();
294
985
  }
295
986
  catch (error) {
@@ -310,30 +1001,449 @@ export class IMessageService extends Service {
310
1001
  }
311
1002
  }, this.settings.pollIntervalMs);
312
1003
  }
1004
+ /**
1005
+ * Poll chat.db for rows newer than the cursor and route each inbound
1006
+ * message through the agent's message pipeline.
1007
+ *
1008
+ * Flow per message:
1009
+ * 1. Read new rows from chat.db via the bun:sqlite reader.
1010
+ * 2. Skip outbound (is_from_me=1), already-seen, and policy-denied rows.
1011
+ * 3. Build a Memory object in the shape the bootstrap plugin expects.
1012
+ * 4. Ensure the entity + room + world exist via ensureConnection.
1013
+ * 5. Call runtime.messageService.handleMessage with a callback that
1014
+ * sends the agent's reply back through sendViaAppleScript.
1015
+ * 6. Also emit the plugin-namespaced IMESSAGE_MESSAGE_RECEIVED event
1016
+ * and the core EventType.MESSAGE_RECEIVED event for any listeners.
1017
+ *
1018
+ * Advances this.lastRowId unconditionally to the max rowId we saw in
1019
+ * this batch — even for skipped rows — so the cursor keeps moving
1020
+ * forward and we never get stuck re-reading the same row on every poll.
1021
+ */
313
1022
  async pollForNewMessages() {
314
- if (!this.runtime) {
1023
+ if (!this.runtime || !this.chatDb) {
1024
+ return;
1025
+ }
1026
+ // Reentrancy gate — see field-level comment on pollInFlight.
1027
+ if (this.pollInFlight) {
1028
+ return;
1029
+ }
1030
+ this.pollInFlight = true;
1031
+ try {
1032
+ await this.pollForNewMessagesInner();
1033
+ }
1034
+ finally {
1035
+ this.pollInFlight = false;
1036
+ }
1037
+ }
1038
+ async pollForNewMessagesInner() {
1039
+ if (!this.runtime || !this.chatDb)
1040
+ return;
1041
+ // Cap batch size to 50 to keep per-tick latency bounded on busy
1042
+ // inboxes. If traffic exceeds that, subsequent ticks catch up.
1043
+ const batch = this.chatDb.fetchNewMessages(this.lastRowId, 50);
1044
+ if (batch.length === 0) {
315
1045
  return;
316
1046
  }
317
- const messages = await this.getRecentMessages(10);
318
- for (const msg of messages) {
319
- // Skip if we've already seen this message
320
- if (this.lastMessageId && msg.id <= this.lastMessageId) {
1047
+ // CRITICAL: advance the cursor synchronously, BEFORE any async
1048
+ // dispatch work. If we advance after the for loop, a slow dispatch
1049
+ // gives setInterval time to fire the next tick, which would re-read
1050
+ // the stale cursor and re-dispatch the same rows. Since chat.db's
1051
+ // ROWID is monotonic, we know every row in `batch` is unique and
1052
+ // each has rowId > sinceRowId, so advancing to the max NOW is safe
1053
+ // even if individual dispatches fail later.
1054
+ const maxRowIdInBatch = batch.reduce((max, row) => (row.rowId > max ? row.rowId : max), this.lastRowId);
1055
+ this.lastRowId = maxRowIdInBatch;
1056
+ logger.debug(`[imessage][poll-start] fetched=${batch.length} cursor advanced to ${this.lastRowId}`);
1057
+ for (const row of batch) {
1058
+ // Skip outbound messages — the agent sent these itself via AppleScript,
1059
+ // we don't want it reacting to its own output.
1060
+ if (row.isFromMe) {
321
1061
  continue;
322
1062
  }
323
- // Skip messages from self
324
- if (msg.isFromMe) {
1063
+ // Policy gate: DM allowlist, group allowlist, disabled, etc.
1064
+ if (!this.isAllowed(row.handle)) {
325
1065
  continue;
326
1066
  }
327
- // Check DM policy
328
- if (!this.isAllowed(msg.handle)) {
1067
+ // Skip non-text rows for the main dispatch path. Reactions and
1068
+ // system events still emit their own plugin-namespaced events
1069
+ // below so downstream listeners can react to them, but they
1070
+ // don't flow through messageService.handleMessage (which is
1071
+ // for conversational turns only).
1072
+ if (row.kind !== "text") {
1073
+ this.emitAuxiliaryEvent(row);
329
1074
  continue;
330
1075
  }
331
- // Emit message received event
332
- this.runtime.emitEvent(IMessageEventTypes.MESSAGE_RECEIVED, {
1076
+ // Undecodable text (attributedBody decode miss) would produce an
1077
+ // empty-string turn the agent has nothing to do with. Skip with a
1078
+ // debug log so the cursor still advances.
1079
+ if (row.text.trim().length === 0) {
1080
+ logger.debug(`[imessage] skipping ROWID=${row.rowId} — text column and attributedBody both empty after decode`);
1081
+ continue;
1082
+ }
1083
+ logger.debug(`[imessage][dispatch] ROWID=${row.rowId} handle=${row.handle} text="${row.text.slice(0, 40)}"`);
1084
+ try {
1085
+ await this.dispatchInboundMessage(row);
1086
+ }
1087
+ catch (error) {
1088
+ logger.error(`[imessage] Failed to dispatch inbound message ROWID=${row.rowId}: ${error instanceof Error ? error.message : String(error)}`);
1089
+ }
1090
+ }
1091
+ logger.debug(`[imessage][poll-end] done. lastRowId=${this.lastRowId}`);
1092
+ }
1093
+ /**
1094
+ * Turn a single chat.db row into a `Memory`, wire up a reply callback,
1095
+ * and hand the whole thing to `runtime.messageService.handleMessage`.
1096
+ *
1097
+ * Mirrors the shape used by @elizaos/plugin-telegram so the bootstrap
1098
+ * plugin's message pipeline picks up inbound iMessages the same way it
1099
+ * picks up Telegram messages — same entity/room/world creation, same
1100
+ * `source: "imessage"` tag on content, same HandlerCallback signature
1101
+ * for the reply path.
1102
+ */
1103
+ async dispatchInboundMessage(row) {
1104
+ if (!this.runtime)
1105
+ return;
1106
+ const accountId = IMESSAGE_LOCAL_ACCOUNT_ID;
1107
+ // chat_identifier is stable across messages in the same chat; use it
1108
+ // as the room key. Fall back to the handle for the edge case where
1109
+ // chat_message_join is empty (shouldn't happen on a healthy chat.db).
1110
+ const roomKey = row.chatId || row.handle || `imessage-room-${row.rowId}`;
1111
+ const entityKey = row.handle || roomKey;
1112
+ const entityId = createUniqueUuid(this.runtime, entityKey);
1113
+ const roomId = createUniqueUuid(this.runtime, roomKey);
1114
+ const worldId = createUniqueUuid(this.runtime, `imessage-world-${roomKey}`);
1115
+ // Key the Memory id by the chat.db message guid, not the ROWID —
1116
+ // guids are stable across restarts and CloudKit syncs, so reply
1117
+ // threading (which targets guids) can resolve via createUniqueUuid
1118
+ // against the same key.
1119
+ const messageId = createUniqueUuid(this.runtime, `imessage-guid-${row.guid}`);
1120
+ const channelType = row.chatType === "group" ? ChannelType.GROUP : ChannelType.DM;
1121
+ // Resolve the sender handle against the Apple Contacts map. The map
1122
+ // is loaded lazily on first inbound message — see ensureContactsLoaded
1123
+ // for the rationale (deferring the macOS Contacts TCC dialog away from
1124
+ // app launch). On a miss we fall back to the raw handle, so the
1125
+ // conversation still works — it just looks uglier in logs and state.
1126
+ await this.ensureContactsLoaded();
1127
+ const resolvedContact = this.contacts.get(normalizeContactHandle(row.handle)) ?? null;
1128
+ const resolvedName = resolvedContact?.name ?? null;
1129
+ // Make sure the agent's memory store knows about this entity + room +
1130
+ // world before we try to persist a Memory into it — otherwise FK
1131
+ // constraints fire downstream.
1132
+ await this.runtime.ensureConnection({
1133
+ entityId,
1134
+ roomId,
1135
+ worldId,
1136
+ source: "imessage",
1137
+ channelId: roomKey,
1138
+ type: channelType,
1139
+ metadata: {
1140
+ accountId,
1141
+ chatId: roomKey,
1142
+ chatType: row.chatType,
1143
+ },
1144
+ name: resolvedName ?? row.displayName ?? row.handle ?? undefined,
1145
+ ...(row.handle ? { userId: row.handle } : {}),
1146
+ worldName: row.displayName ? `imessage-chat-${row.displayName}` : `imessage-chat-${roomKey}`,
1147
+ userName: resolvedName ?? row.handle ?? undefined,
1148
+ });
1149
+ if (typeof this.runtime.ensureRoomExists === "function") {
1150
+ await this.runtime.ensureRoomExists({
1151
+ id: roomId,
1152
+ name: row.displayName ?? roomKey,
1153
+ agentId: this.runtime.agentId,
1154
+ source: "imessage",
1155
+ type: channelType,
1156
+ channelId: roomKey,
1157
+ worldId,
1158
+ metadata: {
1159
+ accountId,
1160
+ chatId: roomKey,
1161
+ chatType: row.chatType,
1162
+ },
1163
+ });
1164
+ }
1165
+ // Lifecycle events — WORLD_JOINED + ENTITY_JOINED fire ONCE per
1166
+ // room/entity per service lifetime. These are the architectural
1167
+ // signals that Eliza's bootstrap plugin and any other observer
1168
+ // (trajectory logger, analytics, onboarding flows) subscribe to in
1169
+ // order to sync data, update rosters, or trigger side effects.
1170
+ // Without them, a plugin that writes Memories directly via
1171
+ // createMemory leaves observers in the dark about new rooms.
1172
+ if (!this.seenWorlds.has(roomKey)) {
1173
+ this.seenWorlds.add(roomKey);
1174
+ this.runtime.emitEvent(EventType.WORLD_JOINED, {
1175
+ runtime: this.runtime,
1176
+ source: "imessage",
1177
+ world: {
1178
+ id: worldId,
1179
+ name: row.displayName ? `imessage-chat-${row.displayName}` : `imessage-chat-${roomKey}`,
1180
+ agentId: this.runtime.agentId,
1181
+ serverId: roomKey,
1182
+ metadata: {
1183
+ accountId,
1184
+ type: channelType,
1185
+ chatId: roomKey,
1186
+ displayName: row.displayName ?? undefined,
1187
+ },
1188
+ },
1189
+ rooms: [
1190
+ {
1191
+ id: roomId,
1192
+ name: row.displayName ?? roomKey,
1193
+ type: channelType,
1194
+ source: "imessage",
1195
+ worldId,
1196
+ channelId: roomKey,
1197
+ serverId: roomKey,
1198
+ agentId: this.runtime.agentId,
1199
+ metadata: { accountId, chatType: row.chatType },
1200
+ },
1201
+ ],
1202
+ entities: [],
1203
+ });
1204
+ logger.debug(`[imessage][world-joined] roomKey=${roomKey}`);
1205
+ }
1206
+ if (!this.seenEntities.has(entityKey)) {
1207
+ this.seenEntities.add(entityKey);
1208
+ this.runtime.emitEvent(EventType.ENTITY_JOINED, {
1209
+ runtime: this.runtime,
1210
+ source: "imessage",
1211
+ entityId,
1212
+ worldId,
1213
+ roomId,
1214
+ metadata: {
1215
+ accountId,
1216
+ originalId: row.handle,
1217
+ username: row.handle,
1218
+ displayName: resolvedName ?? row.handle,
1219
+ type: channelType,
1220
+ },
1221
+ });
1222
+ logger.debug(`[imessage][entity-joined] entityKey=${entityKey} name=${resolvedName ?? row.handle}`);
1223
+ }
1224
+ // Resolve the in-reply-to link. If this message is an inline reply to
1225
+ // an earlier one, we build the same UUID the earlier dispatch would
1226
+ // have used (same rule: `imessage-${rowId}` keyed off the reader's
1227
+ // stable guid), so the agent's threading actually resolves. We don't
1228
+ // know the target's ROWID from the guid alone without an extra query,
1229
+ // but createUniqueUuid is content-addressable — hashing the guid gives
1230
+ // the same UUID the original dispatch did if we key both on guid.
1231
+ const inReplyTo = row.replyToGuid
1232
+ ? createUniqueUuid(this.runtime, `imessage-guid-${row.replyToGuid}`)
1233
+ : undefined;
1234
+ const memoryContent = {
1235
+ text: row.text,
1236
+ source: "imessage",
1237
+ channelType,
1238
+ ...(inReplyTo ? { inReplyTo } : {}),
1239
+ ...(row.attachments.length > 0
1240
+ ? {
1241
+ attachments: row.attachments.map((a) => ({
1242
+ id: a.guid,
1243
+ url: "",
1244
+ title: a.filename ?? a.guid,
1245
+ contentType: mimeToContentType(a.mimeType, a.uti),
1246
+ source: "imessage",
1247
+ description: a.isSticker ? "sticker" : (a.filename ?? a.mimeType ?? a.uti ?? ""),
1248
+ text: "",
1249
+ })),
1250
+ }
1251
+ : {}),
1252
+ };
1253
+ const memory = {
1254
+ id: messageId,
1255
+ entityId,
1256
+ agentId: this.runtime.agentId,
1257
+ roomId,
1258
+ content: memoryContent,
1259
+ // The core `MemoryMetadata` type constrains its keys to a fixed set
1260
+ // (`type`, `source`, etc.), so we stash plugin-specific fields by
1261
+ // widening via an unknown cast. Downstream providers read these
1262
+ // keys dynamically so the static shape doesn't matter at runtime.
1263
+ metadata: {
1264
+ type: MemoryType.MESSAGE,
1265
+ source: "imessage",
1266
+ provider: "imessage",
1267
+ accountId,
1268
+ timestamp: row.timestamp || Date.now(),
1269
+ entityName: resolvedName ?? row.displayName ?? row.handle ?? undefined,
1270
+ entityUserName: row.handle ?? undefined,
1271
+ fromBot: row.isFromMe,
1272
+ fromId: row.handle,
1273
+ sourceId: entityId,
1274
+ chatType: channelType,
1275
+ messageIdFull: row.guid,
1276
+ sender: {
1277
+ id: row.handle,
1278
+ name: resolvedName ?? row.displayName ?? row.handle ?? undefined,
1279
+ username: row.handle ?? undefined,
1280
+ },
1281
+ imessage: {
1282
+ accountId,
1283
+ id: row.handle,
1284
+ userId: row.handle,
1285
+ username: row.handle,
1286
+ userName: row.handle,
1287
+ name: resolvedName ?? row.displayName ?? row.handle ?? undefined,
1288
+ chatId: roomKey,
1289
+ guid: row.guid,
1290
+ rowId: row.rowId,
1291
+ service: row.service,
1292
+ },
1293
+ // Raw handle + resolved contact name for connector target context.
1294
+ ...(row.handle ? { imessageHandle: row.handle } : {}),
1295
+ ...(resolvedName ? { imessageContactName: resolvedName } : {}),
1296
+ // Delivery service: iMessage / SMS / RCS.
1297
+ ...(row.service ? { imessageService: row.service } : {}),
1298
+ // Stable correlation keys across restarts.
1299
+ imessageGuid: row.guid,
1300
+ imessageRowId: row.rowId,
1301
+ // Editing / retraction state for downstream filters.
1302
+ ...(row.dateEdited ? { imessageEditedAt: row.dateEdited } : {}),
1303
+ ...(row.dateRetracted ? { imessageRetractedAt: row.dateRetracted } : {}),
1304
+ },
1305
+ createdAt: row.timestamp || Date.now(),
1306
+ };
1307
+ // Reply callback: when the agent produces a response, send it back out
1308
+ // through the existing AppleScript send path. For groups we send to
1309
+ // `chat_id:<identifier>` so AppleScript targets the whole chat; for
1310
+ // DMs we target the sender's handle directly.
1311
+ const replyTarget = row.chatType === "group" ? `chat_id:${row.chatId}` : row.handle;
1312
+ const callback = async (content) => {
1313
+ const replyText = content.text?.trim();
1314
+ if (!replyText || !this.runtime) {
1315
+ return [];
1316
+ }
1317
+ const sendResult = await this.sendViaAppleScript(replyTarget, replyText);
1318
+ if (!sendResult.success) {
1319
+ logger.error(`[imessage] Reply send failed for ROWID=${row.rowId}: ${sendResult.error}`);
1320
+ return [];
1321
+ }
1322
+ const responseMemory = {
1323
+ id: createUniqueUuid(this.runtime, `imessage-reply-${row.rowId}-${Date.now()}`),
1324
+ entityId: this.runtime.agentId,
1325
+ agentId: this.runtime.agentId,
1326
+ roomId,
1327
+ content: {
1328
+ ...content,
1329
+ source: "imessage",
1330
+ channelType,
1331
+ inReplyTo: messageId,
1332
+ },
1333
+ metadata: {
1334
+ type: MemoryType.MESSAGE,
1335
+ source: "imessage",
1336
+ provider: "imessage",
1337
+ accountId,
1338
+ imessage: {
1339
+ accountId,
1340
+ chatId: roomKey,
1341
+ },
1342
+ },
1343
+ createdAt: Date.now(),
1344
+ };
1345
+ await this.runtime.createMemory(responseMemory, "messages");
1346
+ return [responseMemory];
1347
+ };
1348
+ // Emit the plugin-namespaced event for any iMessage-specific listeners
1349
+ // (kept for backwards compatibility with pre-fix code) and the generic
1350
+ // core event so anything subscribed to EventType.MESSAGE_RECEIVED sees it.
1351
+ this.runtime.emitEvent(IMessageEventTypes.MESSAGE_RECEIVED, {
1352
+ runtime: this.runtime,
1353
+ message: memory,
1354
+ source: "imessage",
1355
+ accountId,
1356
+ callback,
1357
+ });
1358
+ this.runtime.emitEvent(EventType.MESSAGE_RECEIVED, {
1359
+ runtime: this.runtime,
1360
+ message: memory,
1361
+ source: "imessage",
1362
+ accountId,
1363
+ callback,
1364
+ });
1365
+ // Inbound messages are always ingested + emitted above. The agent only
1366
+ // auto-generates a reply when IMESSAGE_AUTO_REPLY is explicitly enabled —
1367
+ // default-off prevents the runtime from speaking on the user's behalf to
1368
+ // real iMessage contacts.
1369
+ const autoReplyRaw = this.runtime.getSetting("IMESSAGE_AUTO_REPLY");
1370
+ const autoReply = !lifeOpsPassiveConnectorsEnabled(this.runtime) &&
1371
+ (autoReplyRaw === true || autoReplyRaw === "true");
1372
+ if (!autoReply) {
1373
+ // Persist the inbound memory so LifeOps and history views still see it.
1374
+ try {
1375
+ await this.runtime.createMemory(memory, "messages");
1376
+ }
1377
+ catch (err) {
1378
+ logger.warn(`[imessage] Failed to persist inbound memory for ROWID=${row.rowId}: ${err instanceof Error ? err.message : String(err)}`);
1379
+ }
1380
+ logger.debug("[imessage] Auto-reply disabled (IMESSAGE_AUTO_REPLY=false); message ingested without response");
1381
+ return;
1382
+ }
1383
+ // Route through the message service — this is what actually triggers
1384
+ // the agent's decision pipeline (shouldRespond → generate → callback).
1385
+ if (!this.runtime.messageService) {
1386
+ logger.error("[imessage] runtime.messageService is null; cannot route inbound message. " +
1387
+ "Ensure the bootstrap plugin (or a custom IMessageService) is registered " +
1388
+ "before the iMessage plugin starts.");
1389
+ return;
1390
+ }
1391
+ await this.runtime.messageService.handleMessage(this.runtime, memory, callback);
1392
+ }
1393
+ /**
1394
+ * Handle non-conversational chat.db rows — reactions, group events,
1395
+ * anything that isn't a normal text turn. These shouldn't flow through
1396
+ * `messageService.handleMessage` (which would try to generate a reply)
1397
+ * but they're still useful to surface as plugin-namespaced events so
1398
+ * listeners can react, log, or update state.
1399
+ *
1400
+ * Emits two events:
1401
+ * - A plugin-namespaced event on the existing `IMessageEventTypes`
1402
+ * enum (e.g. `IMESSAGE_REACTION_RECEIVED`).
1403
+ * - The generic `EventType.REACTION_RECEIVED` from core when the
1404
+ * row is a reaction, so core-level handlers see it.
1405
+ */
1406
+ emitAuxiliaryEvent(row) {
1407
+ if (!this.runtime)
1408
+ return;
1409
+ const accountId = IMESSAGE_LOCAL_ACCOUNT_ID;
1410
+ if (row.kind === "reaction" && row.reaction) {
1411
+ logger.debug(`[imessage] reaction ${row.reaction.add ? "+" : "-"}${row.reaction.kind} on guid=${row.reaction.targetGuid} by ${row.handle}`);
1412
+ this.runtime.emitEvent(IMessageEventTypes.REACTION_RECEIVED, {
1413
+ runtime: this.runtime,
1414
+ source: "imessage",
1415
+ accountId,
1416
+ chatId: row.chatId,
1417
+ handle: row.handle,
1418
+ targetGuid: row.reaction.targetGuid,
1419
+ reactionKind: row.reaction.kind,
1420
+ add: row.reaction.add,
1421
+ emoji: row.reaction.emoji,
1422
+ service: row.service,
1423
+ });
1424
+ this.runtime.emitEvent(EventType.REACTION_RECEIVED, {
1425
+ runtime: this.runtime,
1426
+ source: "imessage",
1427
+ accountId,
1428
+ targetGuid: row.reaction.targetGuid,
1429
+ reactionKind: row.reaction.kind,
1430
+ add: row.reaction.add,
1431
+ emoji: row.reaction.emoji,
1432
+ });
1433
+ return;
1434
+ }
1435
+ if (row.kind === "system") {
1436
+ logger.debug(`[imessage] system event ROWID=${row.rowId} handle=${row.handle} chat=${row.chatId}`);
1437
+ // No core equivalent — plugin-namespaced only.
1438
+ this.runtime.emitEvent("IMESSAGE_SYSTEM_EVENT", {
333
1439
  runtime: this.runtime,
334
- message: msg,
1440
+ source: "imessage",
1441
+ accountId,
1442
+ chatId: row.chatId,
1443
+ handle: row.handle,
1444
+ rowId: row.rowId,
1445
+ guid: row.guid,
335
1446
  });
336
- this.lastMessageId = msg.id;
337
1447
  }
338
1448
  }
339
1449
  isAllowed(handle) {
@@ -352,12 +1462,148 @@ export class IMessageService extends Service {
352
1462
  // pairing - allow and track
353
1463
  return true;
354
1464
  }
355
- parseMessagesResult(result) {
356
- return parseMessagesFromAppleScript(result);
1465
+ /**
1466
+ * Register a recurring heartbeat task with Eliza's task system and
1467
+ * kick off one if it isn't already queued.
1468
+ *
1469
+ * The heartbeat runs once a minute (configurable via
1470
+ * `IMESSAGE_HEARTBEAT_INTERVAL_MS`) and does a lightweight health
1471
+ * probe: (a) chat.db reader is still open and responsive,
1472
+ * (b) Contacts map still populated, (c) polling cursor is advancing
1473
+ * when expected. On failure it logs + emits
1474
+ * `IMESSAGE_HEARTBEAT_UNHEALTHY`; on success it emits
1475
+ * `IMESSAGE_HEARTBEAT_OK`. Observers (Eliza's heartbeat UI, ops
1476
+ * dashboards, trajectory logger) subscribe to these events.
1477
+ *
1478
+ * The task is tagged `["queue", "repeat", "imessage"]` so Eliza's
1479
+ * built-in TaskService picks it up via its standard polling loop.
1480
+ * Without `updateInterval` being set in metadata, the task fires
1481
+ * once and then deletes; with it, the task service re-schedules.
1482
+ */
1483
+ async registerHeartbeat() {
1484
+ if (!this.runtime)
1485
+ return;
1486
+ if (typeof this.runtime.registerTaskWorker !== "function") {
1487
+ logger.debug("[imessage][heartbeat] runtime does not support registerTaskWorker — skipping");
1488
+ return;
1489
+ }
1490
+ const heartbeatIntervalMs = Number(process.env.IMESSAGE_HEARTBEAT_INTERVAL_MS) || 60_000;
1491
+ this.runtime.registerTaskWorker({
1492
+ name: "IMESSAGE_HEARTBEAT",
1493
+ execute: async (runtime, _options, _task) => {
1494
+ let ok = true;
1495
+ let reason = "";
1496
+ let tip = 0;
1497
+ let contactsCount = 0;
1498
+ try {
1499
+ if (!this.chatDb) {
1500
+ ok = false;
1501
+ reason = "chat.db reader not available (send-only mode)";
1502
+ }
1503
+ else {
1504
+ tip = this.chatDb.getLatestRowId();
1505
+ if (tip <= 0) {
1506
+ ok = false;
1507
+ reason = "chat.db getLatestRowId returned 0";
1508
+ }
1509
+ }
1510
+ contactsCount = this.contacts.size;
1511
+ }
1512
+ catch (err) {
1513
+ ok = false;
1514
+ reason = err instanceof Error ? err.message : String(err);
1515
+ }
1516
+ logger.debug(`[imessage][heartbeat] ok=${ok} tip=${tip} cursor=${this.lastRowId} contacts=${contactsCount}${reason ? ` reason=${reason}` : ""}`);
1517
+ runtime.emitEvent(ok ? "IMESSAGE_HEARTBEAT_OK" : "IMESSAGE_HEARTBEAT_UNHEALTHY", {
1518
+ runtime,
1519
+ source: "imessage",
1520
+ ok,
1521
+ reason,
1522
+ tip,
1523
+ cursor: this.lastRowId,
1524
+ contactsCount,
1525
+ connected: this.connected,
1526
+ timestamp: Date.now(),
1527
+ });
1528
+ return { nextInterval: heartbeatIntervalMs };
1529
+ },
1530
+ shouldRun: async () => true,
1531
+ });
1532
+ // Only create the task if one doesn't already exist. This is safe
1533
+ // across restarts — on a cold boot no task exists yet, on a warm
1534
+ // restart the previous one is still in the queue and we skip.
1535
+ if (hasTaskLookup(this.runtime)) {
1536
+ try {
1537
+ const existing = await this.runtime.getTasksByName("IMESSAGE_HEARTBEAT");
1538
+ if (Array.isArray(existing) && existing.length > 0) {
1539
+ logger.debug(`[imessage][heartbeat] task already registered (${existing.length} existing) — skipping createTask`);
1540
+ return;
1541
+ }
1542
+ }
1543
+ catch {
1544
+ // If the query fails, fall through and try createTask anyway.
1545
+ }
1546
+ }
1547
+ if (typeof this.runtime.createTask === "function") {
1548
+ try {
1549
+ await this.runtime.createTask({
1550
+ name: "IMESSAGE_HEARTBEAT",
1551
+ description: "Periodic health probe for the iMessage connector (chat.db reader, contacts, polling cursor).",
1552
+ metadata: {
1553
+ updatedAt: Date.now(),
1554
+ updateInterval: heartbeatIntervalMs,
1555
+ blocking: true,
1556
+ },
1557
+ tags: ["queue", "repeat", "imessage"],
1558
+ });
1559
+ logger.debug(`[imessage][heartbeat] task registered, interval ${heartbeatIntervalMs}ms`);
1560
+ }
1561
+ catch (err) {
1562
+ logger.warn(`[imessage][heartbeat] createTask failed: ${err instanceof Error ? err.message : String(err)}`);
1563
+ }
1564
+ }
357
1565
  }
358
- parseChatsResult(result) {
359
- return parseChatsFromAppleScript(result);
1566
+ }
1567
+ /**
1568
+ * Resolve a chat.db attachment's mime/UTI to one of the five ContentType
1569
+ * buckets the core Memory.content type accepts. Anything we can't bucket
1570
+ * falls back to `"document"` which is the catch-all.
1571
+ */
1572
+ function mimeToContentType(mimeType, uti) {
1573
+ const m = (mimeType ?? "").toLowerCase();
1574
+ const u = (uti ?? "").toLowerCase();
1575
+ if (m.startsWith("image/") || u.includes("image"))
1576
+ return ContentType.IMAGE;
1577
+ if (m.startsWith("video/") || u.includes("movie") || u.includes("video")) {
1578
+ return ContentType.VIDEO;
360
1579
  }
1580
+ if (m.startsWith("audio/") || u.includes("audio"))
1581
+ return ContentType.AUDIO;
1582
+ return ContentType.DOCUMENT;
1583
+ }
1584
+ /**
1585
+ * Convert a `ChatDbMessage` (the shape the bun:sqlite reader returns)
1586
+ * into the public `IMessageMessage` shape exposed by this plugin's API.
1587
+ * Exported so the test suite can exercise it in isolation without
1588
+ * spinning up a full runtime + service instance.
1589
+ */
1590
+ export function chatDbMessageToPublicShape(row) {
1591
+ return {
1592
+ id: String(row.rowId),
1593
+ text: row.text,
1594
+ handle: row.handle,
1595
+ chatId: row.chatId,
1596
+ timestamp: row.timestamp,
1597
+ isFromMe: row.isFromMe,
1598
+ hasAttachments: row.attachments.length > 0,
1599
+ ...(row.attachments.length > 0
1600
+ ? {
1601
+ attachmentPaths: row.attachments
1602
+ .map((attachment) => attachment.filename)
1603
+ .filter((filename) => Boolean(filename)),
1604
+ }
1605
+ : {}),
1606
+ };
361
1607
  }
362
1608
  /**
363
1609
  * Parse tab-delimited AppleScript messages output.
@@ -365,7 +1611,7 @@ export class IMessageService extends Service {
365
1611
  */
366
1612
  export function parseMessagesFromAppleScript(result) {
367
1613
  const messages = [];
368
- if (!result || !result.trim()) {
1614
+ if (!result?.trim()) {
369
1615
  return messages;
370
1616
  }
371
1617
  for (const line of result.split("\n")) {
@@ -406,7 +1652,22 @@ export function parseMessagesFromAppleScript(result) {
406
1652
  */
407
1653
  export function parseChatsFromAppleScript(result) {
408
1654
  const chats = [];
409
- if (!result || !result.trim()) {
1655
+ if (!result?.trim()) {
1656
+ return chats;
1657
+ }
1658
+ const listStyleEntryPattern = /\{\s*"([^"]*)"\s*,\s*(?:"([^"]*)"|(missing value))\s*\}/g;
1659
+ const listStyleMatches = Array.from(result.matchAll(listStyleEntryPattern));
1660
+ if (listStyleMatches.length > 0) {
1661
+ for (const match of listStyleMatches) {
1662
+ const chatIdentifier = match[1] ?? "";
1663
+ const displayName = match[2];
1664
+ chats.push({
1665
+ chatId: chatIdentifier,
1666
+ chatType: chatIdentifier.includes(";+;") ? "group" : "direct",
1667
+ displayName: displayName || undefined,
1668
+ participants: [],
1669
+ });
1670
+ }
410
1671
  return chats;
411
1672
  }
412
1673
  for (const line of result.split("\n")) {