imsg-mcp 1.15.0 → 1.16.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 +15 -0
- package/README.md +35 -5
- package/dist/cli.js +3 -3
- package/dist/{imessage-db-C1owvGNS.js → imessage-db-DXFIm7is.js} +171 -80
- package/dist/imessage-db-DXFIm7is.js.map +1 -0
- package/dist/index.js +21 -11
- package/dist/index.js.map +1 -1
- package/dist/{meta-ByD-lGq7.js → meta-DcLrk46N.js} +2 -2
- package/dist/{meta-ByD-lGq7.js.map → meta-DcLrk46N.js.map} +1 -1
- package/dist/shutdown-0TCv4XJB.js.map +1 -1
- package/dist/tui.js +46 -14
- package/dist/tui.js.map +1 -1
- package/package.json +1 -1
- package/skills/imsg-mcp/SKILL.md +18 -5
- package/dist/imessage-db-C1owvGNS.js.map +0 -1
package/CHANGELOG.md
CHANGED
|
@@ -3,6 +3,21 @@
|
|
|
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.16.0](https://github.com/george43g/imsg-mcp/compare/v1.15.1...v1.16.0) (2026-07-25)
|
|
7
|
+
|
|
8
|
+
|
|
9
|
+
### Features
|
|
10
|
+
|
|
11
|
+
* **contacts:** rank search_contacts by relevance with matchedField ([352ccd2](https://github.com/george43g/imsg-mcp/commit/352ccd2da1263f26baa31ef38737a9d52bdf3aa0))
|
|
12
|
+
|
|
13
|
+
## [1.15.1](https://github.com/george43g/imsg-mcp/compare/v1.15.0...v1.15.1) (2026-07-25)
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
### Bug Fixes
|
|
17
|
+
|
|
18
|
+
* **tui:** refresh sidebar after in-thread send + navigable filter results ([f6552be](https://github.com/george43g/imsg-mcp/commit/f6552be71a6c11abb240d38c853d0dadefcb25a9))
|
|
19
|
+
* **tui:** show identity-canonical service on conversation badges ([0b05827](https://github.com/george43g/imsg-mcp/commit/0b05827eb48b2d217b0d56c6af6b0dbe66e7829b))
|
|
20
|
+
|
|
6
21
|
# [1.15.0](https://github.com/george43g/imsg-mcp/compare/v1.14.0...v1.15.0) (2026-07-23)
|
|
7
22
|
|
|
8
23
|
|
package/README.md
CHANGED
|
@@ -5,7 +5,7 @@
|
|
|
5
5
|
[](https://github.com/george43g/imsg-mcp/actions/workflows/release.yml)
|
|
6
6
|
[](LICENSE)
|
|
7
7
|
|
|
8
|
-
**MCP server, CLI, and terminal UI for iMessage on macOS.** Let AI agents read your iMessage / SMS, search history, send messages,
|
|
8
|
+
**MCP server, CLI, and terminal UI for iMessage on macOS.** Let AI agents read your iMessage / SMS, search history, send messages, export conversations, and *understand* media — transcribe voice notes, caption images & video — without ever leaving your machine by default. Includes a self-healing watchdog so a wedged query can't take your agent down.
|
|
9
9
|
|
|
10
10
|

|
|
11
11
|
|
|
@@ -41,6 +41,7 @@ Prefer the canonical `npx -y` config above. If you'd rather install the MCPB ext
|
|
|
41
41
|
```bash
|
|
42
42
|
npx -y imsg-mcp setup --write claude # or --write cursor
|
|
43
43
|
npx -y imsg-mcp doctor # verify Full Disk Access + DB
|
|
44
|
+
npx -y imsg-mcp setup --interactive # (optional) configure media interpretation
|
|
44
45
|
```
|
|
45
46
|
|
|
46
47
|
---
|
|
@@ -85,7 +86,7 @@ Full walkthrough with screenshots: [**docs/WORKFLOWS.md**](docs/WORKFLOWS.md).
|
|
|
85
86
|
| `wait_for_reply` | Poll for the next reply — includes the user's own interjections from other devices. |
|
|
86
87
|
| `export_messages` | Stream a chat to file (md/csv/json/ndjson). |
|
|
87
88
|
| `search_attachments` | Find attachments by mime/date/chat. |
|
|
88
|
-
| `get_attachment` | Fetch attachment
|
|
89
|
+
| `get_attachment` | Fetch an attachment (image block / poster / audio) + optional AI interpretation (`interpret`). |
|
|
89
90
|
| `check_imessage_availability` | Pre-flight iMessage vs SMS reachability. |
|
|
90
91
|
| `chat_analytics` | Pre-aggregated stats (heatmaps, leaderboards, etc). |
|
|
91
92
|
| `list_contacts` / `search_contacts` | Browse/search contacts (name, phone, email). |
|
|
@@ -108,18 +109,47 @@ Full reference (CLI subcommands + MCP tools + every flag): [**docs/TOOLS.md**](d
|
|
|
108
109
|
imsg tui
|
|
109
110
|
```
|
|
110
111
|
|
|
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
|
+
Vim-style: `j/k` move, `gg/G` jump, `Enter` drawer, `i` per-thread info + attachment browser, `o` Quick Look an attachment, `f` reveal in Finder, `R` run/retry media interpretation, `:` 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), `,` settings panel, `q` quit.
|
|
112
113
|
|
|
113
114
|
Themes (`safe` / `powerline`) and a single accent color drive the whole palette. See [docs/TOOLS.md#tui-configuration](docs/TOOLS.md#tui-configuration).
|
|
114
115
|
|
|
115
116
|
---
|
|
116
117
|
|
|
118
|
+
## Media interpretation
|
|
119
|
+
|
|
120
|
+
imsg-mcp reads what Apple already computed **for free, on-device**: iPhone-synced voice-note
|
|
121
|
+
transcripts and Genmoji descriptions surface automatically with zero setup and zero network. For
|
|
122
|
+
everything else it runs a configurable **chain** per media type:
|
|
123
|
+
|
|
124
|
+
```
|
|
125
|
+
apple → local (hear / yap / whisper-cli) → provider:<cloud>
|
|
126
|
+
```
|
|
127
|
+
|
|
128
|
+
- **Free-first & opt-in.** `interpret.auto` defaults to `free`, so only Apple + local legs run
|
|
129
|
+
unless you deliberately add a cloud provider. **Audio and images leave your device only when a
|
|
130
|
+
cloud leg you configured actually fires** — never by default.
|
|
131
|
+
- **Local tools** (optional, free): `brew install sveinbjornt/hear/hear` · `brew install yap` ·
|
|
132
|
+
`brew install whisper-cpp`. Optional `brew install ffmpeg mpv` improves video framing/playback.
|
|
133
|
+
- **Cloud providers** (optional): presets for `openai`, `groq`, `openrouter`, `cloudflare`,
|
|
134
|
+
`huggingface`, `ollama` (local, no key), or a custom OpenAI-compatible base URL. Configure with
|
|
135
|
+
`imsg setup --interactive`; keys are stored in `~/.imsg-mcp/credentials.json` (chmod 600), never
|
|
136
|
+
in the shared config.
|
|
137
|
+
- **Cached forever.** Every result is cached in `~/.imsg-mcp/media-intel.db` — the same attachment
|
|
138
|
+
is never interpreted (or paid for) twice. Bulk exports confirm before making more than
|
|
139
|
+
`exportConfirmThreshold` (default 25) uncached cloud calls.
|
|
140
|
+
|
|
141
|
+
Trigger it via `get_attachment` (`interpret`), `export_messages` (`interpret`), `imsg interpret
|
|
142
|
+
<rowId>`, or the TUI (`R`). Full config reference: [docs/TOOLS.md](docs/TOOLS.md).
|
|
143
|
+
|
|
144
|
+
---
|
|
145
|
+
|
|
117
146
|
## Permissions
|
|
118
147
|
|
|
119
148
|
- **Full Disk Access** — required to read `chat.db` and Address Book. System Settings → Privacy & Security → Full Disk Access → add the terminal/IDE you actually run from.
|
|
120
|
-
- **Automation** — required only for sending. macOS prompts on first
|
|
149
|
+
- **Automation** — required only for sending, and for the attachment **sync nudge** (opening a conversation so Messages downloads its media). macOS prompts on first use.
|
|
150
|
+
- **Accessibility** *(opt-in, off by default)* — only if you enable the Tier-2 "Sync Now" nudge (`interpret.nudge.tier2SyncNow`), which UI-scripts the Messages ▸ Settings ▸ Sync Now button. Leave it off unless you need it. System Settings → Privacy & Security → Accessibility.
|
|
121
151
|
|
|
122
|
-
Privacy: only
|
|
152
|
+
Privacy: reads only your **local** `~/Library/Messages/chat.db` — nothing is uploaded by default. Cloud media interpretation is strictly opt-in (see above) and only sends the specific attachment you interpret to the provider you configured. The connected MCP host (Claude / Cursor / Warp / …) sees whatever messages the agent fetches — treat those hosts the same way you treat any app with Full Disk Access.
|
|
123
153
|
|
|
124
154
|
---
|
|
125
155
|
|
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 { d as checkLocalAccess, f as formatAccessReport, I as IMPLEMENTED_TYPES, A as ANALYTIC_INFO, i as installShutdownHandlers, r as registerCleanup, l as looksLikeThreadSlug } from "./shutdown-0TCv4XJB.js";
|
|
7
|
-
import { A as APP_VERSION, t as toYaml } from "./meta-
|
|
7
|
+
import { A as APP_VERSION, t as toYaml } from "./meta-DcLrk46N.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-0TCv4XJB.js").then((n) => n.a2);
|
|
450
|
-
const { IMessageDB } = await import("./imessage-db-
|
|
450
|
+
const { IMessageDB } = await import("./imessage-db-DXFIm7is.js").then((n) => n.i);
|
|
451
451
|
const { streamExport } = await import("./exportStream-BFxilSlL.js");
|
|
452
452
|
const { parseUserDate } = await import("./date-parse-DJXMfq3a.js");
|
|
453
453
|
const format = normalizeFormat(opts.format ?? "md");
|
|
@@ -719,7 +719,7 @@ program.command("interpret <rowId>").description("Interpret one attachment (tran
|
|
|
719
719
|
return;
|
|
720
720
|
}
|
|
721
721
|
const { getContactsDbPaths, getImsgDbPath, getSlugsDbPath } = await import("./shutdown-0TCv4XJB.js").then((n) => n.a2);
|
|
722
|
-
const { IMessageDB } = await import("./imessage-db-
|
|
722
|
+
const { IMessageDB } = await import("./imessage-db-DXFIm7is.js").then((n) => n.i);
|
|
723
723
|
const { getInterpretRuntime, refForAttachment } = await import("./media-intel-runtime-BkxG4bmW.js").then((n) => n.c);
|
|
724
724
|
const { deleteMediaIntel } = await import("./media-intel-cache-mCgH4z9P.js");
|
|
725
725
|
const db = new IMessageDB(getImsgDbPath(), getContactsDbPaths(), getSlugsDbPath());
|
|
@@ -6,6 +6,144 @@ import { fileURLToPath } from "node:url";
|
|
|
6
6
|
import { existsSync, readdirSync, mkdirSync } from "node:fs";
|
|
7
7
|
import { O as OBJECT_REPLACEMENT_CHAR, p as perf, m as isGroupGuid, n as isGroupChatIdentifier, q as generateThreadSlug, T as Tables, t as AssociatedMessageType, u as macAutoTimestampToDate, v as isReactionType, M as MAC_EPOCH_OFFSET, N as NANOS_PER_SECOND, x as macTimestampToDate$1, y as parseAssociatedMessageGuid$1 } from "./shutdown-0TCv4XJB.js";
|
|
8
8
|
import bplist, { parseBuffer } from "bplist-parser";
|
|
9
|
+
const EMOJI_REGEX = /[\u{1F300}-\u{1FAFF}\u{2600}-\u{27BF}\u{1F1E6}-\u{1F1FF}]/gu;
|
|
10
|
+
const WHITESPACE_REGEX = /\s+/g;
|
|
11
|
+
function cleanText(s) {
|
|
12
|
+
return s.toLowerCase().replace(EMOJI_REGEX, " ").replace(WHITESPACE_REGEX, " ").trim();
|
|
13
|
+
}
|
|
14
|
+
function tokenSet(s) {
|
|
15
|
+
if (!s) return /* @__PURE__ */ new Set();
|
|
16
|
+
return new Set(s.split(" ").filter(Boolean));
|
|
17
|
+
}
|
|
18
|
+
function levenshtein(a, b, maxLen = 200) {
|
|
19
|
+
if (a === b) return 0;
|
|
20
|
+
const aLen = Math.min(a.length, maxLen);
|
|
21
|
+
const bLen = Math.min(b.length, maxLen);
|
|
22
|
+
if (aLen === 0) return bLen;
|
|
23
|
+
if (bLen === 0) return aLen;
|
|
24
|
+
let prev = new Array(bLen + 1);
|
|
25
|
+
let curr = new Array(bLen + 1);
|
|
26
|
+
for (let j = 0; j <= bLen; j++) prev[j] = j;
|
|
27
|
+
for (let i = 1; i <= aLen; i++) {
|
|
28
|
+
curr[0] = i;
|
|
29
|
+
for (let j = 1; j <= bLen; j++) {
|
|
30
|
+
const cost = a[i - 1] === b[j - 1] ? 0 : 1;
|
|
31
|
+
const del = (prev[j] ?? 0) + 1;
|
|
32
|
+
const ins = (curr[j - 1] ?? 0) + 1;
|
|
33
|
+
const sub = (prev[j - 1] ?? 0) + cost;
|
|
34
|
+
curr[j] = Math.min(del, ins, sub);
|
|
35
|
+
}
|
|
36
|
+
[prev, curr] = [curr, prev];
|
|
37
|
+
}
|
|
38
|
+
return prev[bLen] ?? Math.max(aLen, bLen);
|
|
39
|
+
}
|
|
40
|
+
function levenshteinRatio(a, b) {
|
|
41
|
+
if (a === b) return 1;
|
|
42
|
+
const aLen = Math.min(a.length, 200);
|
|
43
|
+
const bLen = Math.min(b.length, 200);
|
|
44
|
+
const maxLen = Math.max(aLen, bLen);
|
|
45
|
+
if (maxLen === 0) return 1;
|
|
46
|
+
return 1 - levenshtein(a, b) / maxLen;
|
|
47
|
+
}
|
|
48
|
+
function fuzzyScore(query, candidate) {
|
|
49
|
+
if (query && candidate && candidate.toLowerCase().includes(query.toLowerCase())) {
|
|
50
|
+
return 0.95;
|
|
51
|
+
}
|
|
52
|
+
const q = cleanText(query);
|
|
53
|
+
const c = cleanText(candidate);
|
|
54
|
+
if (!q || !c) return 0;
|
|
55
|
+
if (c.includes(q)) return 0.95;
|
|
56
|
+
if (q.includes(c)) return 0.9;
|
|
57
|
+
const qTokens = tokenSet(q);
|
|
58
|
+
const cTokens = tokenSet(c);
|
|
59
|
+
let intersection = 0;
|
|
60
|
+
for (const tok of qTokens) {
|
|
61
|
+
if (cTokens.has(tok)) intersection++;
|
|
62
|
+
}
|
|
63
|
+
const dice = 2 * intersection / (qTokens.size + cTokens.size || 1);
|
|
64
|
+
let bestTokenLev = 0;
|
|
65
|
+
if (qTokens.size === 1) {
|
|
66
|
+
for (const tok of cTokens) {
|
|
67
|
+
const r = levenshteinRatio(q, tok);
|
|
68
|
+
if (r > bestTokenLev) bestTokenLev = r;
|
|
69
|
+
}
|
|
70
|
+
}
|
|
71
|
+
const levRatio = Math.max(levenshteinRatio(q, c), bestTokenLev);
|
|
72
|
+
const blend = qTokens.size <= 1 ? 0.1 * dice + 0.9 * levRatio : 0.7 * dice + 0.3 * levRatio;
|
|
73
|
+
return Math.max(0, Math.min(1, blend));
|
|
74
|
+
}
|
|
75
|
+
function rankFuzzy(query, candidates, getText, minScore = 0.6) {
|
|
76
|
+
const out = [];
|
|
77
|
+
for (const c of candidates) {
|
|
78
|
+
const score = fuzzyScore(query, getText(c));
|
|
79
|
+
if (score >= minScore) out.push({ item: c, score });
|
|
80
|
+
}
|
|
81
|
+
out.sort((a, b) => b.score - a.score);
|
|
82
|
+
return out;
|
|
83
|
+
}
|
|
84
|
+
function scoreTextField(query, value) {
|
|
85
|
+
if (!value) return 0;
|
|
86
|
+
const v = value.toLowerCase();
|
|
87
|
+
const q = query.toLowerCase().trim();
|
|
88
|
+
if (!q) return 0;
|
|
89
|
+
if (v === q) return 1;
|
|
90
|
+
if (v.startsWith(q)) return 0.9;
|
|
91
|
+
if (v.includes(q)) return 0.7;
|
|
92
|
+
return fuzzyScore(q, v);
|
|
93
|
+
}
|
|
94
|
+
function scorePhoneField(query, phone) {
|
|
95
|
+
const qd = query.replace(/\D/g, "");
|
|
96
|
+
const pd = phone.replace(/\D/g, "");
|
|
97
|
+
if (!qd || !pd) return 0;
|
|
98
|
+
if (pd === qd) return 1;
|
|
99
|
+
if (pd.includes(qd)) return 0.8;
|
|
100
|
+
return 0;
|
|
101
|
+
}
|
|
102
|
+
const NAME_FIELDS = [
|
|
103
|
+
["displayName", (c) => c.displayName],
|
|
104
|
+
["firstName", (c) => c.firstName],
|
|
105
|
+
["lastName", (c) => c.lastName],
|
|
106
|
+
["nickname", (c) => c.nickname],
|
|
107
|
+
["organization", (c) => c.organization]
|
|
108
|
+
];
|
|
109
|
+
function scoreContactMatch(query, contact, floor = 0.6) {
|
|
110
|
+
let best = 0;
|
|
111
|
+
let field = "displayName";
|
|
112
|
+
for (const [name, get] of NAME_FIELDS) {
|
|
113
|
+
const s = scoreTextField(query, get(contact));
|
|
114
|
+
if (s > best) {
|
|
115
|
+
best = s;
|
|
116
|
+
field = name;
|
|
117
|
+
}
|
|
118
|
+
}
|
|
119
|
+
for (const phone of contact.phoneNumbers) {
|
|
120
|
+
const s = scorePhoneField(query, phone);
|
|
121
|
+
if (s > best) {
|
|
122
|
+
best = s;
|
|
123
|
+
field = "phone";
|
|
124
|
+
}
|
|
125
|
+
}
|
|
126
|
+
for (const email of contact.emails) {
|
|
127
|
+
const s = scoreTextField(query, email);
|
|
128
|
+
if (s > best) {
|
|
129
|
+
best = s;
|
|
130
|
+
field = "email";
|
|
131
|
+
}
|
|
132
|
+
}
|
|
133
|
+
if (best < floor) return null;
|
|
134
|
+
return { contact, score: Math.round(best * 1e3) / 1e3, matchedField: field };
|
|
135
|
+
}
|
|
136
|
+
function rankContacts(query, contacts, floor = 0.6) {
|
|
137
|
+
const out = [];
|
|
138
|
+
for (const c of contacts) {
|
|
139
|
+
const m = scoreContactMatch(query, c, floor);
|
|
140
|
+
if (m) out.push(m);
|
|
141
|
+
}
|
|
142
|
+
out.sort(
|
|
143
|
+
(a, b) => b.score - a.score || a.contact.displayName.localeCompare(b.contact.displayName)
|
|
144
|
+
);
|
|
145
|
+
return out;
|
|
146
|
+
}
|
|
9
147
|
function normalizePhoneNumber(phone) {
|
|
10
148
|
const digits = phone.replace(/\D/g, "");
|
|
11
149
|
if (digits.length === 11 && digits.startsWith("1")) {
|
|
@@ -319,6 +457,18 @@ class ContactsDB {
|
|
|
319
457
|
(c) => c.displayName.toLowerCase().includes(lowerQuery) || c.phoneNumbers.some((p) => p.includes(query)) || c.emails.some((e) => e.toLowerCase().includes(lowerQuery))
|
|
320
458
|
);
|
|
321
459
|
}
|
|
460
|
+
/**
|
|
461
|
+
* Search contacts, ranked by relevance with the matched field annotated.
|
|
462
|
+
* Preserves the substring recall of searchContacts (a substring match scores
|
|
463
|
+
* 0.7, above the inclusion floor) while ordering best-first so callers can
|
|
464
|
+
* disambiguate close matches instead of eyeballing an unordered list.
|
|
465
|
+
*/
|
|
466
|
+
searchContactsRanked(query) {
|
|
467
|
+
if (!this.initialized) {
|
|
468
|
+
this.initialize();
|
|
469
|
+
}
|
|
470
|
+
return rankContacts(query, Array.from(this.contactCache.values()));
|
|
471
|
+
}
|
|
322
472
|
/**
|
|
323
473
|
* Build display name from contact fields
|
|
324
474
|
*/
|
|
@@ -373,81 +523,6 @@ class ContactsDB {
|
|
|
373
523
|
};
|
|
374
524
|
}
|
|
375
525
|
}
|
|
376
|
-
const EMOJI_REGEX = /[\u{1F300}-\u{1FAFF}\u{2600}-\u{27BF}\u{1F1E6}-\u{1F1FF}]/gu;
|
|
377
|
-
const WHITESPACE_REGEX = /\s+/g;
|
|
378
|
-
function cleanText(s) {
|
|
379
|
-
return s.toLowerCase().replace(EMOJI_REGEX, " ").replace(WHITESPACE_REGEX, " ").trim();
|
|
380
|
-
}
|
|
381
|
-
function tokenSet(s) {
|
|
382
|
-
if (!s) return /* @__PURE__ */ new Set();
|
|
383
|
-
return new Set(s.split(" ").filter(Boolean));
|
|
384
|
-
}
|
|
385
|
-
function levenshtein(a, b, maxLen = 200) {
|
|
386
|
-
if (a === b) return 0;
|
|
387
|
-
const aLen = Math.min(a.length, maxLen);
|
|
388
|
-
const bLen = Math.min(b.length, maxLen);
|
|
389
|
-
if (aLen === 0) return bLen;
|
|
390
|
-
if (bLen === 0) return aLen;
|
|
391
|
-
let prev = new Array(bLen + 1);
|
|
392
|
-
let curr = new Array(bLen + 1);
|
|
393
|
-
for (let j = 0; j <= bLen; j++) prev[j] = j;
|
|
394
|
-
for (let i = 1; i <= aLen; i++) {
|
|
395
|
-
curr[0] = i;
|
|
396
|
-
for (let j = 1; j <= bLen; j++) {
|
|
397
|
-
const cost = a[i - 1] === b[j - 1] ? 0 : 1;
|
|
398
|
-
const del = (prev[j] ?? 0) + 1;
|
|
399
|
-
const ins = (curr[j - 1] ?? 0) + 1;
|
|
400
|
-
const sub = (prev[j - 1] ?? 0) + cost;
|
|
401
|
-
curr[j] = Math.min(del, ins, sub);
|
|
402
|
-
}
|
|
403
|
-
[prev, curr] = [curr, prev];
|
|
404
|
-
}
|
|
405
|
-
return prev[bLen] ?? Math.max(aLen, bLen);
|
|
406
|
-
}
|
|
407
|
-
function levenshteinRatio(a, b) {
|
|
408
|
-
if (a === b) return 1;
|
|
409
|
-
const aLen = Math.min(a.length, 200);
|
|
410
|
-
const bLen = Math.min(b.length, 200);
|
|
411
|
-
const maxLen = Math.max(aLen, bLen);
|
|
412
|
-
if (maxLen === 0) return 1;
|
|
413
|
-
return 1 - levenshtein(a, b) / maxLen;
|
|
414
|
-
}
|
|
415
|
-
function fuzzyScore(query, candidate) {
|
|
416
|
-
if (query && candidate && candidate.toLowerCase().includes(query.toLowerCase())) {
|
|
417
|
-
return 0.95;
|
|
418
|
-
}
|
|
419
|
-
const q = cleanText(query);
|
|
420
|
-
const c = cleanText(candidate);
|
|
421
|
-
if (!q || !c) return 0;
|
|
422
|
-
if (c.includes(q)) return 0.95;
|
|
423
|
-
if (q.includes(c)) return 0.9;
|
|
424
|
-
const qTokens = tokenSet(q);
|
|
425
|
-
const cTokens = tokenSet(c);
|
|
426
|
-
let intersection = 0;
|
|
427
|
-
for (const tok of qTokens) {
|
|
428
|
-
if (cTokens.has(tok)) intersection++;
|
|
429
|
-
}
|
|
430
|
-
const dice = 2 * intersection / (qTokens.size + cTokens.size || 1);
|
|
431
|
-
let bestTokenLev = 0;
|
|
432
|
-
if (qTokens.size === 1) {
|
|
433
|
-
for (const tok of cTokens) {
|
|
434
|
-
const r = levenshteinRatio(q, tok);
|
|
435
|
-
if (r > bestTokenLev) bestTokenLev = r;
|
|
436
|
-
}
|
|
437
|
-
}
|
|
438
|
-
const levRatio = Math.max(levenshteinRatio(q, c), bestTokenLev);
|
|
439
|
-
const blend = qTokens.size <= 1 ? 0.1 * dice + 0.9 * levRatio : 0.7 * dice + 0.3 * levRatio;
|
|
440
|
-
return Math.max(0, Math.min(1, blend));
|
|
441
|
-
}
|
|
442
|
-
function rankFuzzy(query, candidates, getText, minScore = 0.6) {
|
|
443
|
-
const out = [];
|
|
444
|
-
for (const c of candidates) {
|
|
445
|
-
const score = fuzzyScore(query, getText(c));
|
|
446
|
-
if (score >= minScore) out.push({ item: c, score });
|
|
447
|
-
}
|
|
448
|
-
out.sort((a, b) => b.score - a.score);
|
|
449
|
-
return out;
|
|
450
|
-
}
|
|
451
526
|
const __dirname$1 = dirname(fileURLToPath(import.meta.url));
|
|
452
527
|
let _native;
|
|
453
528
|
function tryLoadNative() {
|
|
@@ -1532,6 +1607,24 @@ class IMessageDB {
|
|
|
1532
1607
|
}
|
|
1533
1608
|
return "iMessage";
|
|
1534
1609
|
}
|
|
1610
|
+
/**
|
|
1611
|
+
* Identity-canonical service for a conversation's badge (prefer iMessage).
|
|
1612
|
+
* Unlike detectServiceForChat (one leg's service), this reflects the whole
|
|
1613
|
+
* identity: if ANY leg is iMessage the thread is iMessage. It matches the
|
|
1614
|
+
* ~imsg~/~sms~ slug segment (computeSlugForChat) and the slug-store send
|
|
1615
|
+
* route, so the badge can't disagree with them. Without this, a single newer
|
|
1616
|
+
* SMS leg of a fundamentally-iMessage thread wins the merge cascade and flips
|
|
1617
|
+
* the badge to "SMS" while the slug stays ~imsg~. Groups have no cross-service
|
|
1618
|
+
* identity, so fall back to the chat's own service.
|
|
1619
|
+
*/
|
|
1620
|
+
canonicalServiceForChat(chat) {
|
|
1621
|
+
if (isGroupGuid(chat.guid) || isGroupChatIdentifier(chat.chat_identifier)) {
|
|
1622
|
+
return this.detectServiceForChat(chat);
|
|
1623
|
+
}
|
|
1624
|
+
this.ensureIdentityServiceMap();
|
|
1625
|
+
const mergeKey = this.getConversationMergeKey(chat.chat_identifier, chat.guid, false);
|
|
1626
|
+
return this.identityServiceMap?.get(mergeKey) ?? this.detectServiceForChat(chat);
|
|
1627
|
+
}
|
|
1535
1628
|
/**
|
|
1536
1629
|
* Get the N most recent messages across all conversations
|
|
1537
1630
|
* By default excludes reactions (tapbacks) for cleaner output
|
|
@@ -1900,8 +1993,7 @@ class IMessageDB {
|
|
|
1900
1993
|
const participants = isGroup ? this.fetchChatParticipants(chat.ROWID) : [rawIdentifier];
|
|
1901
1994
|
const mergeKey = this.getConversationMergeKey(rawIdentifier, chat.guid, isGroup);
|
|
1902
1995
|
const slug = this.getSlugForChatGuid(chat.guid) ?? this.syncSlugForChat(chat);
|
|
1903
|
-
const
|
|
1904
|
-
const serviceType = chatData ? this.detectServiceForChat(chatData) : this.detectServiceForChat(chat);
|
|
1996
|
+
const serviceType = this.canonicalServiceForChat(chat);
|
|
1905
1997
|
return {
|
|
1906
1998
|
last,
|
|
1907
1999
|
mergeKey,
|
|
@@ -1965,8 +2057,7 @@ class IMessageDB {
|
|
|
1965
2057
|
displayName = resolved !== rawIdentifier ? resolved : null;
|
|
1966
2058
|
}
|
|
1967
2059
|
const slug = this.getSlugForChatGuid(found.guid) ?? this.syncSlugForChat(found);
|
|
1968
|
-
const
|
|
1969
|
-
const serviceType = chatData ? this.detectServiceForChat(chatData) : this.detectServiceForChat(found);
|
|
2060
|
+
const serviceType = this.canonicalServiceForChat(found);
|
|
1970
2061
|
return {
|
|
1971
2062
|
chatId: found.guid,
|
|
1972
2063
|
chatIdentifier: rawIdentifier,
|
|
@@ -3205,4 +3296,4 @@ export {
|
|
|
3205
3296
|
normalizedPhoneVariants as n,
|
|
3206
3297
|
rankFuzzy as r
|
|
3207
3298
|
};
|
|
3208
|
-
//# sourceMappingURL=imessage-db-
|
|
3299
|
+
//# sourceMappingURL=imessage-db-DXFIm7is.js.map
|