imsg-mcp 1.2.0 → 1.3.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -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() {
@@ -306,318 +673,60 @@ function normalizeCandidate(text) {
306
673
  }
307
674
  function scoreCandidate(text) {
308
675
  let score = 0;
309
- 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;
676
+ if (/[A-Za-z]/.test(text)) score += 50;
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)));
800
928
  }
801
- /** Remove slugs whose chat_guid is not in the given set of valid guids. */
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 }));
933
+ }
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,17 @@ 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
+ cachedReactionsByChat = /* @__PURE__ */ new Map();
901
1056
  backgroundSyncNeeded = true;
902
1057
  backgroundRefreshScheduled = false;
1058
+ /** Set on close() so background chunked work stops touching a closed DB. */
1059
+ closed = false;
903
1060
  constructor(dbPath, contactsDbPaths, slugStorePath) {
904
1061
  const span = perf("IMessageDB.constructor");
905
1062
  this.dbPath = dbPath || join(homedir(), "Library", "Messages", "chat.db");
@@ -923,9 +1080,11 @@ class IMessageDB {
923
1080
  */
924
1081
  loadCachedSlugs() {
925
1082
  const span = perf("loadCachedSlugs");
1083
+ for (const link of this.slugStore.guidLinks()) {
1084
+ this.guidToSlug.set(link.chatGuid, link.slug);
1085
+ }
926
1086
  const records = this.slugStore.all();
927
1087
  for (const r of records) {
928
- this.guidToSlug.set(r.chatGuid, r.slug);
929
1088
  this.slugMap.set(r.slug, {
930
1089
  ROWID: 0,
931
1090
  // placeholder -- will be filled on full sync
@@ -938,24 +1097,53 @@ class IMessageDB {
938
1097
  }
939
1098
  span.end({ loaded: records.length });
940
1099
  }
941
- /** Generate and cache a slug for a single chat. */
942
- syncSlugForChat(chat) {
1100
+ /**
1101
+ * Compute the canonical slug for a chat without persisting anything.
1102
+ *
1103
+ * Identity key: every chat leg of one contact (phone + email, SMS +
1104
+ * iMessage) shares this, so they collapse to ONE stable slug. Groups stay
1105
+ * per-guid. Mirrors getConversationMergeKey so the slug tracks the merge.
1106
+ * The service segment is identity-wide (prefer iMessage) — otherwise the
1107
+ * SMS leg and iMessage leg of one contact would produce `…~sms~h` and
1108
+ * `…~imsg~h`, i.e. two slugs.
1109
+ */
1110
+ computeSlugForChat(chat) {
943
1111
  const isGroup = isGroupGuid(chat.guid) || isGroupChatIdentifier(chat.chat_identifier);
944
- const resolvedName = !isGroup && chat.chat_identifier ? this.contacts.lookupHandle(chat.chat_identifier) : null;
1112
+ const mergeKey = isGroup ? `group:${chat.guid}` : this.getConversationMergeKey(chat.chat_identifier, chat.guid, false);
1113
+ const canonicalService = isGroup ? this.detectServiceForChat(chat) : this.identityServiceMap?.get(mergeKey) ?? this.detectServiceForChat(chat);
1114
+ let identityKey = mergeKey;
1115
+ let resolvedName = null;
1116
+ if (!isGroup && chat.chat_identifier) {
1117
+ const lookup = this.contacts.lookupContact(chat.chat_identifier);
1118
+ if (lookup) {
1119
+ const anchor = this.contacts.stableAnchor(lookup.contactId);
1120
+ if (anchor) identityKey = `contact:${anchor}`;
1121
+ resolvedName = this.contacts.getContact(lookup.contactId)?.displayName ?? lookup.displayName;
1122
+ } else {
1123
+ const fallback = this.contacts.lookupHandle(chat.chat_identifier);
1124
+ resolvedName = fallback !== chat.chat_identifier ? fallback : null;
1125
+ }
1126
+ }
945
1127
  const slug = generateThreadSlug({
946
1128
  chatIdentifier: chat.chat_identifier,
947
1129
  guid: chat.guid,
948
1130
  displayName: chat.display_name,
949
- serviceName: chat.service_name ?? null,
950
- resolvedContactName: resolvedName !== chat.chat_identifier ? resolvedName : null
1131
+ serviceName: canonicalService,
1132
+ resolvedContactName: resolvedName,
1133
+ identityKey
951
1134
  });
1135
+ return { slug, isGroup, resolvedName, canonicalService };
1136
+ }
1137
+ /** Generate and cache a slug for a single chat. */
1138
+ syncSlugForChat(chat) {
1139
+ const { slug, isGroup, resolvedName, canonicalService } = this.computeSlugForChat(chat);
952
1140
  const participants = isGroup ? this.fetchChatParticipants(chat.ROWID) : [chat.chat_identifier];
953
1141
  this.slugStore.upsert({
954
1142
  slug,
955
1143
  chatGuid: chat.guid,
956
1144
  chatIdentifier: chat.chat_identifier,
957
- displayName: resolvedName !== chat.chat_identifier ? resolvedName : chat.display_name || null,
958
- service: this.detectServiceForChat(chat),
1145
+ displayName: resolvedName ?? chat.display_name ?? null,
1146
+ service: canonicalService,
959
1147
  isGroup,
960
1148
  participants: participants.join(","),
961
1149
  updatedAt: Date.now()
@@ -973,21 +1161,30 @@ class IMessageDB {
973
1161
  this.backgroundSyncNeeded = false;
974
1162
  const span = perf("backgroundSlugSync");
975
1163
  const chats = this.getAllChatsWithLastDate();
1164
+ const identityService = /* @__PURE__ */ new Map();
1165
+ for (const c of chats) {
1166
+ if (isGroupGuid(c.guid) || isGroupChatIdentifier(c.chat_identifier)) continue;
1167
+ const key = this.getConversationMergeKey(c.chat_identifier, c.guid, false);
1168
+ const svc = this.detectServiceForChat(c);
1169
+ if (svc === "iMessage" || !identityService.has(key)) identityService.set(key, svc);
1170
+ }
1171
+ this.identityServiceMap = identityService;
976
1172
  const validGuids = /* @__PURE__ */ new Set();
977
1173
  let index = 0;
978
1174
  let synced = 0;
979
1175
  const CHUNK = 100;
980
1176
  const processChunk = () => {
1177
+ if (this.closed) return;
981
1178
  const end = Math.min(index + CHUNK, chats.length);
982
1179
  for (; index < end; index++) {
983
1180
  const chat = chats[index];
984
1181
  validGuids.add(chat.guid);
985
- if (!this.guidToSlug.has(chat.guid)) {
1182
+ const expected = this.computeSlugForChat(chat).slug;
1183
+ if (this.guidToSlug.get(chat.guid) !== expected) {
986
1184
  this.syncSlugForChat(chat);
987
1185
  synced++;
988
1186
  } else {
989
- const existingSlug = this.guidToSlug.get(chat.guid);
990
- this.slugMap.set(existingSlug, chat);
1187
+ this.slugMap.set(expected, chat);
991
1188
  }
992
1189
  }
993
1190
  if (index < chats.length) {
@@ -1102,13 +1299,17 @@ class IMessageDB {
1102
1299
  return [];
1103
1300
  }
1104
1301
  const perChatLimit = Math.max(limit * 2, 50);
1302
+ const beforeDate = beforeMessageId != null ? this.dateOfMessage(beforeMessageId) : void 0;
1303
+ const afterDate = afterMessageId != null ? this.dateOfMessage(afterMessageId) : void 0;
1105
1304
  const result = /* @__PURE__ */ new Map();
1106
1305
  for (const chat of chats) {
1107
1306
  const rows = this.fetchMessagesForChatRowId(
1108
1307
  chat.ROWID,
1109
1308
  perChatLimit,
1110
1309
  beforeMessageId,
1111
- afterMessageId
1310
+ afterMessageId,
1311
+ beforeDate,
1312
+ afterDate
1112
1313
  );
1113
1314
  const extBatch = this.fetchExtendedMessageDataBatch(rows.map((r) => r.ROWID));
1114
1315
  const reactionsByGuid = includeReactionDetails ? this.fetchReactionsForChat(chat.ROWID) : void 0;
@@ -1139,6 +1340,140 @@ class IMessageDB {
1139
1340
  span.end({ limit, chats: chats.length, returned: sorted.length });
1140
1341
  return sorted;
1141
1342
  }
1343
+ /**
1344
+ * Get one chronological export page for a conversation.
1345
+ *
1346
+ * This deliberately does not use getMessagesForChat's ROWID-only
1347
+ * beforeMessageId contract. Exports need to walk the whole conversation in
1348
+ * display order across every chat row that resolves to the same visible
1349
+ * thread, dedupe messages linked to multiple chats before applying LIMIT,
1350
+ * and avoid letting reaction/system rows consume page slots.
1351
+ */
1352
+ async getMessagesForChatExportPage(chatIdentifier, limit = 1e3, options = {}) {
1353
+ const span = perf("getMessagesForChatExportPage");
1354
+ const {
1355
+ includeReactions = false,
1356
+ includeReactionDetails = false,
1357
+ afterCursor,
1358
+ since,
1359
+ until
1360
+ } = options;
1361
+ const pageLimit = Math.max(1, limit);
1362
+ const chats = this.resolveChatsForConversation(chatIdentifier);
1363
+ if (chats.length === 0) {
1364
+ span.end({ limit: pageLimit, returned: 0 });
1365
+ return { messages: [], nextCursor: null, rawCount: 0 };
1366
+ }
1367
+ const chatIds = chats.map((chat) => chat.ROWID);
1368
+ const chatPlaceholders = chatIds.map(() => "?").join(",");
1369
+ const conditions = [
1370
+ `cmj.chat_id IN (${chatPlaceholders})`,
1371
+ "COALESCE(m.item_type, 0) = 0"
1372
+ ];
1373
+ const params = [...chatIds];
1374
+ if (!includeReactions) {
1375
+ conditions.push(`COALESCE(m.associated_message_type, 0) = ${AssociatedMessageType.NORMAL}`);
1376
+ }
1377
+ if (afterCursor) {
1378
+ conditions.push("(m.date > ? OR (m.date = ? AND m.ROWID > ?))");
1379
+ params.push(afterCursor.date, afterCursor.date, afterCursor.rowid);
1380
+ }
1381
+ if (since) {
1382
+ conditions.push("m.date >= ?");
1383
+ params.push(dateToMacTimestamp(since));
1384
+ }
1385
+ if (until) {
1386
+ conditions.push("m.date <= ?");
1387
+ params.push(dateToMacTimestamp(until));
1388
+ }
1389
+ params.push(pageLimit);
1390
+ const stmt = this.raw.prepare(`
1391
+ WITH target_messages AS (
1392
+ SELECT
1393
+ m.ROWID,
1394
+ m.date,
1395
+ MIN(c.chat_identifier) as chat_identifier
1396
+ FROM ${Tables.MESSAGE} m
1397
+ JOIN ${Tables.CHAT_MESSAGE_JOIN} cmj ON m.ROWID = cmj.message_id
1398
+ JOIN ${Tables.CHAT} c ON c.ROWID = cmj.chat_id
1399
+ WHERE ${conditions.join("\n AND ")}
1400
+ GROUP BY m.ROWID, m.date
1401
+ ORDER BY m.date ASC, m.ROWID ASC
1402
+ LIMIT ?
1403
+ )
1404
+ SELECT
1405
+ m.ROWID,
1406
+ m.guid,
1407
+ m.text,
1408
+ m.attributedBody,
1409
+ m.date,
1410
+ m.is_from_me,
1411
+ h.id as handle_id,
1412
+ h.service as handle_service,
1413
+ m.cache_has_attachments,
1414
+ tm.chat_identifier,
1415
+ m.is_read,
1416
+ m.date_read,
1417
+ m.is_delivered,
1418
+ m.date_delivered,
1419
+ m.associated_message_type,
1420
+ m.associated_message_guid,
1421
+ m.associated_message_emoji,
1422
+ m.thread_originator_guid,
1423
+ m.thread_originator_part,
1424
+ m.balloon_bundle_id,
1425
+ m.item_type,
1426
+ m.date_edited,
1427
+ m.date_retracted,
1428
+ m.message_summary_info,
1429
+ m.payload_data
1430
+ FROM target_messages tm
1431
+ JOIN ${Tables.MESSAGE} m ON m.ROWID = tm.ROWID
1432
+ LEFT JOIN ${Tables.HANDLE} h ON m.handle_id = h.ROWID
1433
+ ORDER BY tm.date ASC, tm.ROWID ASC
1434
+ `);
1435
+ const rows = stmt.all(...params);
1436
+ const reactionsByGuid = /* @__PURE__ */ new Map();
1437
+ if (includeReactionDetails) {
1438
+ for (const chat of chats) {
1439
+ for (const [guid, reactions] of this.fetchReactionsForChat(chat.ROWID)) {
1440
+ const existing = reactionsByGuid.get(guid);
1441
+ if (existing) existing.push(...reactions);
1442
+ else reactionsByGuid.set(guid, [...reactions]);
1443
+ }
1444
+ }
1445
+ }
1446
+ const messages = [];
1447
+ for (const row of rows) {
1448
+ const text = this.parseMessageText(row);
1449
+ if (isHiddenSystemItem(row.item_type)) continue;
1450
+ const converted = this.convertMessage(
1451
+ row,
1452
+ text,
1453
+ row.chat_identifier ?? chatIdentifier,
1454
+ row,
1455
+ false
1456
+ );
1457
+ if (!includeReactions && converted.isReaction) continue;
1458
+ if (includeReactionDetails && !converted.isReaction) {
1459
+ const reactions = reactionsByGuid.get(row.guid);
1460
+ if (reactions && reactions.length > 0) {
1461
+ converted.reactions = this.consolidateReactions(reactions);
1462
+ if (converted.reactions.length === 0) converted.reactions = void 0;
1463
+ }
1464
+ }
1465
+ messages.push(converted);
1466
+ }
1467
+ const lastRow = rows[rows.length - 1];
1468
+ const nextCursor = lastRow ? { date: lastRow.date, rowid: lastRow.ROWID } : null;
1469
+ span.end({
1470
+ limit: pageLimit,
1471
+ chats: chats.length,
1472
+ rows: rows.length,
1473
+ returned: messages.length
1474
+ });
1475
+ return { messages, nextCursor, rawCount: rows.length };
1476
+ }
1142
1477
  /**
1143
1478
  * Get unread messages across all conversations, sorted by date descending (newest first).
1144
1479
  * Excludes reactions for cleaner output.
@@ -1195,7 +1530,7 @@ class IMessageDB {
1195
1530
  */
1196
1531
  async getMessagesAfter(chatIdentifier, afterMessageId) {
1197
1532
  const messages = await this.getMessagesForChat(chatIdentifier, 1e3, { afterMessageId });
1198
- const filtered = messages.filter((m) => m.id > afterMessageId && !m.isFromMe);
1533
+ const filtered = messages.filter((m) => m.id !== afterMessageId && !m.isFromMe);
1199
1534
  filtered.sort((a, b) => a.date.getTime() - b.date.getTime());
1200
1535
  return filtered;
1201
1536
  }
@@ -1215,8 +1550,7 @@ class IMessageDB {
1215
1550
  last: lastByChat[chat.ROWID]
1216
1551
  }));
1217
1552
  sortEntries.sort((a, b) => b.lastDate - a.lastDate);
1218
- const candidates = sortEntries.slice(0, limit * 3);
1219
- const prepared = candidates.map(({ chat, isGroup, last }) => {
1553
+ const enrich = ({ chat, isGroup, last }) => {
1220
1554
  const lastDate = last ? macTimestampToDate(last.lastDate) : null;
1221
1555
  const rawIdentifier = chat.chat_identifier;
1222
1556
  let displayName = chat.display_name;
@@ -1246,8 +1580,17 @@ class IMessageDB {
1246
1580
  serviceType
1247
1581
  }
1248
1582
  };
1249
- });
1250
- const deduped = this.mergeDuplicateConversations(prepared);
1583
+ };
1584
+ const CHUNK = Math.max(limit, 200);
1585
+ const prepared = [];
1586
+ let cursor = 0;
1587
+ let deduped = this.mergeDuplicateConversations(prepared);
1588
+ while (deduped.length < limit && cursor < sortEntries.length) {
1589
+ const chunk = sortEntries.slice(cursor, cursor + CHUNK);
1590
+ cursor += chunk.length;
1591
+ for (const entry of chunk) prepared.push(enrich(entry));
1592
+ deduped = this.mergeDuplicateConversations(prepared);
1593
+ }
1251
1594
  const selected = deduped.slice(0, limit);
1252
1595
  const result = selected.map(({ conversation, last }) => ({
1253
1596
  ...conversation,
@@ -1255,7 +1598,7 @@ class IMessageDB {
1255
1598
  }));
1256
1599
  span.end({
1257
1600
  chats: chats.length,
1258
- candidates: candidates.length,
1601
+ enriched: cursor,
1259
1602
  deduped: deduped.length,
1260
1603
  returned: result.length
1261
1604
  });
@@ -1532,6 +1875,76 @@ class IMessageDB {
1532
1875
  (chat) => !(isGroupGuid(chat.guid) || isGroupChatIdentifier(chat.chat_identifier)) && this.getConversationMergeKey(chat.chat_identifier, chat.guid, false) === mergeKey
1533
1876
  ).map((chat) => toChatRow(chat));
1534
1877
  }
1878
+ /**
1879
+ * Completeness diagnostic for exports. Finds non-group chats that belong to
1880
+ * the same identity as the exported conversation but that the merge-key logic
1881
+ * did NOT fold in — e.g. a contact whose phone and email landed on separate
1882
+ * Address Book cards, or chats Apple linked via `person_centric_id`.
1883
+ *
1884
+ * Two independent signals, unioned and deduped by chat ROWID:
1885
+ * 1. **contactId invariant** (primary): any non-group chat whose handle
1886
+ * resolves (via the loaded Address Books, incl. iCloud sources) to the
1887
+ * same contactId as the merged set but wasn't merged. Catches merge
1888
+ * regressions and normalization gaps.
1889
+ * 2. **person_centric_id** (fallback): Apple's own cross-handle link. Still
1890
+ * fires when the contact isn't in any Address Book — but note this column
1891
+ * is NULL on many real chat.dbs, so signal (1) is the dependable one.
1892
+ *
1893
+ * Inherent limit: a contact in NO Address Book *and* with a NULL
1894
+ * person_centric_id cannot be linked from data alone — that case is guarded
1895
+ * by the cross-source merge tests, not this diagnostic. Read-only.
1896
+ */
1897
+ findUnmergedSiblingChats(chatIdentifier) {
1898
+ const merged = this.resolveChatsForConversation(chatIdentifier);
1899
+ if (merged.length === 0) return [];
1900
+ const mergedIds = new Set(merged.map((c) => c.ROWID));
1901
+ const siblings = /* @__PURE__ */ new Map();
1902
+ const isGroupChat = (guid, ident) => isGroupGuid(guid) || isGroupChatIdentifier(ident ?? "");
1903
+ const mergedContactIds = /* @__PURE__ */ new Set();
1904
+ for (const chat of merged) {
1905
+ const contact = this.contacts.lookupContact(chat.chat_identifier);
1906
+ if (contact) mergedContactIds.add(contact.contactId);
1907
+ }
1908
+ if (mergedContactIds.size > 0) {
1909
+ for (const chat of this.getAllChatsWithLastDate()) {
1910
+ if (mergedIds.has(chat.ROWID)) continue;
1911
+ if (isGroupChat(chat.guid, chat.chat_identifier)) continue;
1912
+ const contact = this.contacts.lookupContact(chat.chat_identifier);
1913
+ if (contact && mergedContactIds.has(contact.contactId)) {
1914
+ siblings.set(chat.ROWID, {
1915
+ chatIdentifier: chat.chat_identifier,
1916
+ reason: "resolves to the same contact but was not merged"
1917
+ });
1918
+ }
1919
+ }
1920
+ }
1921
+ const mergedIdList = [...mergedIds];
1922
+ const placeholders = mergedIdList.map(() => "?").join(",");
1923
+ const rows = this.raw.prepare(`
1924
+ SELECT DISTINCT c.ROWID as rowid, c.guid, c.chat_identifier
1925
+ FROM ${Tables.CHAT} c
1926
+ JOIN ${Tables.CHAT_HANDLE_JOIN} chj ON chj.chat_id = c.ROWID
1927
+ JOIN ${Tables.HANDLE} h ON h.ROWID = chj.handle_id
1928
+ WHERE c.ROWID NOT IN (${placeholders})
1929
+ AND h.person_centric_id IN (
1930
+ SELECT DISTINCT h2.person_centric_id
1931
+ FROM ${Tables.CHAT_HANDLE_JOIN} chj2
1932
+ JOIN ${Tables.HANDLE} h2 ON h2.ROWID = chj2.handle_id
1933
+ WHERE chj2.chat_id IN (${placeholders})
1934
+ AND h2.person_centric_id IS NOT NULL
1935
+ AND h2.person_centric_id != ''
1936
+ )
1937
+ `).all(...mergedIdList, ...mergedIdList);
1938
+ for (const row of rows) {
1939
+ if (isGroupChat(row.guid, row.chat_identifier)) continue;
1940
+ if (siblings.has(row.rowid)) continue;
1941
+ siblings.set(row.rowid, {
1942
+ chatIdentifier: row.chat_identifier ?? row.guid,
1943
+ reason: "shares this contact's identity (person_centric_id) but was not merged"
1944
+ });
1945
+ }
1946
+ return [...siblings.values()];
1947
+ }
1535
1948
  /**
1536
1949
  * Get all chats with their last message date in one join query.
1537
1950
  * Used to resolve the correct chat for a contact when multiple chats exist (e.g. same number).
@@ -1642,53 +2055,45 @@ class IMessageDB {
1642
2055
  ).all();
1643
2056
  return rows;
1644
2057
  }
2058
+ /** Resolve a message ROWID to its date (for composite pagination cursors). */
2059
+ dateOfMessage(rowid) {
2060
+ const row = this.raw.prepare(`SELECT date FROM ${Tables.MESSAGE} WHERE ROWID = ?`).get(rowid);
2061
+ return row?.date;
2062
+ }
1645
2063
  /**
1646
2064
  * Fetch message rows for a chat ROWID, ordered by date DESC.
1647
- * Replaces the upstream IMessageDatabase.getMessagesFromChat().
2065
+ *
2066
+ * Pagination boundaries are composite `(date, ROWID)` cursors, NOT bare
2067
+ * ROWID comparisons: results are ordered by date, and in restored/merged
2068
+ * threads ROWID order diverges from date order — a ROWID-only bound both
2069
+ * skips older-date/higher-ROWID messages and re-shows newer-date/lower-ROWID
2070
+ * ones (paginating a real thread that way reached only ~47% of it; the same
2071
+ * defect made wait_for_reply able to miss replies entirely). ROWID-only is
2072
+ * kept solely as a fallback when the boundary message's date is unknown
2073
+ * (e.g. the boundary row was deleted).
1648
2074
  */
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
- }
2075
+ fetchMessagesForChatRowId(chatRowId, limit, beforeMessageId, afterMessageId, beforeDate, afterDate) {
2076
+ const conditions = ["cmj.chat_id = ?"];
2077
+ const params = [chatRowId];
1664
2078
  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);
2079
+ if (beforeDate != null) {
2080
+ conditions.push("(m.date < ? OR (m.date = ? AND m.ROWID < ?))");
2081
+ params.push(beforeDate, beforeDate, beforeMessageId);
2082
+ } else {
2083
+ conditions.push("m.ROWID < ?");
2084
+ params.push(beforeMessageId);
2085
+ }
1677
2086
  }
1678
2087
  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);
2088
+ if (afterDate != null) {
2089
+ conditions.push("(m.date > ? OR (m.date = ? AND m.ROWID > ?))");
2090
+ params.push(afterDate, afterDate, afterMessageId);
2091
+ } else {
2092
+ conditions.push("m.ROWID > ?");
2093
+ params.push(afterMessageId);
2094
+ }
1691
2095
  }
2096
+ params.push(limit);
1692
2097
  const stmt = this.raw.prepare(`
1693
2098
  SELECT
1694
2099
  m.ROWID, m.guid, m.text, m.attributedBody, m.date,
@@ -1696,11 +2101,11 @@ class IMessageDB {
1696
2101
  FROM ${Tables.MESSAGE} m
1697
2102
  LEFT JOIN ${Tables.HANDLE} h ON m.handle_id = h.ROWID
1698
2103
  LEFT JOIN ${Tables.CHAT_MESSAGE_JOIN} cmj ON m.ROWID = cmj.message_id
1699
- WHERE cmj.chat_id = ?
2104
+ WHERE ${conditions.join(" AND ")}
1700
2105
  ORDER BY m.date DESC, m.ROWID DESC
1701
2106
  LIMIT ?
1702
2107
  `);
1703
- return stmt.all(chatRowId, limit);
2108
+ return stmt.all(...params);
1704
2109
  }
1705
2110
  /**
1706
2111
  * Extract readable text from a message row.
@@ -1872,6 +2277,7 @@ class IMessageDB {
1872
2277
  this.cachedAllChats = null;
1873
2278
  this.cachedLastByChat = null;
1874
2279
  this.cachedUnreadByChat = null;
2280
+ this.cachedReactionsByChat.clear();
1875
2281
  this.getAllChatsWithLastDate();
1876
2282
  this.scheduleBackgroundSlugSync();
1877
2283
  } catch {
@@ -1886,6 +2292,7 @@ class IMessageDB {
1886
2292
  * Close database connections
1887
2293
  */
1888
2294
  async close() {
2295
+ this.closed = true;
1889
2296
  this.raw.close();
1890
2297
  this.contacts.close();
1891
2298
  this.slugStore.close();
@@ -2126,6 +2533,10 @@ class IMessageDB {
2126
2533
  * One query replaces N individual LIKE queries (the main perf bottleneck).
2127
2534
  */
2128
2535
  fetchReactionsForChat(chatRowId) {
2536
+ const cached = this.cachedReactionsByChat.get(chatRowId);
2537
+ if (cached && Date.now() - cached.ts < IMessageDB.CACHE_TTL_MS) {
2538
+ return cached.data;
2539
+ }
2129
2540
  const stmt = this.raw.prepare(`
2130
2541
  SELECT
2131
2542
  m.associated_message_type,
@@ -2161,6 +2572,7 @@ class IMessageDB {
2161
2572
  if (existing) existing.push(reaction);
2162
2573
  else result.set(targetGuid, [reaction]);
2163
2574
  }
2575
+ this.cachedReactionsByChat.set(chatRowId, { data: result, ts: Date.now() });
2164
2576
  return result;
2165
2577
  }
2166
2578
  /**
@@ -2242,6 +2654,7 @@ const imessageDb = /* @__PURE__ */ Object.freeze(/* @__PURE__ */ Object.definePr
2242
2654
  export {
2243
2655
  IMessageDB as I,
2244
2656
  hasNativeModule as h,
2245
- imessageDb as i
2657
+ imessageDb as i,
2658
+ normalizedPhoneVariants as n
2246
2659
  };
2247
- //# sourceMappingURL=imessage-db-BYU5mxM-.js.map
2660
+ //# sourceMappingURL=imessage-db-C7w6mu1Q.js.map