imsg-mcp 1.8.0 → 1.9.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/CHANGELOG.md CHANGED
@@ -3,6 +3,13 @@
3
3
  All notable changes to this project will be documented in this file.
4
4
  The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/) and follows [Semantic Versioning](https://semver.org/).
5
5
 
6
+ # [1.9.0](https://github.com/george43g/imsg-mcp/compare/v1.8.0...v1.9.0) (2026-07-21)
7
+
8
+
9
+ ### Features
10
+
11
+ * **db:** extract Apple-native voice-note transcripts, Genmoji & reply kinds ([6b9d715](https://github.com/george43g/imsg-mcp/commit/6b9d715dd6c356a337b8bb0b3ef96ff40419a73a))
12
+
6
13
  # [1.8.0](https://github.com/george43g/imsg-mcp/compare/v1.7.0...v1.8.0) (2026-07-20)
7
14
 
8
15
 
package/README.md CHANGED
@@ -108,7 +108,7 @@ Full reference (CLI subcommands + MCP tools + every flag): [**docs/TOOLS.md**](d
108
108
  imsg tui
109
109
  ```
110
110
 
111
- Vim-style: `j/k` move, `gg/G` jump, `Enter` drawer, `o` Quick Look an attachment, `:` jump to date, `V` visual select, `e` export, `S` send via other app, `y` copy slug, `c` compose in current thread, `N` compose to new recipient (phone / email / contact name), `q` quit.
111
+ Vim-style: `j/k` move, `gg/G` jump, `Enter` drawer, `i` per-thread info + attachment browser, `o` Quick Look an attachment, `:` jump to date, `V` visual select, `e` export, `S` send via other app, `y` copy slug, `c` compose in current thread, `N` compose to new recipient (phone / email / contact name), `q` quit.
112
112
 
113
113
  Themes (`safe` / `powerline`) and a single accent color drive the whole palette. See [docs/TOOLS.md#tui-configuration](docs/TOOLS.md#tui-configuration).
114
114
 
package/dist/cli.js CHANGED
@@ -4,7 +4,7 @@ import { createInterface } from "node:readline";
4
4
  import { fileURLToPath, pathToFileURL } from "node:url";
5
5
  import { Command } from "commander";
6
6
  import { c as checkLocalAccess, f as formatAccessReport, J as IMPLEMENTED_TYPES, K as ANALYTIC_INFO, w as installShutdownHandlers, x as registerCleanup, L as looksLikeThreadSlug } from "./shutdown-CQ9wzrxA.js";
7
- import { A as APP_VERSION, t as toYaml } from "./meta-DSkZz63Q.js";
7
+ import { A as APP_VERSION, t as toYaml } from "./meta-BrYyl7Ls.js";
8
8
  import { spawn } from "node:child_process";
9
9
  import { join, dirname } from "node:path";
10
10
  function distRoot() {
@@ -447,7 +447,7 @@ async function runExportCommand(target, opts) {
447
447
  const { homedir } = await import("node:os");
448
448
  const { dirname: dirname2, join: join2, isAbsolute, resolve } = await import("node:path");
449
449
  const { getContactsDbPaths, getImsgDbPath, getSlugsDbPath } = await import("./shutdown-CQ9wzrxA.js").then((n) => n.a0);
450
- const { IMessageDB } = await import("./imessage-db-BXrBHlvx.js").then((n) => n.i);
450
+ const { IMessageDB } = await import("./imessage-db-vrzopRGT.js").then((n) => n.i);
451
451
  const { streamExport } = await import("./exportStream-DgPVRZTZ.js");
452
452
  const { parseUserDate } = await import("./date-parse-DJXMfq3a.js");
453
453
  const format = normalizeFormat(opts.format ?? "md");
@@ -767,6 +767,47 @@ function scoreCandidate(text) {
767
767
  return score;
768
768
  }
769
769
  const STRUCTURED_BOOST = 500;
770
+ const AUDIO_TRANSCRIPTION_MARKER = Buffer.from("IMAudioTranscription", "ascii");
771
+ const AUDIO_VALUE_FRAMING = Buffer.from([134, 146, 132, 150, 150]);
772
+ function extractAudioTranscription(blob) {
773
+ if (!blob) return void 0;
774
+ const markerAt = blob.indexOf(AUDIO_TRANSCRIPTION_MARKER);
775
+ if (markerAt === -1) return void 0;
776
+ let pos = markerAt + AUDIO_TRANSCRIPTION_MARKER.length;
777
+ if (blob.subarray(pos, pos + AUDIO_VALUE_FRAMING.length).equals(AUDIO_VALUE_FRAMING)) {
778
+ pos += AUDIO_VALUE_FRAMING.length;
779
+ } else {
780
+ const scanEnd = Math.min(pos + 8, blob.length);
781
+ let found = -1;
782
+ for (let i = pos; i < scanEnd; i++) {
783
+ const b = blob[i];
784
+ if (b === 129 || b === 130 || b <= 128) {
785
+ found = i;
786
+ break;
787
+ }
788
+ }
789
+ if (found === -1) return void 0;
790
+ pos = found;
791
+ }
792
+ if (pos >= blob.length) return void 0;
793
+ const lenByte = blob[pos++];
794
+ let length;
795
+ if (lenByte === 129) {
796
+ if (pos + 2 > blob.length) return void 0;
797
+ length = blob.readUInt16LE(pos);
798
+ pos += 2;
799
+ } else if (lenByte === 130) {
800
+ if (pos + 4 > blob.length) return void 0;
801
+ length = blob.readUInt32LE(pos);
802
+ pos += 4;
803
+ } else {
804
+ length = lenByte;
805
+ }
806
+ if (length <= 0 || pos + length > blob.length) return void 0;
807
+ const text = blob.toString("utf8", pos, pos + length).trim();
808
+ if (!text || !/[\p{L}\p{N}]/u.test(text)) return void 0;
809
+ return text;
810
+ }
770
811
  function extractAttributedBodyText(blob) {
771
812
  if (!blob) return void 0;
772
813
  const native = getNativeParser();
@@ -819,6 +860,63 @@ function extractAttributedBodyText(blob) {
819
860
  const best = [...candidates.entries()].sort((a, b) => b[1] - a[1])[0];
820
861
  return best?.[0];
821
862
  }
863
+ function mergeDuplicateConversations(prepared) {
864
+ const merged = [];
865
+ const indexByKey = /* @__PURE__ */ new Map();
866
+ for (const entry of prepared) {
867
+ const existingIndex = indexByKey.get(entry.mergeKey);
868
+ if (existingIndex === void 0) {
869
+ indexByKey.set(entry.mergeKey, merged.length);
870
+ merged.push(entry);
871
+ continue;
872
+ }
873
+ merged[existingIndex] = mergeConversationEntries(merged[existingIndex], entry);
874
+ }
875
+ return merged;
876
+ }
877
+ function mergeConversationEntries(left, right) {
878
+ const preferred = pickPreferredConversationEntry(left, right);
879
+ const other = preferred === left ? right : left;
880
+ const sameIdentifier = preferred.conversation.chatIdentifier === other.conversation.chatIdentifier;
881
+ return {
882
+ mergeKey: preferred.mergeKey,
883
+ last: preferred.last ?? other.last,
884
+ conversation: {
885
+ ...preferred.conversation,
886
+ displayName: preferred.conversation.displayName ?? other.conversation.displayName,
887
+ participants: [
888
+ .../* @__PURE__ */ new Set([...preferred.conversation.participants, ...other.conversation.participants])
889
+ ],
890
+ unreadCount: sameIdentifier ? Math.max(preferred.conversation.unreadCount, other.conversation.unreadCount) : preferred.conversation.unreadCount + other.conversation.unreadCount
891
+ }
892
+ };
893
+ }
894
+ function pickPreferredConversationEntry(left, right) {
895
+ const leftTime = left.conversation.lastMessageDate?.getTime() ?? 0;
896
+ const rightTime = right.conversation.lastMessageDate?.getTime() ?? 0;
897
+ if (leftTime !== rightTime) {
898
+ return leftTime > rightTime ? left : right;
899
+ }
900
+ const preferredService = left.last?.lastService ?? right.last?.lastService ?? null;
901
+ if (preferredService) {
902
+ const preferredType = preferredService.toLowerCase().includes("sms") ? "SMS" : "iMessage";
903
+ if (left.conversation.serviceType === preferredType && right.conversation.serviceType !== preferredType) {
904
+ return left;
905
+ }
906
+ if (right.conversation.serviceType === preferredType && left.conversation.serviceType !== preferredType) {
907
+ return right;
908
+ }
909
+ }
910
+ if (left.conversation.displayName && !right.conversation.displayName) return left;
911
+ if (right.conversation.displayName && !left.conversation.displayName) return right;
912
+ if (left.conversation.serviceType === "iMessage" && right.conversation.serviceType === "SMS") {
913
+ return left;
914
+ }
915
+ if (right.conversation.serviceType === "iMessage" && left.conversation.serviceType === "SMS") {
916
+ return right;
917
+ }
918
+ return left;
919
+ }
822
920
  function normalizeSnippetText(text) {
823
921
  if (!text) return null;
824
922
  const attachmentMarker = new RegExp(
@@ -1162,6 +1260,8 @@ class IMessageDB {
1162
1260
  * other clear site. Add one if you introduce a read path that bypasses it.
1163
1261
  */
1164
1262
  cachedReactionsByChat = /* @__PURE__ */ new Map();
1263
+ /** Per-table column-name sets, for optional-column guards (schema drift / synthetic fixtures). */
1264
+ columnCache = /* @__PURE__ */ new Map();
1165
1265
  backgroundSyncNeeded = true;
1166
1266
  backgroundRefreshScheduled = false;
1167
1267
  /** Set on close() so background chunked work stops touching a closed DB. */
@@ -1771,12 +1871,12 @@ class IMessageDB {
1771
1871
  const CHUNK = Math.max(limit, 200);
1772
1872
  const prepared = [];
1773
1873
  let cursor = 0;
1774
- let deduped = this.mergeDuplicateConversations(prepared);
1874
+ let deduped = mergeDuplicateConversations(prepared);
1775
1875
  while (deduped.length < limit && cursor < sortEntries.length) {
1776
1876
  const chunk = sortEntries.slice(cursor, cursor + CHUNK);
1777
1877
  cursor += chunk.length;
1778
1878
  for (const entry of chunk) prepared.push(enrich(entry));
1779
- deduped = this.mergeDuplicateConversations(prepared);
1879
+ deduped = mergeDuplicateConversations(prepared);
1780
1880
  }
1781
1881
  const selected = deduped.slice(0, limit);
1782
1882
  const result = selected.map(({ conversation, last }) => ({
@@ -2091,63 +2191,6 @@ class IMessageDB {
2091
2191
  }
2092
2192
  return null;
2093
2193
  }
2094
- mergeDuplicateConversations(prepared) {
2095
- const merged = [];
2096
- const indexByKey = /* @__PURE__ */ new Map();
2097
- for (const entry of prepared) {
2098
- const existingIndex = indexByKey.get(entry.mergeKey);
2099
- if (existingIndex === void 0) {
2100
- indexByKey.set(entry.mergeKey, merged.length);
2101
- merged.push(entry);
2102
- continue;
2103
- }
2104
- merged[existingIndex] = this.mergeConversationEntries(merged[existingIndex], entry);
2105
- }
2106
- return merged;
2107
- }
2108
- mergeConversationEntries(left, right) {
2109
- const preferred = this.pickPreferredConversationEntry(left, right);
2110
- const other = preferred === left ? right : left;
2111
- const sameIdentifier = preferred.conversation.chatIdentifier === other.conversation.chatIdentifier;
2112
- return {
2113
- mergeKey: preferred.mergeKey,
2114
- last: preferred.last ?? other.last,
2115
- conversation: {
2116
- ...preferred.conversation,
2117
- displayName: preferred.conversation.displayName ?? other.conversation.displayName,
2118
- participants: [
2119
- .../* @__PURE__ */ new Set([...preferred.conversation.participants, ...other.conversation.participants])
2120
- ],
2121
- unreadCount: sameIdentifier ? Math.max(preferred.conversation.unreadCount, other.conversation.unreadCount) : preferred.conversation.unreadCount + other.conversation.unreadCount
2122
- }
2123
- };
2124
- }
2125
- pickPreferredConversationEntry(left, right) {
2126
- const leftTime = left.conversation.lastMessageDate?.getTime() ?? 0;
2127
- const rightTime = right.conversation.lastMessageDate?.getTime() ?? 0;
2128
- if (leftTime !== rightTime) {
2129
- return leftTime > rightTime ? left : right;
2130
- }
2131
- const preferredService = left.last?.lastService ?? right.last?.lastService ?? null;
2132
- if (preferredService) {
2133
- const preferredType = preferredService.toLowerCase().includes("sms") ? "SMS" : "iMessage";
2134
- if (left.conversation.serviceType === preferredType && right.conversation.serviceType !== preferredType) {
2135
- return left;
2136
- }
2137
- if (right.conversation.serviceType === preferredType && left.conversation.serviceType !== preferredType) {
2138
- return right;
2139
- }
2140
- }
2141
- if (left.conversation.displayName && !right.conversation.displayName) return left;
2142
- if (right.conversation.displayName && !left.conversation.displayName) return right;
2143
- if (left.conversation.serviceType === "iMessage" && right.conversation.serviceType === "SMS") {
2144
- return left;
2145
- }
2146
- if (right.conversation.serviceType === "iMessage" && left.conversation.serviceType === "SMS") {
2147
- return right;
2148
- }
2149
- return left;
2150
- }
2151
2194
  getConversationMergeKey(chatIdentifier, chatGuid, isGroup) {
2152
2195
  const cacheKey = `${chatIdentifier}::${chatGuid}::${isGroup}`;
2153
2196
  const cached = this.cachedMergeKeys.get(cacheKey);
@@ -2486,11 +2529,7 @@ class IMessageDB {
2486
2529
  const isReply = Boolean(ext.thread_originator_guid);
2487
2530
  let replyTo;
2488
2531
  if (isReply && ext.thread_originator_guid) {
2489
- const originalText = this.getMessageTextByGuid(ext.thread_originator_guid);
2490
- replyTo = {
2491
- replyToGuid: ext.thread_originator_guid,
2492
- replyToText: originalText
2493
- };
2532
+ replyTo = this.getReplyContextByGuid(ext.thread_originator_guid);
2494
2533
  }
2495
2534
  const richContentType = getRichContentType(ext.balloon_bundle_id || null);
2496
2535
  const hasAttachments = Boolean(ext.cache_has_attachments);
@@ -2520,6 +2559,7 @@ class IMessageDB {
2520
2559
  associatedMessageType: associatedType,
2521
2560
  hasAttachments
2522
2561
  });
2562
+ const appleAudioTranscript = extractAudioTranscription(raw.attributedBody ?? null);
2523
2563
  return {
2524
2564
  id: raw.ROWID,
2525
2565
  guid: raw.guid,
@@ -2547,6 +2587,7 @@ class IMessageDB {
2547
2587
  richContentSummary,
2548
2588
  isEdited: !isUnsent && Boolean(ext.date_edited && ext.date_edited > 0),
2549
2589
  isRetracted: isUnsent || Boolean(ext.date_retracted && ext.date_retracted > 0),
2590
+ appleAudioTranscript,
2550
2591
  hasAttachments,
2551
2592
  attachments
2552
2593
  };
@@ -2674,6 +2715,24 @@ class IMessageDB {
2674
2715
  }
2675
2716
  return result;
2676
2717
  }
2718
+ /**
2719
+ * Whether `table` has `column` in the current chat.db schema (cached per table).
2720
+ * Guards columns that are absent on older macOS DBs or synthetic fixtures
2721
+ * (e.g. `message.is_audio_message`, `attachment.emoji_image_short_description`).
2722
+ */
2723
+ hasColumn(table, column) {
2724
+ let cols = this.columnCache.get(table);
2725
+ if (!cols) {
2726
+ try {
2727
+ const rows = this.raw.prepare(`PRAGMA table_info(${table})`).all();
2728
+ cols = new Set(rows.map((r) => r.name));
2729
+ } catch {
2730
+ cols = /* @__PURE__ */ new Set();
2731
+ }
2732
+ this.columnCache.set(table, cols);
2733
+ }
2734
+ return cols.has(column);
2735
+ }
2677
2736
  /** Max ROWID currently in the message table — used as a cache key. */
2678
2737
  getMaxMessageRowId() {
2679
2738
  const row = this.raw.prepare(`SELECT COALESCE(MAX(ROWID), 0) AS m FROM ${Tables.MESSAGE}`).get();
@@ -2869,13 +2928,14 @@ class IMessageDB {
2869
2928
  * Fetch attachments for a message
2870
2929
  */
2871
2930
  fetchAttachments(messageRowId) {
2931
+ const emojiCol = this.hasColumn("attachment", "emoji_image_short_description") ? ", a.emoji_image_short_description as emoji_desc" : "";
2872
2932
  const stmt = this.raw.prepare(`
2873
- SELECT
2933
+ SELECT
2874
2934
  a.ROWID as row_id,
2875
2935
  a.filename,
2876
2936
  a.mime_type,
2877
2937
  a.transfer_name,
2878
- a.total_bytes
2938
+ a.total_bytes${emojiCol}
2879
2939
  FROM ${Tables.ATTACHMENT} a
2880
2940
  JOIN ${Tables.MESSAGE_ATTACHMENT_JOIN} maj ON a.ROWID = maj.attachment_id
2881
2941
  WHERE maj.message_id = ?
@@ -2886,7 +2946,8 @@ class IMessageDB {
2886
2946
  filename: r.filename || "",
2887
2947
  mimeType: r.mime_type,
2888
2948
  transferName: r.transfer_name,
2889
- totalBytes: r.total_bytes || 0
2949
+ totalBytes: r.total_bytes || 0,
2950
+ emojiDescription: r.emoji_desc ?? null
2890
2951
  }));
2891
2952
  }
2892
2953
  /**
@@ -2998,9 +3059,53 @@ class IMessageDB {
2998
3059
  const row = this.raw.prepare(`SELECT ROWID, text, attributedBody FROM ${Tables.MESSAGE} WHERE ROWID = ? LIMIT 1`).get(rowId);
2999
3060
  return this.extractMessageText(row);
3000
3061
  }
3001
- getMessageTextByGuid(guid) {
3002
- const row = this.raw.prepare(`SELECT ROWID, text, attributedBody FROM ${Tables.MESSAGE} WHERE guid = ? LIMIT 1`).get(guid);
3003
- return this.extractMessageText(row);
3062
+ /**
3063
+ * Build reply context for the message being replied to, by GUID. Beyond the
3064
+ * plain text, this resolves what KIND of message it was when it carries no text
3065
+ * of its own — a voice note (surfacing Apple's transcript), image, video, or
3066
+ * file — so the UI renders "↩ voice note: '…'" instead of a bare "(unknown)".
3067
+ */
3068
+ getReplyContextByGuid(guid) {
3069
+ const audioCol = this.hasColumn("message", "is_audio_message") ? "is_audio_message" : "0 AS is_audio_message";
3070
+ const row = this.raw.prepare(
3071
+ `SELECT ROWID, text, attributedBody, ${audioCol}, cache_has_attachments
3072
+ FROM ${Tables.MESSAGE} WHERE guid = ? LIMIT 1`
3073
+ ).get(guid);
3074
+ const ctx = { replyToGuid: guid };
3075
+ if (!row) return ctx;
3076
+ if (row.is_audio_message) {
3077
+ ctx.replyToKind = "voice-note";
3078
+ ctx.replyToText = extractAudioTranscription(row.attributedBody) ?? null;
3079
+ return ctx;
3080
+ }
3081
+ const text = this.extractMessageText(row);
3082
+ if (text) {
3083
+ ctx.replyToText = text;
3084
+ return ctx;
3085
+ }
3086
+ if (row.cache_has_attachments) {
3087
+ ctx.replyToKind = this.deriveAttachmentReplyKind(row.ROWID);
3088
+ ctx.replyToText = null;
3089
+ return ctx;
3090
+ }
3091
+ ctx.replyToText = null;
3092
+ return ctx;
3093
+ }
3094
+ /** Map a message's first (non-sticker) attachment mime/uti to a reply-kind label. */
3095
+ deriveAttachmentReplyKind(messageRowId) {
3096
+ const utiCol = this.hasColumn("attachment", "uti") ? "a.uti" : "NULL AS uti";
3097
+ const row = this.raw.prepare(
3098
+ `SELECT a.mime_type, ${utiCol}
3099
+ FROM ${Tables.ATTACHMENT} a
3100
+ JOIN ${Tables.MESSAGE_ATTACHMENT_JOIN} maj ON a.ROWID = maj.attachment_id
3101
+ WHERE maj.message_id = ? AND a.is_sticker = 0
3102
+ ORDER BY a.ROWID LIMIT 1`
3103
+ ).get(messageRowId);
3104
+ const mime = (row?.mime_type ?? "").toLowerCase();
3105
+ const uti = (row?.uti ?? "").toLowerCase();
3106
+ if (mime.startsWith("image/") || uti.includes("image")) return "image";
3107
+ if (mime.startsWith("video/") || uti.includes("movie") || uti.includes("video")) return "video";
3108
+ return "file";
3004
3109
  }
3005
3110
  extractMessageText(row) {
3006
3111
  if (!row) return null;
@@ -3019,4 +3124,4 @@ export {
3019
3124
  normalizedPhoneVariants as n,
3020
3125
  rankFuzzy as r
3021
3126
  };
3022
- //# sourceMappingURL=imessage-db-BXrBHlvx.js.map
3127
+ //# sourceMappingURL=imessage-db-vrzopRGT.js.map