mac-imsg 0.1.0__tar.gz

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.
mac_imsg-0.1.0/LICENSE ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 ml-lubich
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
@@ -0,0 +1,162 @@
1
+ Metadata-Version: 2.4
2
+ Name: mac-imsg
3
+ Version: 0.1.0
4
+ Classifier: Environment :: MacOS X
5
+ Classifier: Operating System :: MacOS :: MacOS X
6
+ Classifier: Programming Language :: Python :: 3
7
+ Classifier: Programming Language :: Rust
8
+ Classifier: License :: OSI Approved :: MIT License
9
+ Requires-Dist: typer>=0.12
10
+ Requires-Dist: rich>=13.7
11
+ Requires-Dist: mcp[cli]>=1.6.0
12
+ Requires-Dist: pytest>=8.0 ; extra == 'dev'
13
+ Requires-Dist: pytest-cov>=5.0 ; extra == 'dev'
14
+ Provides-Extra: dev
15
+ License-File: LICENSE
16
+ Summary: Fast, local iMessage for your terminal + MCP — Rust-accelerated read/search, AppleScript send. macOS only.
17
+ Keywords: imessage,macos,mcp,cli,rust,applescript
18
+ Author: ml-lubich
19
+ License: MIT
20
+ Requires-Python: >=3.10
21
+ Description-Content-Type: text/markdown; charset=UTF-8; variant=GFM
22
+ Project-URL: Homepage, https://github.com/ml-lubich/imsg
23
+ Project-URL: Repository, https://github.com/ml-lubich/imsg
24
+
25
+ # imsg — fast, local iMessage for your terminal & MCP
26
+
27
+ [![PyPI](https://img.shields.io/pypi/v/imsg.svg)](https://pypi.org/project/imsg/)
28
+ [![Python](https://img.shields.io/pypi/pyversions/imsg.svg)](https://pypi.org/project/imsg/)
29
+ [![Built with Rust](https://img.shields.io/badge/core-Rust-orange.svg)](rust/)
30
+ [![License: MIT](https://img.shields.io/badge/license-MIT-blue.svg)](LICENSE)
31
+ [![macOS only](https://img.shields.io/badge/os-macOS-black.svg)](#requirements)
32
+
33
+ Read, search, and send iMessage from your terminal — or expose it to Claude,
34
+ Cursor, VS Code, or any MCP client. Everything runs **locally on your Mac**:
35
+ no cloud, no login, no account. Reads open `~/Library/Messages/chat.db`
36
+ **read-only**; sends go through Messages.app.
37
+
38
+ **Why another one?** The hot path — decoding tens of thousands of
39
+ `attributedBody` typedstream blobs — is written in **Rust** (PyO3 + rusqlite),
40
+ so reads and searches over a large history are several times faster than the
41
+ pure-Python equivalent that other Messages servers use. Pure Python still ships
42
+ as a zero-dependency fallback, so it works even where the wheel doesn't.
43
+
44
+ <!-- BENCH -->
45
+ ### Benchmark
46
+
47
+ Full-history search over a synthetic **50,000-message** database (70% stored as
48
+ `attributedBody` blobs), best-of-5, Apple Silicon (16 cores):
49
+
50
+ | operation | pure-Python | Rust core | speedup |
51
+ |---|---:|---:|---:|
52
+ | **full-history search** (decodes every message) | 120.9 ms | **35.7 ms** | **3.4×** |
53
+ | batched blob decode (allocation-bound) | 19.1 ms | 19.6 ms | 1.0× |
54
+
55
+ Two honest caveats, stated up front:
56
+ - Raw blob decoding is ~a wash — it's bound by allocating result strings, not
57
+ CPU, so native code doesn't help. The win is in **search**, where Rust
58
+ decodes and filters across all cores and returns only the matches.
59
+ - It's 3.4×, not 16×, because the SQLite read and result marshalling are serial
60
+ on both sides; only the decode+match parallelizes. The gap widens on larger
61
+ histories.
62
+
63
+ Reproduce: `python bench/benchmark.py`. **Bonus:** this search also finds
64
+ messages whose text lives in an `attributedBody` blob — which a plain
65
+ `text LIKE` query (used by Python-only Messages servers) silently misses.
66
+ <!-- /BENCH -->
67
+
68
+ ## Install
69
+
70
+ ```bash
71
+ # with uv (recommended) — provisions Python + the prebuilt wheel
72
+ uv tool install imsg
73
+
74
+ # or pip
75
+ pip install imsg
76
+
77
+ # or Homebrew
78
+ brew install ml-lubich/tap/imsg
79
+ ```
80
+
81
+ ## Requirements
82
+
83
+ - **macOS** (reads the local Messages database; sends via Messages.app).
84
+ - **Full Disk Access** for the app that runs `imsg` — Terminal/iTerm/Ghostty
85
+ for the CLI, or your MCP client (Claude Desktop, Cursor, VS Code…) for the
86
+ server. System Settings → Privacy & Security → Full Disk Access → add it,
87
+ then fully quit and reopen it.
88
+ - Messages.app signed in and able to send a normal message.
89
+
90
+ Run `imsg doctor` to check access and see which engine (Rust or Python) is live.
91
+
92
+ ## CLI
93
+
94
+ ```bash
95
+ imsg doctor # check Full Disk Access + engine
96
+ imsg chats # recent conversations + their ids
97
+ imsg contacts # handles (numbers / emails) seen
98
+ imsg read -c +14155551234 # recent messages with a contact
99
+ imsg read --chat 42 --limit 100 # a specific conversation
100
+ imsg search "dinner" # search message text
101
+ imsg send +14155551234 "on my way"
102
+ ```
103
+
104
+ ## MCP server
105
+
106
+ The `imsg-mcp` entry point speaks MCP over stdio. Add it to any client:
107
+
108
+ **Claude Code**
109
+ ```bash
110
+ claude mcp add --transport stdio --scope user imsg -- imsg-mcp
111
+ ```
112
+
113
+ **Claude Desktop / Cursor** (`mcpServers`) · **VS Code** (`servers`):
114
+ ```json
115
+ { "mcpServers": { "imsg": { "command": "imsg-mcp" } } }
116
+ ```
117
+
118
+ Tools exposed: `check_access`, `get_recent_messages`, `search_messages`,
119
+ `list_chats`, `list_contacts`, `send_message` (the only one with a side effect).
120
+
121
+ ## How it works
122
+
123
+ There is no official iMessage API. Every tool in this space does the same two
124
+ local things; `imsg` just does the heavy half in Rust:
125
+
126
+ | Operation | Mechanism | Engine |
127
+ |---|---|---|
128
+ | read / search / list | `chat.db` (SQLite, read-only) + typedstream decode | **Rust** (`imsgcore`), Python fallback |
129
+ | send | AppleScript → Messages.app | Python (`osascript`) |
130
+
131
+ SQLite is the same C library everywhere, so the read speedup comes from doing
132
+ the per-message `attributedBody` decode and row marshalling natively instead of
133
+ in a Python loop. See [`bench/benchmark.py`](bench/benchmark.py) for the
134
+ methodology — both engines run identical queries over an identical synthetic
135
+ database, and the pure-Python column is the same algorithm Python-only servers
136
+ use.
137
+
138
+ ## Privacy & security
139
+
140
+ - All database connections are opened **read-only** (`mode=ro`).
141
+ - Nothing is uploaded, mirrored, or indexed off-device.
142
+ - Sending is isolated in one function, escapes its AppleScript inputs, and is
143
+ the only operation that writes anything anywhere.
144
+ - Full Disk Access is broad — grant it only to apps you trust.
145
+
146
+ ## Development
147
+
148
+ ```bash
149
+ git clone https://github.com/ml-lubich/imsg.git && cd imsg
150
+ uv venv && source .venv/bin/activate
151
+ uv pip install maturin
152
+ maturin develop # builds the Rust core + installs the package
153
+ uv pip install -e ".[dev]"
154
+ pytest # tests run on the pure-Python path (and Rust if built)
155
+ python bench/benchmark.py # regenerate the benchmark
156
+ ```
157
+
158
+ ## License
159
+
160
+ MIT © ml-lubich. Not affiliated with Apple. Use responsibly and only with
161
+ accounts and conversations you own.
162
+
@@ -0,0 +1,137 @@
1
+ # imsg — fast, local iMessage for your terminal & MCP
2
+
3
+ [![PyPI](https://img.shields.io/pypi/v/imsg.svg)](https://pypi.org/project/imsg/)
4
+ [![Python](https://img.shields.io/pypi/pyversions/imsg.svg)](https://pypi.org/project/imsg/)
5
+ [![Built with Rust](https://img.shields.io/badge/core-Rust-orange.svg)](rust/)
6
+ [![License: MIT](https://img.shields.io/badge/license-MIT-blue.svg)](LICENSE)
7
+ [![macOS only](https://img.shields.io/badge/os-macOS-black.svg)](#requirements)
8
+
9
+ Read, search, and send iMessage from your terminal — or expose it to Claude,
10
+ Cursor, VS Code, or any MCP client. Everything runs **locally on your Mac**:
11
+ no cloud, no login, no account. Reads open `~/Library/Messages/chat.db`
12
+ **read-only**; sends go through Messages.app.
13
+
14
+ **Why another one?** The hot path — decoding tens of thousands of
15
+ `attributedBody` typedstream blobs — is written in **Rust** (PyO3 + rusqlite),
16
+ so reads and searches over a large history are several times faster than the
17
+ pure-Python equivalent that other Messages servers use. Pure Python still ships
18
+ as a zero-dependency fallback, so it works even where the wheel doesn't.
19
+
20
+ <!-- BENCH -->
21
+ ### Benchmark
22
+
23
+ Full-history search over a synthetic **50,000-message** database (70% stored as
24
+ `attributedBody` blobs), best-of-5, Apple Silicon (16 cores):
25
+
26
+ | operation | pure-Python | Rust core | speedup |
27
+ |---|---:|---:|---:|
28
+ | **full-history search** (decodes every message) | 120.9 ms | **35.7 ms** | **3.4×** |
29
+ | batched blob decode (allocation-bound) | 19.1 ms | 19.6 ms | 1.0× |
30
+
31
+ Two honest caveats, stated up front:
32
+ - Raw blob decoding is ~a wash — it's bound by allocating result strings, not
33
+ CPU, so native code doesn't help. The win is in **search**, where Rust
34
+ decodes and filters across all cores and returns only the matches.
35
+ - It's 3.4×, not 16×, because the SQLite read and result marshalling are serial
36
+ on both sides; only the decode+match parallelizes. The gap widens on larger
37
+ histories.
38
+
39
+ Reproduce: `python bench/benchmark.py`. **Bonus:** this search also finds
40
+ messages whose text lives in an `attributedBody` blob — which a plain
41
+ `text LIKE` query (used by Python-only Messages servers) silently misses.
42
+ <!-- /BENCH -->
43
+
44
+ ## Install
45
+
46
+ ```bash
47
+ # with uv (recommended) — provisions Python + the prebuilt wheel
48
+ uv tool install imsg
49
+
50
+ # or pip
51
+ pip install imsg
52
+
53
+ # or Homebrew
54
+ brew install ml-lubich/tap/imsg
55
+ ```
56
+
57
+ ## Requirements
58
+
59
+ - **macOS** (reads the local Messages database; sends via Messages.app).
60
+ - **Full Disk Access** for the app that runs `imsg` — Terminal/iTerm/Ghostty
61
+ for the CLI, or your MCP client (Claude Desktop, Cursor, VS Code…) for the
62
+ server. System Settings → Privacy & Security → Full Disk Access → add it,
63
+ then fully quit and reopen it.
64
+ - Messages.app signed in and able to send a normal message.
65
+
66
+ Run `imsg doctor` to check access and see which engine (Rust or Python) is live.
67
+
68
+ ## CLI
69
+
70
+ ```bash
71
+ imsg doctor # check Full Disk Access + engine
72
+ imsg chats # recent conversations + their ids
73
+ imsg contacts # handles (numbers / emails) seen
74
+ imsg read -c +14155551234 # recent messages with a contact
75
+ imsg read --chat 42 --limit 100 # a specific conversation
76
+ imsg search "dinner" # search message text
77
+ imsg send +14155551234 "on my way"
78
+ ```
79
+
80
+ ## MCP server
81
+
82
+ The `imsg-mcp` entry point speaks MCP over stdio. Add it to any client:
83
+
84
+ **Claude Code**
85
+ ```bash
86
+ claude mcp add --transport stdio --scope user imsg -- imsg-mcp
87
+ ```
88
+
89
+ **Claude Desktop / Cursor** (`mcpServers`) · **VS Code** (`servers`):
90
+ ```json
91
+ { "mcpServers": { "imsg": { "command": "imsg-mcp" } } }
92
+ ```
93
+
94
+ Tools exposed: `check_access`, `get_recent_messages`, `search_messages`,
95
+ `list_chats`, `list_contacts`, `send_message` (the only one with a side effect).
96
+
97
+ ## How it works
98
+
99
+ There is no official iMessage API. Every tool in this space does the same two
100
+ local things; `imsg` just does the heavy half in Rust:
101
+
102
+ | Operation | Mechanism | Engine |
103
+ |---|---|---|
104
+ | read / search / list | `chat.db` (SQLite, read-only) + typedstream decode | **Rust** (`imsgcore`), Python fallback |
105
+ | send | AppleScript → Messages.app | Python (`osascript`) |
106
+
107
+ SQLite is the same C library everywhere, so the read speedup comes from doing
108
+ the per-message `attributedBody` decode and row marshalling natively instead of
109
+ in a Python loop. See [`bench/benchmark.py`](bench/benchmark.py) for the
110
+ methodology — both engines run identical queries over an identical synthetic
111
+ database, and the pure-Python column is the same algorithm Python-only servers
112
+ use.
113
+
114
+ ## Privacy & security
115
+
116
+ - All database connections are opened **read-only** (`mode=ro`).
117
+ - Nothing is uploaded, mirrored, or indexed off-device.
118
+ - Sending is isolated in one function, escapes its AppleScript inputs, and is
119
+ the only operation that writes anything anywhere.
120
+ - Full Disk Access is broad — grant it only to apps you trust.
121
+
122
+ ## Development
123
+
124
+ ```bash
125
+ git clone https://github.com/ml-lubich/imsg.git && cd imsg
126
+ uv venv && source .venv/bin/activate
127
+ uv pip install maturin
128
+ maturin develop # builds the Rust core + installs the package
129
+ uv pip install -e ".[dev]"
130
+ pytest # tests run on the pure-Python path (and Rust if built)
131
+ python bench/benchmark.py # regenerate the benchmark
132
+ ```
133
+
134
+ ## License
135
+
136
+ MIT © ml-lubich. Not affiliated with Apple. Use responsibly and only with
137
+ accounts and conversations you own.
@@ -0,0 +1,13 @@
1
+ """imsg: fast, local iMessage access (Rust-accelerated) with CLI + MCP server."""
2
+
3
+ __version__ = "0.1.0"
4
+
5
+ # True when the compiled Rust core (imsgcore) is importable; the pure-Python
6
+ # path in imessage.py is used otherwise. Exposed so `imsg doctor` / benchmarks
7
+ # can report which engine is active.
8
+ try: # pragma: no cover - presence depends on build
9
+ import imsgcore # noqa: F401
10
+
11
+ HAVE_RUST = True
12
+ except ImportError: # pragma: no cover
13
+ HAVE_RUST = False
@@ -0,0 +1,358 @@
1
+ """Core iMessage access: read the local chat.db (read-only) and send via osascript.
2
+
3
+ No bridge or daemon is needed. Unlike WhatsApp (remote data behind a Go
4
+ bridge), iMessage data already lives locally in ~/Library/Messages/chat.db and
5
+ Messages.app can be driven with AppleScript. The CLI and the MCP server both
6
+ call the functions here directly.
7
+
8
+ Requirements at runtime:
9
+ - Full Disk Access for the process reading chat.db (System Settings ->
10
+ Privacy & Security -> Full Disk Access -> add your terminal / the app).
11
+ - Automation permission for Messages.app (granted on first send).
12
+ """
13
+ from __future__ import annotations
14
+
15
+ import os
16
+ import subprocess
17
+ from dataclasses import asdict, dataclass
18
+ from datetime import datetime, timezone
19
+ from pathlib import Path
20
+ from typing import Any, Iterable
21
+
22
+ # Apple's Core Data epoch: 2001-01-01 UTC, in unix seconds.
23
+ _APPLE_EPOCH = 978307200
24
+
25
+ DEFAULT_DB = Path.home() / "Library" / "Messages" / "chat.db"
26
+
27
+ try: # Rust-accelerated core; falls back to pure Python when absent.
28
+ import imsgcore as _RUST
29
+ except ImportError: # pragma: no cover - depends on build
30
+ _RUST = None
31
+
32
+
33
+ class AccessError(RuntimeError):
34
+ """chat.db exists but cannot be opened (missing Full Disk Access)."""
35
+
36
+
37
+ def db_path() -> Path:
38
+ """The chat.db path, overridable via IMESSAGE_DB (handy for tests)."""
39
+ return Path(os.environ.get("IMESSAGE_DB", str(DEFAULT_DB)))
40
+
41
+
42
+ def _connect(path: Path | None = None):
43
+ import sqlite3
44
+
45
+ p = path or db_path()
46
+ if not p.exists():
47
+ raise AccessError(f"chat.db not found at {p}")
48
+ try:
49
+ return sqlite3.connect(f"file:{p}?mode=ro", uri=True)
50
+ except sqlite3.OperationalError as exc: # pragma: no cover - env specific
51
+ raise AccessError(
52
+ f"cannot open {p}: {exc}. Grant Full Disk Access to this terminal "
53
+ "in System Settings -> Privacy & Security -> Full Disk Access."
54
+ ) from exc
55
+
56
+
57
+ def _via_rust(thunk):
58
+ """Run a Rust core call, translating its RuntimeError (e.g. an unopenable
59
+ chat.db from missing Full Disk Access) into AccessError so the CLI/MCP
60
+ handle it the same way as the pure-Python path."""
61
+ try:
62
+ return thunk()
63
+ except RuntimeError as exc:
64
+ raise AccessError(
65
+ f"cannot open {db_path()}: {exc}. Grant Full Disk Access to this "
66
+ "terminal in System Settings -> Privacy & Security -> Full Disk "
67
+ "Access, then fully quit and reopen it."
68
+ ) from exc
69
+
70
+
71
+ def _apple_time_to_iso(raw: int | None) -> str | None:
72
+ """Convert a chat.db `date` value to an ISO-8601 UTC string.
73
+
74
+ Modern macOS stores nanoseconds since the Apple epoch; older stores
75
+ seconds. Disambiguate by magnitude (ns values are ~1e18).
76
+ """
77
+ if not raw:
78
+ return None
79
+ seconds = raw / 1_000_000_000 if raw > 1_000_000_000_000 else raw
80
+ ts = seconds + _APPLE_EPOCH
81
+ return datetime.fromtimestamp(ts, tz=timezone.utc).isoformat()
82
+
83
+
84
+ def _decode_attributed_body(blob: bytes | None) -> str | None:
85
+ """Best-effort text extraction from a message's attributedBody blob.
86
+
87
+ Newer messages leave `text` NULL and stash the body in a typedstream
88
+ archive. This scans for the NSString payload and reads its length-prefixed
89
+ UTF-8 content — the widely-used heuristic.
90
+
91
+ ponytail: byte-scan heuristic, not a full typedstream parser. Handles the
92
+ common single-run case; upgrade to `pip install pytypedstream` if rich
93
+ multi-run attributed strings start losing text.
94
+ """
95
+ if not blob or b"NSString" not in blob:
96
+ return None
97
+ data = blob.split(b"NSString", 1)[1]
98
+ # Skip the class-version bytes that precede the length marker.
99
+ data = data[5:]
100
+ if not data:
101
+ return None
102
+ marker = data[0]
103
+ if marker == 0x81: # 2-byte little-endian length
104
+ if len(data) < 3:
105
+ return None
106
+ length = int.from_bytes(data[1:3], "little")
107
+ start = 3
108
+ else: # single-byte length
109
+ length = marker
110
+ start = 1
111
+ text = data[start : start + length].decode("utf-8", errors="replace")
112
+ return text or None
113
+
114
+
115
+ @dataclass
116
+ class Chat:
117
+ chat_id: int
118
+ identifier: str
119
+ name: str | None
120
+ service: str | None
121
+
122
+ def dict(self) -> dict[str, Any]:
123
+ return asdict(self)
124
+
125
+
126
+ @dataclass
127
+ class Message:
128
+ text: str | None
129
+ sender: str # "me" or a handle id (phone/email)
130
+ is_from_me: bool
131
+ date: str | None
132
+ service: str | None
133
+ has_attachment: bool
134
+
135
+ def dict(self) -> dict[str, Any]:
136
+ return asdict(self)
137
+
138
+
139
+ @dataclass
140
+ class Contact:
141
+ handle: str # phone number or email
142
+ service: str | None
143
+
144
+ def dict(self) -> dict[str, Any]:
145
+ return asdict(self)
146
+
147
+
148
+ def _rows(cur) -> Iterable[tuple]:
149
+ while True:
150
+ row = cur.fetchone()
151
+ if row is None:
152
+ return
153
+ yield row
154
+
155
+
156
+ def list_chats(limit: int = 20, path: Path | None = None) -> list[Chat]:
157
+ """Recent conversations, most-recently-active first."""
158
+ if _RUST is not None:
159
+ rows = _via_rust(lambda: _RUST.list_chats(str(path or db_path()), limit))
160
+ return [Chat(*r) for r in rows]
161
+ conn = _connect(path)
162
+ try:
163
+ cur = conn.execute(
164
+ """
165
+ SELECT c.ROWID, c.chat_identifier, c.display_name, c.service_name,
166
+ MAX(m.date) AS last
167
+ FROM chat c
168
+ JOIN chat_message_join cmj ON cmj.chat_id = c.ROWID
169
+ JOIN message m ON m.ROWID = cmj.message_id
170
+ GROUP BY c.ROWID
171
+ ORDER BY last DESC
172
+ LIMIT ?
173
+ """,
174
+ (limit,),
175
+ )
176
+ return [Chat(r[0], r[1], r[2], r[3]) for r in _rows(cur)]
177
+ finally:
178
+ conn.close()
179
+
180
+
181
+ def list_contacts(limit: int = 100, path: Path | None = None) -> list[Contact]:
182
+ """Distinct handles (phone numbers / emails) seen in the message store."""
183
+ if _RUST is not None:
184
+ rows = _via_rust(lambda: _RUST.list_contacts(str(path or db_path()), limit))
185
+ return [Contact(*r) for r in rows]
186
+ conn = _connect(path)
187
+ try:
188
+ cur = conn.execute(
189
+ "SELECT DISTINCT id, service FROM handle ORDER BY id LIMIT ?",
190
+ (limit,),
191
+ )
192
+ return [Contact(r[0], r[1]) for r in _rows(cur)]
193
+ finally:
194
+ conn.close()
195
+
196
+
197
+ def _message_from_rust(t: tuple) -> Message:
198
+ """Wrap a tuple from imsgcore into a Message.
199
+
200
+ Rust returns (text|None, is_from_me, sender, date_raw, service, has_attach)
201
+ with text already resolved (attributedBody decoded in Rust).
202
+ """
203
+ text, is_from_me, sender, date_raw, service, has_attach = t
204
+ return Message(
205
+ text=text,
206
+ sender=sender,
207
+ is_from_me=bool(is_from_me),
208
+ date=_apple_time_to_iso(date_raw),
209
+ service=service,
210
+ has_attachment=bool(has_attach),
211
+ )
212
+
213
+
214
+ def _message_from_row(row: tuple) -> Message:
215
+ text, body, is_from_me, handle, date, service, has_attach = row
216
+ resolved = text or _decode_attributed_body(body)
217
+ return Message(
218
+ text=resolved,
219
+ sender="me" if is_from_me else (handle or "unknown"),
220
+ is_from_me=bool(is_from_me),
221
+ date=_apple_time_to_iso(date),
222
+ service=service,
223
+ has_attachment=bool(has_attach),
224
+ )
225
+
226
+
227
+ _MESSAGE_COLS = (
228
+ "m.text, m.attributedBody, m.is_from_me, h.id, m.date, m.service, "
229
+ "m.cache_has_attachments"
230
+ )
231
+
232
+
233
+ def read_messages(
234
+ contact: str | None = None,
235
+ chat_id: int | None = None,
236
+ limit: int = 20,
237
+ path: Path | None = None,
238
+ ) -> list[Message]:
239
+ """Most recent messages, optionally filtered by contact handle or chat_id.
240
+
241
+ Returned oldest-first (chronological) for readable transcripts.
242
+ """
243
+ if _RUST is not None:
244
+ rows = _via_rust(lambda: _RUST.read_messages(str(path or db_path()), contact, chat_id, limit))
245
+ return list(reversed([_message_from_rust(r) for r in rows]))
246
+ conn = _connect(path)
247
+ try:
248
+ where = []
249
+ params: list[Any] = []
250
+ if chat_id is not None:
251
+ where.append(
252
+ "m.ROWID IN (SELECT message_id FROM chat_message_join WHERE chat_id = ?)"
253
+ )
254
+ params.append(chat_id)
255
+ if contact:
256
+ where.append("h.id = ?")
257
+ params.append(contact)
258
+ clause = ("WHERE " + " AND ".join(where)) if where else ""
259
+ params.append(limit)
260
+ cur = conn.execute(
261
+ f"""
262
+ SELECT {_MESSAGE_COLS}
263
+ FROM message m
264
+ LEFT JOIN handle h ON h.ROWID = m.handle_id
265
+ {clause}
266
+ ORDER BY m.date DESC
267
+ LIMIT ?
268
+ """,
269
+ params,
270
+ )
271
+ msgs = [_message_from_row(r) for r in _rows(cur)]
272
+ return list(reversed(msgs))
273
+ finally:
274
+ conn.close()
275
+
276
+
277
+ def search_messages(query: str, limit: int = 20, path: Path | None = None) -> list[Message]:
278
+ """Full-text-ish LIKE search over message bodies, newest first."""
279
+ if _RUST is not None:
280
+ rows = _via_rust(lambda: _RUST.search_messages(str(path or db_path()), query, limit))
281
+ return [_message_from_rust(r) for r in rows]
282
+ conn = _connect(path)
283
+ try:
284
+ cur = conn.execute(
285
+ f"""
286
+ SELECT {_MESSAGE_COLS}
287
+ FROM message m
288
+ LEFT JOIN handle h ON h.ROWID = m.handle_id
289
+ WHERE m.text LIKE ?
290
+ ORDER BY m.date DESC
291
+ LIMIT ?
292
+ """,
293
+ (f"%{query}%", limit),
294
+ )
295
+ return [_message_from_row(r) for r in _rows(cur)]
296
+ finally:
297
+ conn.close()
298
+
299
+
300
+ def search_all(query: str, limit: int = 100, path: Path | None = None) -> list[Message]:
301
+ """Search the FULL history, including messages stored as attributedBody
302
+ blobs that a plain `text LIKE` query silently misses.
303
+
304
+ Rust decodes and matches every message in parallel across cores; the
305
+ pure-Python fallback below does the same single-threaded (and is the honest
306
+ baseline the benchmark compares against).
307
+ """
308
+ if _RUST is not None:
309
+ rows = _via_rust(lambda: _RUST.search_all(str(path or db_path()), query, limit))
310
+ return [_message_from_rust(r) for r in rows]
311
+
312
+ needle = query.lower()
313
+ conn = _connect(path)
314
+ try:
315
+ cur = conn.execute(
316
+ f"SELECT {_MESSAGE_COLS} FROM message m "
317
+ "LEFT JOIN handle h ON h.ROWID = m.handle_id ORDER BY m.date DESC"
318
+ )
319
+ out: list[Message] = []
320
+ for row in _rows(cur):
321
+ msg = _message_from_row(row)
322
+ if msg.text and needle in msg.text.lower():
323
+ out.append(msg)
324
+ if len(out) >= limit:
325
+ break
326
+ return out
327
+ finally:
328
+ conn.close()
329
+
330
+
331
+ def _osascript(script: str) -> None:
332
+ proc = subprocess.run(
333
+ ["osascript", "-e", script],
334
+ capture_output=True,
335
+ text=True,
336
+ )
337
+ if proc.returncode != 0:
338
+ raise RuntimeError(proc.stderr.strip() or "osascript failed")
339
+
340
+
341
+ def send_message(recipient: str, text: str) -> None:
342
+ """Send an iMessage/SMS to a handle (phone number or email) via Messages.app.
343
+
344
+ Tries the iMessage service first; Messages falls back to SMS for handles
345
+ that aren't on iMessage when the account allows it.
346
+ """
347
+ if not recipient or not text:
348
+ raise ValueError("recipient and text are required")
349
+ safe_text = text.replace("\\", "\\\\").replace('"', '\\"')
350
+ safe_recip = recipient.replace("\\", "\\\\").replace('"', '\\"')
351
+ script = f'''
352
+ tell application "Messages"
353
+ set targetService to 1st account whose service type = iMessage
354
+ set targetBuddy to participant "{safe_recip}" of targetService
355
+ send "{safe_text}" to targetBuddy
356
+ end tell
357
+ '''
358
+ _osascript(script)