imsg-mcp 1.2.0 → 1.3.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 (32) hide show
  1. package/CHANGELOG.md +28 -0
  2. package/README.md +4 -1
  3. package/dist/cli.js +49 -7
  4. package/dist/cli.js.map +1 -1
  5. package/dist/{types-Bbi1xtl-.js → exportFormats-wytSyBTp.js} +6 -12
  6. package/dist/exportFormats-wytSyBTp.js.map +1 -0
  7. package/dist/{exportStream-Bu5recJ4.js → exportStream-BCoZYUUZ.js} +19 -23
  8. package/dist/exportStream-BCoZYUUZ.js.map +1 -0
  9. package/dist/{imessage-db-BYU5mxM-.js → imessage-db-vDZF7E8B.js} +828 -410
  10. package/dist/imessage-db-vDZF7E8B.js.map +1 -0
  11. package/dist/index.js +71 -35
  12. package/dist/index.js.map +1 -1
  13. package/dist/{meta-D3NoTAjA.js → meta-DCZ9Xe9g.js} +2 -2
  14. package/dist/meta-DCZ9Xe9g.js.map +1 -0
  15. package/dist/{setup-C8Ys73o9.js → setup-B2HejfMT.js} +2 -2
  16. package/dist/{setup-C8Ys73o9.js.map → setup-B2HejfMT.js.map} +1 -1
  17. package/dist/{shutdown-BJifUGdo.js → shutdown-CikCSZru.js} +4 -4
  18. package/dist/{shutdown-BJifUGdo.js.map → shutdown-CikCSZru.js.map} +1 -1
  19. package/dist/tui.js +32 -14
  20. package/dist/tui.js.map +1 -1
  21. package/dist/{watchdog-0RGOHpj4.js → watchdog-CnbBZnfg.js} +11 -2
  22. package/dist/watchdog-CnbBZnfg.js.map +1 -0
  23. package/native/imsg-native.darwin-arm64.node +0 -0
  24. package/native/index.d.ts +2 -74
  25. package/native/index.js +0 -2
  26. package/package.json +3 -2
  27. package/skills/imsg-mcp/SKILL.md +5 -0
  28. package/dist/exportStream-Bu5recJ4.js.map +0 -1
  29. package/dist/imessage-db-BYU5mxM-.js.map +0 -1
  30. package/dist/meta-D3NoTAjA.js.map +0 -1
  31. package/dist/types-Bbi1xtl-.js.map +0 -1
  32. package/dist/watchdog-0RGOHpj4.js.map +0 -1
@@ -1,11 +1,378 @@
1
1
  import { homedir } from "node:os";
2
- import { dirname, join } from "node:path";
2
+ import { join, dirname } from "node:path";
3
3
  import Database from "better-sqlite3";
4
4
  import { createRequire } from "node:module";
5
5
  import { fileURLToPath } from "node:url";
6
6
  import { existsSync, readdirSync, mkdirSync } from "node:fs";
7
- import { O as OBJECT_REPLACEMENT_CHAR, p as perf, J as isGroupGuid, K as isGroupChatIdentifier, L as generateThreadSlug, T as Tables, M as AssociatedMessageType, N as macAutoTimestampToDate, P as isReactionType, Q as MAC_EPOCH_OFFSET, R as NANOS_PER_SECOND, S as macTimestampToDate$1, U as parseAssociatedMessageGuid$1 } from "./shutdown-BJifUGdo.js";
7
+ import { O as OBJECT_REPLACEMENT_CHAR, p as perf, J as isGroupGuid, K as isGroupChatIdentifier, L as generateThreadSlug, T as Tables, M as AssociatedMessageType, N as macAutoTimestampToDate, P as isReactionType, Q as MAC_EPOCH_OFFSET, R as NANOS_PER_SECOND, S as parseAssociatedMessageGuid$1, U as macTimestampToDate$1 } from "./shutdown-CikCSZru.js";
8
8
  import { parseBuffer } from "bplist-parser";
9
+ function normalizePhoneNumber(phone) {
10
+ const digits = phone.replace(/\D/g, "");
11
+ if (digits.length === 11 && digits.startsWith("1")) {
12
+ return digits.slice(1);
13
+ }
14
+ return digits;
15
+ }
16
+ function normalizedPhoneVariants(phone) {
17
+ const normalized = normalizePhoneNumber(phone);
18
+ const variants = /* @__PURE__ */ new Set([normalized]);
19
+ if (normalized.length === 11 && normalized.startsWith("61")) {
20
+ const localMobile = normalized.slice(2);
21
+ variants.add(localMobile);
22
+ if (localMobile.length === 9 && localMobile.startsWith("4")) {
23
+ variants.add(`0${localMobile}`);
24
+ }
25
+ }
26
+ if (normalized.length === 10 && normalized.startsWith("04")) {
27
+ const mobileDigits = normalized.slice(1);
28
+ variants.add(mobileDigits);
29
+ variants.add(`61${mobileDigits}`);
30
+ }
31
+ if (normalized.length === 10) {
32
+ variants.add(`1${normalized}`);
33
+ }
34
+ if (normalized.length === 9 && normalized.startsWith("4")) {
35
+ variants.add(`61${normalized}`);
36
+ variants.add(`0${normalized}`);
37
+ }
38
+ return [...variants];
39
+ }
40
+ function normalizeEmail(email) {
41
+ return email.toLowerCase().trim();
42
+ }
43
+ function normalizeNameForMatch(name) {
44
+ if (!name) return "";
45
+ return name.normalize("NFD").replace(/[\u0300-\u036f]/g, "").toLowerCase().replace(/\s+/g, " ").trim();
46
+ }
47
+ function nameCandidates(fields) {
48
+ const out = /* @__PURE__ */ new Set();
49
+ const add = (n) => {
50
+ const norm = normalizeNameForMatch(n);
51
+ if (norm) out.add(norm);
52
+ };
53
+ add(fields.nickname);
54
+ const full = [fields.firstName, fields.lastName].filter(Boolean).join(" ");
55
+ add(full);
56
+ add(fields.organization);
57
+ return out;
58
+ }
59
+ const ADDRESS_BOOK_DIR = join(homedir(), "Library", "Application Support", "AddressBook");
60
+ const MAIN_DB_NAME = "AddressBook-v22.abcddb";
61
+ function discoverContactDbPaths(customPaths) {
62
+ if (customPaths) {
63
+ const list = Array.isArray(customPaths) ? customPaths : [customPaths];
64
+ return list.filter((p) => existsSync(p));
65
+ }
66
+ const paths = [];
67
+ const mainDb = join(ADDRESS_BOOK_DIR, MAIN_DB_NAME);
68
+ if (existsSync(mainDb)) paths.push(mainDb);
69
+ const sourcesDir = join(ADDRESS_BOOK_DIR, "Sources");
70
+ if (existsSync(sourcesDir)) {
71
+ try {
72
+ const subdirs = readdirSync(sourcesDir, { withFileTypes: true });
73
+ for (const d of subdirs) {
74
+ if (!d.isDirectory()) continue;
75
+ const sourceDb = join(sourcesDir, d.name, MAIN_DB_NAME);
76
+ if (existsSync(sourceDb)) paths.push(sourceDb);
77
+ }
78
+ } catch {
79
+ }
80
+ }
81
+ return paths;
82
+ }
83
+ class ContactsDB {
84
+ dbPaths = [];
85
+ databases = [];
86
+ phoneMap = /* @__PURE__ */ new Map();
87
+ emailMap = /* @__PURE__ */ new Map();
88
+ contactCache = /* @__PURE__ */ new Map();
89
+ initialized = false;
90
+ /** Next id when loading from multiple DBs (avoids Z_PK collisions across sources). */
91
+ nextContactId = 1;
92
+ constructor(dbPaths) {
93
+ this.dbPaths = discoverContactDbPaths(dbPaths);
94
+ }
95
+ /**
96
+ * Initialize the contact lookup maps from all discovered DBs (local + iCloud/sources).
97
+ */
98
+ initialize() {
99
+ if (this.initialized) return;
100
+ for (const path of this.dbPaths) {
101
+ try {
102
+ const db = new Database(path, { readonly: true });
103
+ this.databases.push(db);
104
+ this.loadContactsFromDb(db);
105
+ } catch (err) {
106
+ console.warn(`ContactsDB: could not load ${path}:`, err);
107
+ }
108
+ }
109
+ this.initialized = true;
110
+ }
111
+ /**
112
+ * Load contacts from a single Address Book SQLite DB into the shared maps.
113
+ */
114
+ loadContactsFromDb(db) {
115
+ const contacts = db.prepare(`
116
+ SELECT
117
+ Z_PK as localId,
118
+ ZFIRSTNAME as firstName,
119
+ ZLASTNAME as lastName,
120
+ ZMIDDLENAME as middleName,
121
+ ZNICKNAME as nickname,
122
+ ZORGANIZATION as organization
123
+ FROM ZABCDRECORD
124
+ WHERE ZFIRSTNAME IS NOT NULL OR ZLASTNAME IS NOT NULL OR ZORGANIZATION IS NOT NULL
125
+ `).all();
126
+ for (const row of contacts) {
127
+ const localId = row.localId;
128
+ const phones = db.prepare(`
129
+ SELECT ZFULLNUMBER as number, ZLABEL as label
130
+ FROM ZABCDPHONENUMBER
131
+ WHERE ZOWNER = ? OR Z22_OWNER = ?
132
+ `).all(localId, localId).filter((p) => p.number);
133
+ const emails = db.prepare(`
134
+ SELECT ZADDRESS as email, ZLABEL as label
135
+ FROM ZABCDEMAILADDRESS
136
+ WHERE ZOWNER = ? OR Z22_OWNER = ?
137
+ `).all(localId, localId).filter((e) => e.email);
138
+ const existingIds = this.findExistingContactIds(
139
+ phones.map((p) => p.number),
140
+ emails.map((e) => e.email)
141
+ );
142
+ const cardNames = nameCandidates(row);
143
+ const sameEntityIds = existingIds.filter((id) => {
144
+ const existing = this.contactCache.get(id);
145
+ if (!existing) return false;
146
+ const existingNames = nameCandidates(existing);
147
+ if (cardNames.size === 0 || existingNames.size === 0) return true;
148
+ for (const n of cardNames) if (existingNames.has(n)) return true;
149
+ return false;
150
+ });
151
+ let contact;
152
+ if (sameEntityIds.length === 0) {
153
+ contact = {
154
+ id: this.nextContactId++,
155
+ firstName: row.firstName,
156
+ lastName: row.lastName,
157
+ middleName: row.middleName,
158
+ nickname: row.nickname,
159
+ organization: row.organization,
160
+ displayName: this.buildDisplayName(row),
161
+ phoneNumbers: [],
162
+ emails: []
163
+ };
164
+ } else {
165
+ const survivorId = sameEntityIds[0];
166
+ for (let i = 1; i < sameEntityIds.length; i++) {
167
+ this.mergeContacts(survivorId, sameEntityIds[i]);
168
+ }
169
+ contact = this.contactCache.get(survivorId);
170
+ }
171
+ const handleName = this.buildDisplayName(row);
172
+ for (const phone of phones) {
173
+ if (!contact.phoneNumbers.includes(phone.number)) contact.phoneNumbers.push(phone.number);
174
+ for (const variant of normalizedPhoneVariants(phone.number)) {
175
+ if (!this.phoneMap.has(variant)) {
176
+ this.phoneMap.set(variant, {
177
+ contactId: contact.id,
178
+ displayName: handleName,
179
+ label: phone.label
180
+ });
181
+ }
182
+ }
183
+ }
184
+ for (const email of emails) {
185
+ if (!contact.emails.includes(email.email)) contact.emails.push(email.email);
186
+ const key = normalizeEmail(email.email);
187
+ if (!this.emailMap.has(key)) {
188
+ this.emailMap.set(key, {
189
+ contactId: contact.id,
190
+ displayName: handleName,
191
+ label: email.label
192
+ });
193
+ }
194
+ }
195
+ this.contactCache.set(contact.id, contact);
196
+ }
197
+ }
198
+ /**
199
+ * Probe the already-loaded lookup maps for every existing contactId that
200
+ * shares any of these handles (loaded from earlier source DBs). Returns the
201
+ * distinct ids in first-seen order; the caller treats the first as the
202
+ * survivor and unions the rest. Empty when none match.
203
+ */
204
+ findExistingContactIds(phones, emails) {
205
+ const ids = [];
206
+ const seen = /* @__PURE__ */ new Set();
207
+ const add = (id) => {
208
+ if (!seen.has(id)) {
209
+ seen.add(id);
210
+ ids.push(id);
211
+ }
212
+ };
213
+ for (const number of phones) {
214
+ for (const variant of normalizedPhoneVariants(number)) {
215
+ const hit = this.phoneMap.get(variant);
216
+ if (hit) add(hit.contactId);
217
+ }
218
+ }
219
+ for (const email of emails) {
220
+ const hit = this.emailMap.get(normalizeEmail(email));
221
+ if (hit) add(hit.contactId);
222
+ }
223
+ return ids;
224
+ }
225
+ /**
226
+ * Fold contact `dropId` into `keepId`: union their phone/email lists and
227
+ * re-point every lookup-map entry (preserving labels) from the dropped id to
228
+ * the survivor, then forget the dropped id. Used when one card bridges two
229
+ * previously-separate identities.
230
+ */
231
+ mergeContacts(keepId, dropId) {
232
+ if (keepId === dropId) return;
233
+ const keep = this.contactCache.get(keepId);
234
+ const drop = this.contactCache.get(dropId);
235
+ if (!keep || !drop) return;
236
+ for (const p of drop.phoneNumbers)
237
+ if (!keep.phoneNumbers.includes(p)) keep.phoneNumbers.push(p);
238
+ for (const e of drop.emails) if (!keep.emails.includes(e)) keep.emails.push(e);
239
+ for (const [key, lookup] of this.phoneMap) {
240
+ if (lookup.contactId === dropId) {
241
+ this.phoneMap.set(key, { ...lookup, contactId: keepId });
242
+ }
243
+ }
244
+ for (const [key, lookup] of this.emailMap) {
245
+ if (lookup.contactId === dropId) {
246
+ this.emailMap.set(key, { ...lookup, contactId: keepId });
247
+ }
248
+ }
249
+ this.contactCache.delete(dropId);
250
+ }
251
+ /**
252
+ * Look up a contact by phone number or email.
253
+ */
254
+ lookupContact(handle) {
255
+ if (!this.initialized) {
256
+ this.initialize();
257
+ }
258
+ if (/[\d+\-()\s]/.test(handle)) {
259
+ for (const variant of normalizedPhoneVariants(handle)) {
260
+ const contact = this.phoneMap.get(variant);
261
+ if (contact) {
262
+ return contact;
263
+ }
264
+ }
265
+ }
266
+ if (handle.includes("@")) {
267
+ const normalized = normalizeEmail(handle);
268
+ const contact = this.emailMap.get(normalized);
269
+ if (contact) {
270
+ return contact;
271
+ }
272
+ }
273
+ return null;
274
+ }
275
+ /**
276
+ * Look up a contact by phone number or email
277
+ * Returns display name if found, or the original handle if not
278
+ */
279
+ lookupHandle(handle) {
280
+ return this.lookupContact(handle)?.displayName ?? handle;
281
+ }
282
+ /**
283
+ * Get full contact details by ID
284
+ */
285
+ getContact(id) {
286
+ if (!this.initialized) {
287
+ this.initialize();
288
+ }
289
+ return this.contactCache.get(id) || null;
290
+ }
291
+ /**
292
+ * A load-order-independent identity anchor for a contact: the
293
+ * lexicographically smallest normalized handle. Contact ids are assigned in
294
+ * Address Book load order, so they renumber whenever any card is added or
295
+ * removed — anything persisted across sessions (thread-slug hashes) must key
296
+ * off this anchor instead, which only changes if the contact's own handles
297
+ * change.
298
+ */
299
+ stableAnchor(contactId) {
300
+ const contact = this.getContact(contactId);
301
+ if (!contact) return null;
302
+ const handles = [
303
+ ...contact.phoneNumbers.map((p) => normalizePhoneNumber(p)),
304
+ ...contact.emails.map((e) => normalizeEmail(e))
305
+ ].filter((h) => h.length > 0);
306
+ if (handles.length === 0) return null;
307
+ handles.sort();
308
+ return handles[0];
309
+ }
310
+ /**
311
+ * Search contacts by name
312
+ */
313
+ searchContacts(query) {
314
+ if (!this.initialized) {
315
+ this.initialize();
316
+ }
317
+ const lowerQuery = query.toLowerCase();
318
+ return Array.from(this.contactCache.values()).filter(
319
+ (c) => c.displayName.toLowerCase().includes(lowerQuery) || c.phoneNumbers.some((p) => p.includes(query)) || c.emails.some((e) => e.toLowerCase().includes(lowerQuery))
320
+ );
321
+ }
322
+ /**
323
+ * Build display name from contact fields
324
+ */
325
+ buildDisplayName(row) {
326
+ if (row.nickname) return row.nickname;
327
+ const parts = [];
328
+ if (row.firstName) parts.push(row.firstName);
329
+ if (row.lastName) parts.push(row.lastName);
330
+ if (parts.length > 0) {
331
+ return parts.join(" ");
332
+ }
333
+ if (row.organization) return row.organization;
334
+ return "Unknown Contact";
335
+ }
336
+ /**
337
+ * Close all database connections
338
+ */
339
+ close() {
340
+ for (const db of this.databases) {
341
+ try {
342
+ db.close();
343
+ } catch {
344
+ }
345
+ }
346
+ this.databases = [];
347
+ }
348
+ /**
349
+ * Paginated list of all loaded contacts, sorted by displayName.
350
+ * Used by the MCP `list_contacts` tool.
351
+ */
352
+ listContacts(offset = 0, limit = 20) {
353
+ if (!this.initialized) {
354
+ this.initialize();
355
+ }
356
+ const all = Array.from(this.contactCache.values()).sort(
357
+ (a, b) => a.displayName.localeCompare(b.displayName)
358
+ );
359
+ const slice = limit === 0 ? all.slice(offset) : all.slice(offset, offset + limit);
360
+ return { contacts: slice, total: all.length };
361
+ }
362
+ /**
363
+ * Get statistics about loaded contacts
364
+ */
365
+ getStats() {
366
+ if (!this.initialized) {
367
+ this.initialize();
368
+ }
369
+ return {
370
+ totalContacts: this.contactCache.size,
371
+ phoneNumbers: this.phoneMap.size,
372
+ emails: this.emailMap.size
373
+ };
374
+ }
375
+ }
9
376
  const __dirname$1 = dirname(fileURLToPath(import.meta.url));
10
377
  let _native;
11
378
  function tryLoadNative() {
@@ -307,317 +674,59 @@ function normalizeCandidate(text) {
307
674
  function scoreCandidate(text) {
308
675
  let score = 0;
309
676
  if (/[A-Za-z]/.test(text)) score += 50;
310
- if (/\d/.test(text)) score += 10;
311
- if (/\s/.test(text)) score += 30;
312
- if (/[.!?]/.test(text)) score += 15;
313
- if (/[\u{1F300}-\u{1FAFF}]/u.test(text)) score += 100;
314
- if (/^[A-Za-z0-9]{1,3}$/.test(text)) score -= 100;
315
- score += Math.min(text.length, 200);
316
- return score;
317
- }
318
- const STRUCTURED_BOOST = 500;
319
- function extractAttributedBodyText(blob) {
320
- if (!blob) return void 0;
321
- const native = getNativeParser();
322
- if (native) {
323
- try {
324
- const result = native(blob);
325
- if (result && result.length > 1 && nativeResultLooksTrustworthy(result)) {
326
- return result;
327
- }
328
- } catch {
329
- }
330
- }
331
- const parser = new TypedStreamParser(blob);
332
- const candidates = /* @__PURE__ */ new Map();
333
- const deadline = Date.now() + PARSE_TIMEOUT_MS;
334
- const remember = (values, boost = 0) => {
335
- for (const value of values) {
336
- const normalized = normalizeCandidate(value);
337
- if (!normalized) continue;
338
- const score = scoreCandidate(normalized) + boost;
339
- const prev = candidates.get(normalized) ?? -Infinity;
340
- if (score > prev) candidates.set(normalized, score);
341
- }
342
- };
343
- try {
344
- if (Date.now() < deadline) {
345
- remember(
346
- parser.parseAllNSStrings().map((entry) => entry.content),
347
- STRUCTURED_BOOST
348
- );
349
- }
350
- } catch {
351
- }
352
- try {
353
- if (Date.now() < deadline) {
354
- remember(parser.extractReadableText());
355
- }
356
- } catch {
357
- }
358
- if (Date.now() < deadline) {
359
- remember(blob.toString("utf8").split(CONTROL_SPLIT_RE));
360
- }
361
- const best = [...candidates.entries()].sort((a, b) => b[1] - a[1])[0];
362
- return best?.[0];
363
- }
364
- function normalizePhoneNumber(phone) {
365
- const digits = phone.replace(/\D/g, "");
366
- if (digits.length === 11 && digits.startsWith("1")) {
367
- return digits.slice(1);
368
- }
369
- return digits;
370
- }
371
- function normalizedPhoneVariants(phone) {
372
- const normalized = normalizePhoneNumber(phone);
373
- const variants = /* @__PURE__ */ new Set([normalized]);
374
- if (normalized.length === 11 && normalized.startsWith("61")) {
375
- const localMobile = normalized.slice(2);
376
- variants.add(localMobile);
377
- if (localMobile.length === 9 && localMobile.startsWith("4")) {
378
- variants.add(`0${localMobile}`);
379
- }
380
- }
381
- if (normalized.length === 10 && normalized.startsWith("04")) {
382
- const mobileDigits = normalized.slice(1);
383
- variants.add(mobileDigits);
384
- variants.add(`61${mobileDigits}`);
385
- }
386
- if (normalized.length === 10) {
387
- variants.add(`1${normalized}`);
388
- }
389
- if (normalized.length === 9 && normalized.startsWith("4")) {
390
- variants.add(`61${normalized}`);
391
- variants.add(`0${normalized}`);
392
- }
393
- return [...variants];
394
- }
395
- function normalizeEmail(email) {
396
- return email.toLowerCase().trim();
397
- }
398
- const ADDRESS_BOOK_DIR = join(homedir(), "Library", "Application Support", "AddressBook");
399
- const MAIN_DB_NAME = "AddressBook-v22.abcddb";
400
- function discoverContactDbPaths(customPaths) {
401
- if (customPaths) {
402
- const list = Array.isArray(customPaths) ? customPaths : [customPaths];
403
- return list.filter((p) => existsSync(p));
404
- }
405
- const paths = [];
406
- const mainDb = join(ADDRESS_BOOK_DIR, MAIN_DB_NAME);
407
- if (existsSync(mainDb)) paths.push(mainDb);
408
- const sourcesDir = join(ADDRESS_BOOK_DIR, "Sources");
409
- if (existsSync(sourcesDir)) {
410
- try {
411
- const subdirs = readdirSync(sourcesDir, { withFileTypes: true });
412
- for (const d of subdirs) {
413
- if (!d.isDirectory()) continue;
414
- const sourceDb = join(sourcesDir, d.name, MAIN_DB_NAME);
415
- if (existsSync(sourceDb)) paths.push(sourceDb);
416
- }
417
- } catch {
418
- }
419
- }
420
- return paths;
421
- }
422
- class ContactsDB {
423
- dbPaths = [];
424
- databases = [];
425
- phoneMap = /* @__PURE__ */ new Map();
426
- emailMap = /* @__PURE__ */ new Map();
427
- contactCache = /* @__PURE__ */ new Map();
428
- initialized = false;
429
- /** Next id when loading from multiple DBs (avoids Z_PK collisions across sources). */
430
- nextContactId = 1;
431
- constructor(dbPaths) {
432
- this.dbPaths = discoverContactDbPaths(dbPaths);
433
- }
434
- /**
435
- * Initialize the contact lookup maps from all discovered DBs (local + iCloud/sources).
436
- */
437
- initialize() {
438
- if (this.initialized) return;
439
- for (const path of this.dbPaths) {
440
- try {
441
- const db = new Database(path, { readonly: true });
442
- this.databases.push(db);
443
- this.loadContactsFromDb(db);
444
- } catch (err) {
445
- console.warn(`ContactsDB: could not load ${path}:`, err);
446
- }
447
- }
448
- this.initialized = true;
449
- }
450
- /**
451
- * Load contacts from a single Address Book SQLite DB into the shared maps.
452
- */
453
- loadContactsFromDb(db) {
454
- const contacts = db.prepare(`
455
- SELECT
456
- Z_PK as localId,
457
- ZFIRSTNAME as firstName,
458
- ZLASTNAME as lastName,
459
- ZMIDDLENAME as middleName,
460
- ZNICKNAME as nickname,
461
- ZORGANIZATION as organization
462
- FROM ZABCDRECORD
463
- WHERE ZFIRSTNAME IS NOT NULL OR ZLASTNAME IS NOT NULL OR ZORGANIZATION IS NOT NULL
464
- `).all();
465
- for (const row of contacts) {
466
- const globalId = this.nextContactId++;
467
- const contact = {
468
- id: globalId,
469
- firstName: row.firstName,
470
- lastName: row.lastName,
471
- middleName: row.middleName,
472
- nickname: row.nickname,
473
- organization: row.organization,
474
- displayName: this.buildDisplayName(row),
475
- phoneNumbers: [],
476
- emails: []
477
- };
478
- const localId = row.localId;
479
- const phones = db.prepare(`
480
- SELECT ZFULLNUMBER as number, ZLABEL as label
481
- FROM ZABCDPHONENUMBER
482
- WHERE ZOWNER = ? OR Z22_OWNER = ?
483
- `).all(localId, localId);
484
- for (const phone of phones) {
485
- if (phone.number) {
486
- contact.phoneNumbers.push(phone.number);
487
- const lookup = {
488
- contactId: contact.id,
489
- displayName: contact.displayName,
490
- label: phone.label
491
- };
492
- for (const variant of normalizedPhoneVariants(phone.number)) {
493
- this.phoneMap.set(variant, lookup);
494
- }
495
- }
496
- }
497
- const emails = db.prepare(`
498
- SELECT ZADDRESS as email, ZLABEL as label
499
- FROM ZABCDEMAILADDRESS
500
- WHERE ZOWNER = ? OR Z22_OWNER = ?
501
- `).all(localId, localId);
502
- for (const email of emails) {
503
- if (email.email) {
504
- contact.emails.push(email.email);
505
- const normalized = normalizeEmail(email.email);
506
- this.emailMap.set(normalized, {
507
- contactId: contact.id,
508
- displayName: contact.displayName,
509
- label: email.label
510
- });
511
- }
512
- }
513
- this.contactCache.set(contact.id, contact);
514
- }
515
- }
516
- /**
517
- * Look up a contact by phone number or email.
518
- */
519
- lookupContact(handle) {
520
- if (!this.initialized) {
521
- this.initialize();
522
- }
523
- if (/[\d+\-()\s]/.test(handle)) {
524
- for (const variant of normalizedPhoneVariants(handle)) {
525
- const contact = this.phoneMap.get(variant);
526
- if (contact) {
527
- return contact;
528
- }
529
- }
530
- }
531
- if (handle.includes("@")) {
532
- const normalized = normalizeEmail(handle);
533
- const contact = this.emailMap.get(normalized);
534
- if (contact) {
535
- return contact;
677
+ if (/\d/.test(text)) score += 10;
678
+ if (/\s/.test(text)) score += 30;
679
+ if (/[.!?]/.test(text)) score += 15;
680
+ if (/[\u{1F300}-\u{1FAFF}]/u.test(text)) score += 100;
681
+ if (/^[A-Za-z0-9]{1,3}$/.test(text)) score -= 100;
682
+ score += Math.min(text.length, 200);
683
+ return score;
684
+ }
685
+ const STRUCTURED_BOOST = 500;
686
+ function extractAttributedBodyText(blob) {
687
+ if (!blob) return void 0;
688
+ const native = getNativeParser();
689
+ if (native) {
690
+ try {
691
+ const result = native(blob);
692
+ if (result && result.length > 1 && nativeResultLooksTrustworthy(result)) {
693
+ return result;
536
694
  }
695
+ } catch {
537
696
  }
538
- return null;
539
- }
540
- /**
541
- * Look up a contact by phone number or email
542
- * Returns display name if found, or the original handle if not
543
- */
544
- lookupHandle(handle) {
545
- return this.lookupContact(handle)?.displayName ?? handle;
546
- }
547
- /**
548
- * Get full contact details by ID
549
- */
550
- getContact(id) {
551
- if (!this.initialized) {
552
- this.initialize();
553
- }
554
- return this.contactCache.get(id) || null;
555
- }
556
- /**
557
- * Search contacts by name
558
- */
559
- searchContacts(query) {
560
- if (!this.initialized) {
561
- this.initialize();
562
- }
563
- const lowerQuery = query.toLowerCase();
564
- return Array.from(this.contactCache.values()).filter(
565
- (c) => c.displayName.toLowerCase().includes(lowerQuery) || c.phoneNumbers.some((p) => p.includes(query)) || c.emails.some((e) => e.toLowerCase().includes(lowerQuery))
566
- );
567
697
  }
568
- /**
569
- * Build display name from contact fields
570
- */
571
- buildDisplayName(row) {
572
- if (row.nickname) return row.nickname;
573
- const parts = [];
574
- if (row.firstName) parts.push(row.firstName);
575
- if (row.lastName) parts.push(row.lastName);
576
- if (parts.length > 0) {
577
- return parts.join(" ");
698
+ const parser = new TypedStreamParser(blob);
699
+ const candidates = /* @__PURE__ */ new Map();
700
+ const deadline = Date.now() + PARSE_TIMEOUT_MS;
701
+ const remember = (values, boost = 0) => {
702
+ for (const value of values) {
703
+ const normalized = normalizeCandidate(value);
704
+ if (!normalized) continue;
705
+ const score = scoreCandidate(normalized) + boost;
706
+ const prev = candidates.get(normalized) ?? -Infinity;
707
+ if (score > prev) candidates.set(normalized, score);
578
708
  }
579
- if (row.organization) return row.organization;
580
- return "Unknown Contact";
581
- }
582
- /**
583
- * Close all database connections
584
- */
585
- close() {
586
- for (const db of this.databases) {
587
- try {
588
- db.close();
589
- } catch {
590
- }
709
+ };
710
+ try {
711
+ if (Date.now() < deadline) {
712
+ remember(
713
+ parser.parseAllNSStrings().map((entry) => entry.content),
714
+ STRUCTURED_BOOST
715
+ );
591
716
  }
592
- this.databases = [];
717
+ } catch {
593
718
  }
594
- /**
595
- * Paginated list of all loaded contacts, sorted by displayName.
596
- * Used by the MCP `list_contacts` tool.
597
- */
598
- listContacts(offset = 0, limit = 20) {
599
- if (!this.initialized) {
600
- this.initialize();
719
+ try {
720
+ if (Date.now() < deadline) {
721
+ remember(parser.extractReadableText());
601
722
  }
602
- const all = Array.from(this.contactCache.values()).sort(
603
- (a, b) => a.displayName.localeCompare(b.displayName)
604
- );
605
- const slice = limit === 0 ? all.slice(offset) : all.slice(offset, offset + limit);
606
- return { contacts: slice, total: all.length };
723
+ } catch {
607
724
  }
608
- /**
609
- * Get statistics about loaded contacts
610
- */
611
- getStats() {
612
- if (!this.initialized) {
613
- this.initialize();
614
- }
615
- return {
616
- totalContacts: this.contactCache.size,
617
- phoneNumbers: this.phoneMap.size,
618
- emails: this.emailMap.size
619
- };
725
+ if (Date.now() < deadline) {
726
+ remember(blob.toString("utf8").split(CONTROL_SPLIT_RE));
620
727
  }
728
+ const best = [...candidates.entries()].sort((a, b) => b[1] - a[1])[0];
729
+ return best?.[0];
621
730
  }
622
731
  function normalizeSnippetText(text) {
623
732
  if (!text) return null;
@@ -723,20 +832,42 @@ class SlugStore {
723
832
  this.db.pragma("journal_mode = WAL");
724
833
  this.migrate();
725
834
  }
835
+ /**
836
+ * Schema. v2 splits identity-level slug rows from a many-to-one guid map so a
837
+ * single canonical slug can cover every chat leg of one contact (phone + email,
838
+ * SMS + iMessage). v1 keyed a slug to exactly one guid, so a merged identity
839
+ * produced multiple slugs whose surfaced one flipped with recency.
840
+ */
726
841
  migrate() {
842
+ const version = this.db.pragma("user_version", { simple: true }) ?? 0;
843
+ if (version < 2) {
844
+ this.db.exec("DROP TABLE IF EXISTS thread_slugs");
845
+ this.db.exec("DROP TABLE IF EXISTS slug_chat_guids");
846
+ }
727
847
  this.db.exec(`
728
848
  CREATE TABLE IF NOT EXISTS thread_slugs (
729
849
  slug TEXT PRIMARY KEY,
730
- chat_guid TEXT UNIQUE NOT NULL,
731
850
  chat_identifier TEXT NOT NULL,
732
851
  display_name TEXT,
733
852
  service TEXT NOT NULL DEFAULT 'iMessage',
734
853
  is_group INTEGER NOT NULL DEFAULT 0,
735
854
  participants TEXT NOT NULL DEFAULT '',
736
855
  updated_at INTEGER NOT NULL DEFAULT 0
737
- )
856
+ );
857
+ CREATE TABLE IF NOT EXISTS slug_chat_guids (
858
+ chat_guid TEXT PRIMARY KEY,
859
+ slug TEXT NOT NULL
860
+ );
861
+ CREATE INDEX IF NOT EXISTS idx_slug_chat_guids_slug ON slug_chat_guids(slug);
738
862
  `);
863
+ this.db.pragma("user_version = 2");
739
864
  }
865
+ /**
866
+ * Upsert one chat leg under its identity slug. The identity row is keyed by
867
+ * slug (idempotent across legs); the leg's guid is mapped to that slug. When
868
+ * an identity already has a phone `chat_identifier`, a later email leg does
869
+ * NOT overwrite it — phone is preferred as the canonical handle for stability.
870
+ */
740
871
  upsert(record) {
741
872
  const params = {
742
873
  slug: record.slug,
@@ -748,33 +879,28 @@ class SlugStore {
748
879
  participants: record.participants,
749
880
  updatedAt: record.updatedAt
750
881
  };
751
- const updateByGuid = this.db.prepare(`
752
- UPDATE thread_slugs
753
- SET
754
- slug = @slug,
755
- chat_identifier = @chatIdentifier,
756
- display_name = @displayName,
757
- service = @service,
758
- is_group = @isGroup,
759
- participants = @participants,
760
- updated_at = @updatedAt
761
- WHERE chat_guid = @chatGuid
762
- `);
763
- const updateResult = updateByGuid.run(params);
764
- if (updateResult.changes > 0) return;
765
- const insertOrUpdateBySlug = this.db.prepare(`
766
- INSERT INTO thread_slugs (slug, chat_guid, chat_identifier, display_name, service, is_group, participants, updated_at)
767
- VALUES (@slug, @chatGuid, @chatIdentifier, @displayName, @service, @isGroup, @participants, @updatedAt)
768
- ON CONFLICT(slug) DO UPDATE SET
769
- chat_guid = excluded.chat_guid,
770
- chat_identifier = excluded.chat_identifier,
771
- display_name = excluded.display_name,
772
- service = excluded.service,
773
- is_group = excluded.is_group,
774
- participants = excluded.participants,
775
- updated_at = excluded.updated_at
776
- `);
777
- insertOrUpdateBySlug.run(params);
882
+ const tx = this.db.transaction(() => {
883
+ this.db.prepare(`
884
+ INSERT INTO thread_slugs (slug, chat_identifier, display_name, service, is_group, participants, updated_at)
885
+ VALUES (@slug, @chatIdentifier, @displayName, @service, @isGroup, @participants, @updatedAt)
886
+ ON CONFLICT(slug) DO UPDATE SET
887
+ chat_identifier = CASE
888
+ WHEN instr(thread_slugs.chat_identifier, '@') = 0 AND instr(excluded.chat_identifier, '@') > 0
889
+ THEN thread_slugs.chat_identifier
890
+ ELSE excluded.chat_identifier
891
+ END,
892
+ display_name = excluded.display_name,
893
+ service = excluded.service,
894
+ is_group = excluded.is_group,
895
+ participants = excluded.participants,
896
+ updated_at = excluded.updated_at
897
+ `).run(params);
898
+ this.db.prepare(`
899
+ INSERT INTO slug_chat_guids (chat_guid, slug) VALUES (@chatGuid, @slug)
900
+ ON CONFLICT(chat_guid) DO UPDATE SET slug = excluded.slug
901
+ `).run(params);
902
+ });
903
+ tx();
778
904
  }
779
905
  upsertMany(records) {
780
906
  const tx = this.db.transaction((recs) => {
@@ -784,39 +910,56 @@ class SlugStore {
784
910
  }
785
911
  lookupBySlug(slug) {
786
912
  const row = this.db.prepare("SELECT * FROM thread_slugs WHERE slug = ?").get(slug);
787
- return row ? this.rowToRecord(row) : null;
913
+ return row ? this.rowToRecord(row, this.representativeGuid(slug)) : null;
788
914
  }
789
915
  lookupByGuid(chatGuid) {
790
- const row = this.db.prepare("SELECT * FROM thread_slugs WHERE chat_guid = ?").get(chatGuid);
791
- return row ? this.rowToRecord(row) : null;
916
+ const link = this.db.prepare("SELECT slug FROM slug_chat_guids WHERE chat_guid = ?").get(chatGuid);
917
+ if (!link) return null;
918
+ const row = this.db.prepare("SELECT * FROM thread_slugs WHERE slug = ?").get(link.slug);
919
+ return row ? this.rowToRecord(row, chatGuid) : null;
792
920
  }
793
921
  lookupByChatIdentifier(chatIdentifier) {
794
922
  const row = this.db.prepare("SELECT * FROM thread_slugs WHERE chat_identifier = ?").get(chatIdentifier);
795
- return row ? this.rowToRecord(row) : null;
923
+ return row ? this.rowToRecord(row, this.representativeGuid(row.slug)) : null;
796
924
  }
797
925
  all() {
798
926
  const rows = this.db.prepare("SELECT * FROM thread_slugs ORDER BY updated_at DESC").all();
799
- return rows.map((r) => this.rowToRecord(r));
927
+ return rows.map((r) => this.rowToRecord(r, this.representativeGuid(r.slug)));
928
+ }
929
+ /** Every guid→slug link (used to rebuild the in-memory guid map on startup). */
930
+ guidLinks() {
931
+ const rows = this.db.prepare("SELECT chat_guid, slug FROM slug_chat_guids").all();
932
+ return rows.map((r) => ({ chatGuid: r.chat_guid, slug: r.slug }));
800
933
  }
801
- /** Remove slugs whose chat_guid is not in the given set of valid guids. */
934
+ /** A stable representative guid for a slug (lowest guid, or "" if none). */
935
+ representativeGuid(slug) {
936
+ const row = this.db.prepare("SELECT MIN(chat_guid) as guid FROM slug_chat_guids WHERE slug = ?").get(slug);
937
+ return row?.guid ?? "";
938
+ }
939
+ /**
940
+ * Drop guid links whose chat_guid is not in the valid set, then any identity
941
+ * slug left with no guids. Returns the number of guid links removed.
942
+ */
802
943
  prune(validGuids) {
803
- const all = this.db.prepare("SELECT slug, chat_guid FROM thread_slugs").all();
804
- const toDelete = all.filter((r) => !validGuids.has(r.chat_guid));
805
- if (toDelete.length === 0) return 0;
806
- const del = this.db.prepare("DELETE FROM thread_slugs WHERE slug = ?");
807
- const tx = this.db.transaction((slugs) => {
808
- for (const s of slugs) del.run(s);
944
+ const links = this.db.prepare("SELECT chat_guid FROM slug_chat_guids").all();
945
+ const toDelete = links.filter((r) => !validGuids.has(r.chat_guid)).map((r) => r.chat_guid);
946
+ const tx = this.db.transaction(() => {
947
+ const delLink = this.db.prepare("DELETE FROM slug_chat_guids WHERE chat_guid = ?");
948
+ for (const guid of toDelete) delLink.run(guid);
949
+ this.db.exec(
950
+ "DELETE FROM thread_slugs WHERE slug NOT IN (SELECT DISTINCT slug FROM slug_chat_guids)"
951
+ );
809
952
  });
810
- tx(toDelete.map((r) => r.slug));
953
+ tx();
811
954
  return toDelete.length;
812
955
  }
813
956
  close() {
814
957
  this.db.close();
815
958
  }
816
- rowToRecord(row) {
959
+ rowToRecord(row, chatGuid) {
817
960
  return {
818
961
  slug: row.slug,
819
- chatGuid: row.chat_guid,
962
+ chatGuid,
820
963
  chatIdentifier: row.chat_identifier,
821
964
  displayName: row.display_name,
822
965
  service: row.service,
@@ -880,6 +1023,9 @@ function getRichContentType(balloonBundleId) {
880
1023
  return "unknown";
881
1024
  }
882
1025
  const macTimestampToDate = macTimestampToDate$1;
1026
+ function dateToMacTimestamp(date) {
1027
+ return Math.floor((date.getTime() / 1e3 - MAC_EPOCH_OFFSET) * NANOS_PER_SECOND);
1028
+ }
883
1029
  class IMessageDB {
884
1030
  raw;
885
1031
  dbPath;
@@ -890,6 +1036,8 @@ class IMessageDB {
890
1036
  slugMap = /* @__PURE__ */ new Map();
891
1037
  /** In-memory chat guid -> slug for stable per-chat slug lookups. */
892
1038
  guidToSlug = /* @__PURE__ */ new Map();
1039
+ /** Per-sync canonical service per identity key (prefer iMessage) for slugs. */
1040
+ identityServiceMap = null;
893
1041
  // ── TTL caches ───────────────────────────────────────────────────────
894
1042
  static CACHE_TTL_MS = 3e4;
895
1043
  cachedAllChats = null;
@@ -898,8 +1046,22 @@ class IMessageDB {
898
1046
  cachedParticipants = /* @__PURE__ */ new Map();
899
1047
  cachedMergeKeys = /* @__PURE__ */ new Map();
900
1048
  cachedSnippets = /* @__PURE__ */ new Map();
1049
+ /**
1050
+ * Per-chat reaction map (guid → reactions), TTL-cached. A large export pages
1051
+ * the same merged chats dozens of times; without this, every page re-scanned
1052
+ * each chat's full reaction set — O(pages × chats) — which dominated big
1053
+ * exports and spiked event-loop lag.
1054
+ *
1055
+ * Invalidation contract: entries expire via their per-entry TTL on read, and
1056
+ * the whole map is cleared only in scheduleBackgroundRefresh() — every read
1057
+ * path that can observe new messages schedules that refresh, so there is no
1058
+ * other clear site. Add one if you introduce a read path that bypasses it.
1059
+ */
1060
+ cachedReactionsByChat = /* @__PURE__ */ new Map();
901
1061
  backgroundSyncNeeded = true;
902
1062
  backgroundRefreshScheduled = false;
1063
+ /** Set on close() so background chunked work stops touching a closed DB. */
1064
+ closed = false;
903
1065
  constructor(dbPath, contactsDbPaths, slugStorePath) {
904
1066
  const span = perf("IMessageDB.constructor");
905
1067
  this.dbPath = dbPath || join(homedir(), "Library", "Messages", "chat.db");
@@ -923,9 +1085,11 @@ class IMessageDB {
923
1085
  */
924
1086
  loadCachedSlugs() {
925
1087
  const span = perf("loadCachedSlugs");
1088
+ for (const link of this.slugStore.guidLinks()) {
1089
+ this.guidToSlug.set(link.chatGuid, link.slug);
1090
+ }
926
1091
  const records = this.slugStore.all();
927
1092
  for (const r of records) {
928
- this.guidToSlug.set(r.chatGuid, r.slug);
929
1093
  this.slugMap.set(r.slug, {
930
1094
  ROWID: 0,
931
1095
  // placeholder -- will be filled on full sync
@@ -938,24 +1102,53 @@ class IMessageDB {
938
1102
  }
939
1103
  span.end({ loaded: records.length });
940
1104
  }
941
- /** Generate and cache a slug for a single chat. */
942
- syncSlugForChat(chat) {
1105
+ /**
1106
+ * Compute the canonical slug for a chat without persisting anything.
1107
+ *
1108
+ * Identity key: every chat leg of one contact (phone + email, SMS +
1109
+ * iMessage) shares this, so they collapse to ONE stable slug. Groups stay
1110
+ * per-guid. Mirrors getConversationMergeKey so the slug tracks the merge.
1111
+ * The service segment is identity-wide (prefer iMessage) — otherwise the
1112
+ * SMS leg and iMessage leg of one contact would produce `…~sms~h` and
1113
+ * `…~imsg~h`, i.e. two slugs.
1114
+ */
1115
+ computeSlugForChat(chat) {
943
1116
  const isGroup = isGroupGuid(chat.guid) || isGroupChatIdentifier(chat.chat_identifier);
944
- const resolvedName = !isGroup && chat.chat_identifier ? this.contacts.lookupHandle(chat.chat_identifier) : null;
1117
+ const mergeKey = isGroup ? `group:${chat.guid}` : this.getConversationMergeKey(chat.chat_identifier, chat.guid, false);
1118
+ const canonicalService = isGroup ? this.detectServiceForChat(chat) : this.identityServiceMap?.get(mergeKey) ?? this.detectServiceForChat(chat);
1119
+ let identityKey = mergeKey;
1120
+ let resolvedName = null;
1121
+ if (!isGroup && chat.chat_identifier) {
1122
+ const lookup = this.contacts.lookupContact(chat.chat_identifier);
1123
+ if (lookup) {
1124
+ const anchor = this.contacts.stableAnchor(lookup.contactId);
1125
+ if (anchor) identityKey = `contact:${anchor}`;
1126
+ resolvedName = this.contacts.getContact(lookup.contactId)?.displayName ?? lookup.displayName;
1127
+ } else {
1128
+ const fallback = this.contacts.lookupHandle(chat.chat_identifier);
1129
+ resolvedName = fallback !== chat.chat_identifier ? fallback : null;
1130
+ }
1131
+ }
945
1132
  const slug = generateThreadSlug({
946
1133
  chatIdentifier: chat.chat_identifier,
947
1134
  guid: chat.guid,
948
1135
  displayName: chat.display_name,
949
- serviceName: chat.service_name ?? null,
950
- resolvedContactName: resolvedName !== chat.chat_identifier ? resolvedName : null
1136
+ serviceName: canonicalService,
1137
+ resolvedContactName: resolvedName,
1138
+ identityKey
951
1139
  });
1140
+ return { slug, isGroup, resolvedName, canonicalService };
1141
+ }
1142
+ /** Generate and cache a slug for a single chat. */
1143
+ syncSlugForChat(chat) {
1144
+ const { slug, isGroup, resolvedName, canonicalService } = this.computeSlugForChat(chat);
952
1145
  const participants = isGroup ? this.fetchChatParticipants(chat.ROWID) : [chat.chat_identifier];
953
1146
  this.slugStore.upsert({
954
1147
  slug,
955
1148
  chatGuid: chat.guid,
956
1149
  chatIdentifier: chat.chat_identifier,
957
- displayName: resolvedName !== chat.chat_identifier ? resolvedName : chat.display_name || null,
958
- service: this.detectServiceForChat(chat),
1150
+ displayName: resolvedName ?? chat.display_name ?? null,
1151
+ service: canonicalService,
959
1152
  isGroup,
960
1153
  participants: participants.join(","),
961
1154
  updatedAt: Date.now()
@@ -973,21 +1166,30 @@ class IMessageDB {
973
1166
  this.backgroundSyncNeeded = false;
974
1167
  const span = perf("backgroundSlugSync");
975
1168
  const chats = this.getAllChatsWithLastDate();
1169
+ const identityService = /* @__PURE__ */ new Map();
1170
+ for (const c of chats) {
1171
+ if (isGroupGuid(c.guid) || isGroupChatIdentifier(c.chat_identifier)) continue;
1172
+ const key = this.getConversationMergeKey(c.chat_identifier, c.guid, false);
1173
+ const svc = this.detectServiceForChat(c);
1174
+ if (svc === "iMessage" || !identityService.has(key)) identityService.set(key, svc);
1175
+ }
1176
+ this.identityServiceMap = identityService;
976
1177
  const validGuids = /* @__PURE__ */ new Set();
977
1178
  let index = 0;
978
1179
  let synced = 0;
979
1180
  const CHUNK = 100;
980
1181
  const processChunk = () => {
1182
+ if (this.closed) return;
981
1183
  const end = Math.min(index + CHUNK, chats.length);
982
1184
  for (; index < end; index++) {
983
1185
  const chat = chats[index];
984
1186
  validGuids.add(chat.guid);
985
- if (!this.guidToSlug.has(chat.guid)) {
1187
+ const expected = this.computeSlugForChat(chat).slug;
1188
+ if (this.guidToSlug.get(chat.guid) !== expected) {
986
1189
  this.syncSlugForChat(chat);
987
1190
  synced++;
988
1191
  } else {
989
- const existingSlug = this.guidToSlug.get(chat.guid);
990
- this.slugMap.set(existingSlug, chat);
1192
+ this.slugMap.set(expected, chat);
991
1193
  }
992
1194
  }
993
1195
  if (index < chats.length) {
@@ -1102,13 +1304,17 @@ class IMessageDB {
1102
1304
  return [];
1103
1305
  }
1104
1306
  const perChatLimit = Math.max(limit * 2, 50);
1307
+ const beforeDate = beforeMessageId != null ? this.dateOfMessage(beforeMessageId) : void 0;
1308
+ const afterDate = afterMessageId != null ? this.dateOfMessage(afterMessageId) : void 0;
1105
1309
  const result = /* @__PURE__ */ new Map();
1106
1310
  for (const chat of chats) {
1107
1311
  const rows = this.fetchMessagesForChatRowId(
1108
1312
  chat.ROWID,
1109
1313
  perChatLimit,
1110
1314
  beforeMessageId,
1111
- afterMessageId
1315
+ afterMessageId,
1316
+ beforeDate,
1317
+ afterDate
1112
1318
  );
1113
1319
  const extBatch = this.fetchExtendedMessageDataBatch(rows.map((r) => r.ROWID));
1114
1320
  const reactionsByGuid = includeReactionDetails ? this.fetchReactionsForChat(chat.ROWID) : void 0;
@@ -1139,6 +1345,140 @@ class IMessageDB {
1139
1345
  span.end({ limit, chats: chats.length, returned: sorted.length });
1140
1346
  return sorted;
1141
1347
  }
1348
+ /**
1349
+ * Get one chronological export page for a conversation.
1350
+ *
1351
+ * This deliberately does not use getMessagesForChat's ROWID-only
1352
+ * beforeMessageId contract. Exports need to walk the whole conversation in
1353
+ * display order across every chat row that resolves to the same visible
1354
+ * thread, dedupe messages linked to multiple chats before applying LIMIT,
1355
+ * and avoid letting reaction/system rows consume page slots.
1356
+ */
1357
+ async getMessagesForChatExportPage(chatIdentifier, limit = 1e3, options = {}) {
1358
+ const span = perf("getMessagesForChatExportPage");
1359
+ const {
1360
+ includeReactions = false,
1361
+ includeReactionDetails = false,
1362
+ afterCursor,
1363
+ since,
1364
+ until
1365
+ } = options;
1366
+ const pageLimit = Math.max(1, limit);
1367
+ const chats = this.resolveChatsForConversation(chatIdentifier);
1368
+ if (chats.length === 0) {
1369
+ span.end({ limit: pageLimit, returned: 0 });
1370
+ return { messages: [], nextCursor: null, rawCount: 0 };
1371
+ }
1372
+ const chatIds = chats.map((chat) => chat.ROWID);
1373
+ const chatPlaceholders = chatIds.map(() => "?").join(",");
1374
+ const conditions = [
1375
+ `cmj.chat_id IN (${chatPlaceholders})`,
1376
+ "COALESCE(m.item_type, 0) = 0"
1377
+ ];
1378
+ const params = [...chatIds];
1379
+ if (!includeReactions) {
1380
+ conditions.push(`COALESCE(m.associated_message_type, 0) = ${AssociatedMessageType.NORMAL}`);
1381
+ }
1382
+ if (afterCursor) {
1383
+ conditions.push("(m.date > ? OR (m.date = ? AND m.ROWID > ?))");
1384
+ params.push(afterCursor.date, afterCursor.date, afterCursor.rowid);
1385
+ }
1386
+ if (since) {
1387
+ conditions.push("m.date >= ?");
1388
+ params.push(dateToMacTimestamp(since));
1389
+ }
1390
+ if (until) {
1391
+ conditions.push("m.date <= ?");
1392
+ params.push(dateToMacTimestamp(until));
1393
+ }
1394
+ params.push(pageLimit);
1395
+ const stmt = this.raw.prepare(`
1396
+ WITH target_messages AS (
1397
+ SELECT
1398
+ m.ROWID,
1399
+ m.date,
1400
+ MIN(c.chat_identifier) as chat_identifier
1401
+ FROM ${Tables.MESSAGE} m
1402
+ JOIN ${Tables.CHAT_MESSAGE_JOIN} cmj ON m.ROWID = cmj.message_id
1403
+ JOIN ${Tables.CHAT} c ON c.ROWID = cmj.chat_id
1404
+ WHERE ${conditions.join("\n AND ")}
1405
+ GROUP BY m.ROWID, m.date
1406
+ ORDER BY m.date ASC, m.ROWID ASC
1407
+ LIMIT ?
1408
+ )
1409
+ SELECT
1410
+ m.ROWID,
1411
+ m.guid,
1412
+ m.text,
1413
+ m.attributedBody,
1414
+ m.date,
1415
+ m.is_from_me,
1416
+ h.id as handle_id,
1417
+ h.service as handle_service,
1418
+ m.cache_has_attachments,
1419
+ tm.chat_identifier,
1420
+ m.is_read,
1421
+ m.date_read,
1422
+ m.is_delivered,
1423
+ m.date_delivered,
1424
+ m.associated_message_type,
1425
+ m.associated_message_guid,
1426
+ m.associated_message_emoji,
1427
+ m.thread_originator_guid,
1428
+ m.thread_originator_part,
1429
+ m.balloon_bundle_id,
1430
+ m.item_type,
1431
+ m.date_edited,
1432
+ m.date_retracted,
1433
+ m.message_summary_info,
1434
+ m.payload_data
1435
+ FROM target_messages tm
1436
+ JOIN ${Tables.MESSAGE} m ON m.ROWID = tm.ROWID
1437
+ LEFT JOIN ${Tables.HANDLE} h ON m.handle_id = h.ROWID
1438
+ ORDER BY tm.date ASC, tm.ROWID ASC
1439
+ `);
1440
+ const rows = stmt.all(...params);
1441
+ const reactionsByGuid = /* @__PURE__ */ new Map();
1442
+ if (includeReactionDetails) {
1443
+ for (const chat of chats) {
1444
+ for (const [guid, reactions] of this.fetchReactionsForChat(chat.ROWID)) {
1445
+ const existing = reactionsByGuid.get(guid);
1446
+ if (existing) existing.push(...reactions);
1447
+ else reactionsByGuid.set(guid, [...reactions]);
1448
+ }
1449
+ }
1450
+ }
1451
+ const messages = [];
1452
+ for (const row of rows) {
1453
+ const text = this.parseMessageText(row);
1454
+ if (isHiddenSystemItem(row.item_type)) continue;
1455
+ const converted = this.convertMessage(
1456
+ row,
1457
+ text,
1458
+ row.chat_identifier ?? chatIdentifier,
1459
+ row,
1460
+ false
1461
+ );
1462
+ if (!includeReactions && converted.isReaction) continue;
1463
+ if (includeReactionDetails && !converted.isReaction) {
1464
+ const reactions = reactionsByGuid.get(row.guid);
1465
+ if (reactions && reactions.length > 0) {
1466
+ converted.reactions = this.consolidateReactions(reactions);
1467
+ if (converted.reactions.length === 0) converted.reactions = void 0;
1468
+ }
1469
+ }
1470
+ messages.push(converted);
1471
+ }
1472
+ const lastRow = rows[rows.length - 1];
1473
+ const nextCursor = lastRow ? { date: lastRow.date, rowid: lastRow.ROWID } : null;
1474
+ span.end({
1475
+ limit: pageLimit,
1476
+ chats: chats.length,
1477
+ rows: rows.length,
1478
+ returned: messages.length
1479
+ });
1480
+ return { messages, nextCursor, rawCount: rows.length };
1481
+ }
1142
1482
  /**
1143
1483
  * Get unread messages across all conversations, sorted by date descending (newest first).
1144
1484
  * Excludes reactions for cleaner output.
@@ -1195,7 +1535,7 @@ class IMessageDB {
1195
1535
  */
1196
1536
  async getMessagesAfter(chatIdentifier, afterMessageId) {
1197
1537
  const messages = await this.getMessagesForChat(chatIdentifier, 1e3, { afterMessageId });
1198
- const filtered = messages.filter((m) => m.id > afterMessageId && !m.isFromMe);
1538
+ const filtered = messages.filter((m) => m.id !== afterMessageId && !m.isFromMe);
1199
1539
  filtered.sort((a, b) => a.date.getTime() - b.date.getTime());
1200
1540
  return filtered;
1201
1541
  }
@@ -1215,8 +1555,7 @@ class IMessageDB {
1215
1555
  last: lastByChat[chat.ROWID]
1216
1556
  }));
1217
1557
  sortEntries.sort((a, b) => b.lastDate - a.lastDate);
1218
- const candidates = sortEntries.slice(0, limit * 3);
1219
- const prepared = candidates.map(({ chat, isGroup, last }) => {
1558
+ const enrich = ({ chat, isGroup, last }) => {
1220
1559
  const lastDate = last ? macTimestampToDate(last.lastDate) : null;
1221
1560
  const rawIdentifier = chat.chat_identifier;
1222
1561
  let displayName = chat.display_name;
@@ -1246,8 +1585,17 @@ class IMessageDB {
1246
1585
  serviceType
1247
1586
  }
1248
1587
  };
1249
- });
1250
- const deduped = this.mergeDuplicateConversations(prepared);
1588
+ };
1589
+ const CHUNK = Math.max(limit, 200);
1590
+ const prepared = [];
1591
+ let cursor = 0;
1592
+ let deduped = this.mergeDuplicateConversations(prepared);
1593
+ while (deduped.length < limit && cursor < sortEntries.length) {
1594
+ const chunk = sortEntries.slice(cursor, cursor + CHUNK);
1595
+ cursor += chunk.length;
1596
+ for (const entry of chunk) prepared.push(enrich(entry));
1597
+ deduped = this.mergeDuplicateConversations(prepared);
1598
+ }
1251
1599
  const selected = deduped.slice(0, limit);
1252
1600
  const result = selected.map(({ conversation, last }) => ({
1253
1601
  ...conversation,
@@ -1255,7 +1603,7 @@ class IMessageDB {
1255
1603
  }));
1256
1604
  span.end({
1257
1605
  chats: chats.length,
1258
- candidates: candidates.length,
1606
+ enriched: cursor,
1259
1607
  deduped: deduped.length,
1260
1608
  returned: result.length
1261
1609
  });
@@ -1532,6 +1880,76 @@ class IMessageDB {
1532
1880
  (chat) => !(isGroupGuid(chat.guid) || isGroupChatIdentifier(chat.chat_identifier)) && this.getConversationMergeKey(chat.chat_identifier, chat.guid, false) === mergeKey
1533
1881
  ).map((chat) => toChatRow(chat));
1534
1882
  }
1883
+ /**
1884
+ * Completeness diagnostic for exports. Finds non-group chats that belong to
1885
+ * the same identity as the exported conversation but that the merge-key logic
1886
+ * did NOT fold in — e.g. a contact whose phone and email landed on separate
1887
+ * Address Book cards, or chats Apple linked via `person_centric_id`.
1888
+ *
1889
+ * Two independent signals, unioned and deduped by chat ROWID:
1890
+ * 1. **contactId invariant** (primary): any non-group chat whose handle
1891
+ * resolves (via the loaded Address Books, incl. iCloud sources) to the
1892
+ * same contactId as the merged set but wasn't merged. Catches merge
1893
+ * regressions and normalization gaps.
1894
+ * 2. **person_centric_id** (fallback): Apple's own cross-handle link. Still
1895
+ * fires when the contact isn't in any Address Book — but note this column
1896
+ * is NULL on many real chat.dbs, so signal (1) is the dependable one.
1897
+ *
1898
+ * Inherent limit: a contact in NO Address Book *and* with a NULL
1899
+ * person_centric_id cannot be linked from data alone — that case is guarded
1900
+ * by the cross-source merge tests, not this diagnostic. Read-only.
1901
+ */
1902
+ findUnmergedSiblingChats(chatIdentifier) {
1903
+ const merged = this.resolveChatsForConversation(chatIdentifier);
1904
+ if (merged.length === 0) return [];
1905
+ const mergedIds = new Set(merged.map((c) => c.ROWID));
1906
+ const siblings = /* @__PURE__ */ new Map();
1907
+ const isGroupChat = (guid, ident) => isGroupGuid(guid) || isGroupChatIdentifier(ident ?? "");
1908
+ const mergedContactIds = /* @__PURE__ */ new Set();
1909
+ for (const chat of merged) {
1910
+ const contact = this.contacts.lookupContact(chat.chat_identifier);
1911
+ if (contact) mergedContactIds.add(contact.contactId);
1912
+ }
1913
+ if (mergedContactIds.size > 0) {
1914
+ for (const chat of this.getAllChatsWithLastDate()) {
1915
+ if (mergedIds.has(chat.ROWID)) continue;
1916
+ if (isGroupChat(chat.guid, chat.chat_identifier)) continue;
1917
+ const contact = this.contacts.lookupContact(chat.chat_identifier);
1918
+ if (contact && mergedContactIds.has(contact.contactId)) {
1919
+ siblings.set(chat.ROWID, {
1920
+ chatIdentifier: chat.chat_identifier,
1921
+ reason: "resolves to the same contact but was not merged"
1922
+ });
1923
+ }
1924
+ }
1925
+ }
1926
+ const mergedIdList = [...mergedIds];
1927
+ const placeholders = mergedIdList.map(() => "?").join(",");
1928
+ const rows = this.raw.prepare(`
1929
+ SELECT DISTINCT c.ROWID as rowid, c.guid, c.chat_identifier
1930
+ FROM ${Tables.CHAT} c
1931
+ JOIN ${Tables.CHAT_HANDLE_JOIN} chj ON chj.chat_id = c.ROWID
1932
+ JOIN ${Tables.HANDLE} h ON h.ROWID = chj.handle_id
1933
+ WHERE c.ROWID NOT IN (${placeholders})
1934
+ AND h.person_centric_id IN (
1935
+ SELECT DISTINCT h2.person_centric_id
1936
+ FROM ${Tables.CHAT_HANDLE_JOIN} chj2
1937
+ JOIN ${Tables.HANDLE} h2 ON h2.ROWID = chj2.handle_id
1938
+ WHERE chj2.chat_id IN (${placeholders})
1939
+ AND h2.person_centric_id IS NOT NULL
1940
+ AND h2.person_centric_id != ''
1941
+ )
1942
+ `).all(...mergedIdList, ...mergedIdList);
1943
+ for (const row of rows) {
1944
+ if (isGroupChat(row.guid, row.chat_identifier)) continue;
1945
+ if (siblings.has(row.rowid)) continue;
1946
+ siblings.set(row.rowid, {
1947
+ chatIdentifier: row.chat_identifier ?? row.guid,
1948
+ reason: "shares this contact's identity (person_centric_id) but was not merged"
1949
+ });
1950
+ }
1951
+ return [...siblings.values()];
1952
+ }
1535
1953
  /**
1536
1954
  * Get all chats with their last message date in one join query.
1537
1955
  * Used to resolve the correct chat for a contact when multiple chats exist (e.g. same number).
@@ -1642,53 +2060,45 @@ class IMessageDB {
1642
2060
  ).all();
1643
2061
  return rows;
1644
2062
  }
2063
+ /** Resolve a message ROWID to its date (for composite pagination cursors). */
2064
+ dateOfMessage(rowid) {
2065
+ const row = this.raw.prepare(`SELECT date FROM ${Tables.MESSAGE} WHERE ROWID = ?`).get(rowid);
2066
+ return row?.date;
2067
+ }
1645
2068
  /**
1646
2069
  * Fetch message rows for a chat ROWID, ordered by date DESC.
1647
- * Replaces the upstream IMessageDatabase.getMessagesFromChat().
2070
+ *
2071
+ * Pagination boundaries are composite `(date, ROWID)` cursors, NOT bare
2072
+ * ROWID comparisons: results are ordered by date, and in restored/merged
2073
+ * threads ROWID order diverges from date order — a ROWID-only bound both
2074
+ * skips older-date/higher-ROWID messages and re-shows newer-date/lower-ROWID
2075
+ * ones (paginating a real thread that way reached only ~47% of it; the same
2076
+ * defect made wait_for_reply able to miss replies entirely). ROWID-only is
2077
+ * kept solely as a fallback when the boundary message's date is unknown
2078
+ * (e.g. the boundary row was deleted).
1648
2079
  */
1649
- fetchMessagesForChatRowId(chatRowId, limit, beforeMessageId, afterMessageId) {
1650
- if (beforeMessageId != null && afterMessageId != null) {
1651
- const stmt2 = this.raw.prepare(`
1652
- SELECT
1653
- m.ROWID, m.guid, m.text, m.attributedBody, m.date,
1654
- m.is_from_me, h.id as handle_id, m.cache_has_attachments
1655
- FROM ${Tables.MESSAGE} m
1656
- LEFT JOIN ${Tables.HANDLE} h ON m.handle_id = h.ROWID
1657
- LEFT JOIN ${Tables.CHAT_MESSAGE_JOIN} cmj ON m.ROWID = cmj.message_id
1658
- WHERE cmj.chat_id = ? AND m.ROWID > ? AND m.ROWID < ?
1659
- ORDER BY m.date DESC, m.ROWID DESC
1660
- LIMIT ?
1661
- `);
1662
- return stmt2.all(chatRowId, afterMessageId, beforeMessageId, limit);
1663
- }
2080
+ fetchMessagesForChatRowId(chatRowId, limit, beforeMessageId, afterMessageId, beforeDate, afterDate) {
2081
+ const conditions = ["cmj.chat_id = ?"];
2082
+ const params = [chatRowId];
1664
2083
  if (beforeMessageId != null) {
1665
- const stmt2 = this.raw.prepare(`
1666
- SELECT
1667
- m.ROWID, m.guid, m.text, m.attributedBody, m.date,
1668
- m.is_from_me, h.id as handle_id, m.cache_has_attachments
1669
- FROM ${Tables.MESSAGE} m
1670
- LEFT JOIN ${Tables.HANDLE} h ON m.handle_id = h.ROWID
1671
- LEFT JOIN ${Tables.CHAT_MESSAGE_JOIN} cmj ON m.ROWID = cmj.message_id
1672
- WHERE cmj.chat_id = ? AND m.ROWID < ?
1673
- ORDER BY m.date DESC, m.ROWID DESC
1674
- LIMIT ?
1675
- `);
1676
- return stmt2.all(chatRowId, beforeMessageId, limit);
2084
+ if (beforeDate != null) {
2085
+ conditions.push("(m.date < ? OR (m.date = ? AND m.ROWID < ?))");
2086
+ params.push(beforeDate, beforeDate, beforeMessageId);
2087
+ } else {
2088
+ conditions.push("m.ROWID < ?");
2089
+ params.push(beforeMessageId);
2090
+ }
1677
2091
  }
1678
2092
  if (afterMessageId != null) {
1679
- const stmt2 = this.raw.prepare(`
1680
- SELECT
1681
- m.ROWID, m.guid, m.text, m.attributedBody, m.date,
1682
- m.is_from_me, h.id as handle_id, m.cache_has_attachments
1683
- FROM ${Tables.MESSAGE} m
1684
- LEFT JOIN ${Tables.HANDLE} h ON m.handle_id = h.ROWID
1685
- LEFT JOIN ${Tables.CHAT_MESSAGE_JOIN} cmj ON m.ROWID = cmj.message_id
1686
- WHERE cmj.chat_id = ? AND m.ROWID > ?
1687
- ORDER BY m.date DESC, m.ROWID DESC
1688
- LIMIT ?
1689
- `);
1690
- return stmt2.all(chatRowId, afterMessageId, limit);
2093
+ if (afterDate != null) {
2094
+ conditions.push("(m.date > ? OR (m.date = ? AND m.ROWID > ?))");
2095
+ params.push(afterDate, afterDate, afterMessageId);
2096
+ } else {
2097
+ conditions.push("m.ROWID > ?");
2098
+ params.push(afterMessageId);
2099
+ }
1691
2100
  }
2101
+ params.push(limit);
1692
2102
  const stmt = this.raw.prepare(`
1693
2103
  SELECT
1694
2104
  m.ROWID, m.guid, m.text, m.attributedBody, m.date,
@@ -1696,11 +2106,11 @@ class IMessageDB {
1696
2106
  FROM ${Tables.MESSAGE} m
1697
2107
  LEFT JOIN ${Tables.HANDLE} h ON m.handle_id = h.ROWID
1698
2108
  LEFT JOIN ${Tables.CHAT_MESSAGE_JOIN} cmj ON m.ROWID = cmj.message_id
1699
- WHERE cmj.chat_id = ?
2109
+ WHERE ${conditions.join(" AND ")}
1700
2110
  ORDER BY m.date DESC, m.ROWID DESC
1701
2111
  LIMIT ?
1702
2112
  `);
1703
- return stmt.all(chatRowId, limit);
2113
+ return stmt.all(...params);
1704
2114
  }
1705
2115
  /**
1706
2116
  * Extract readable text from a message row.
@@ -1872,6 +2282,7 @@ class IMessageDB {
1872
2282
  this.cachedAllChats = null;
1873
2283
  this.cachedLastByChat = null;
1874
2284
  this.cachedUnreadByChat = null;
2285
+ this.cachedReactionsByChat.clear();
1875
2286
  this.getAllChatsWithLastDate();
1876
2287
  this.scheduleBackgroundSlugSync();
1877
2288
  } catch {
@@ -1886,6 +2297,7 @@ class IMessageDB {
1886
2297
  * Close database connections
1887
2298
  */
1888
2299
  async close() {
2300
+ this.closed = true;
1889
2301
  this.raw.close();
1890
2302
  this.contacts.close();
1891
2303
  this.slugStore.close();
@@ -2126,6 +2538,10 @@ class IMessageDB {
2126
2538
  * One query replaces N individual LIKE queries (the main perf bottleneck).
2127
2539
  */
2128
2540
  fetchReactionsForChat(chatRowId) {
2541
+ const cached = this.cachedReactionsByChat.get(chatRowId);
2542
+ if (cached && Date.now() - cached.ts < IMessageDB.CACHE_TTL_MS) {
2543
+ return cached.data;
2544
+ }
2129
2545
  const stmt = this.raw.prepare(`
2130
2546
  SELECT
2131
2547
  m.associated_message_type,
@@ -2161,6 +2577,7 @@ class IMessageDB {
2161
2577
  if (existing) existing.push(reaction);
2162
2578
  else result.set(targetGuid, [reaction]);
2163
2579
  }
2580
+ this.cachedReactionsByChat.set(chatRowId, { data: result, ts: Date.now() });
2164
2581
  return result;
2165
2582
  }
2166
2583
  /**
@@ -2242,6 +2659,7 @@ const imessageDb = /* @__PURE__ */ Object.freeze(/* @__PURE__ */ Object.definePr
2242
2659
  export {
2243
2660
  IMessageDB as I,
2244
2661
  hasNativeModule as h,
2245
- imessageDb as i
2662
+ imessageDb as i,
2663
+ normalizedPhoneVariants as n
2246
2664
  };
2247
- //# sourceMappingURL=imessage-db-BYU5mxM-.js.map
2665
+ //# sourceMappingURL=imessage-db-vDZF7E8B.js.map