
Imessage Query
- 146 installs
- 62 repo stars
- Updated August 3, 2026
- terrylica/cc-skills
Use imessage-query for development tasks
About
imessage-query: A skill for development. This provides functionality for development workflows.
- imessage-query
Imessage Query by the numbers
- 146 all-time installs (skills.sh)
- +1 installs in the week ending Aug 5, 2026 (Skillselion tracking)
- Ranked #2,574 of 4,347 Backend & APIs skills by installs in the Skillselion catalog
- Data as of Aug 5, 2026 (Skillselion catalog sync)
npx skills add https://github.com/terrylica/cc-skills --skill imessage-queryAdd your badge
Show developers this skill is listed on Skillselion. Paste this into your README.
| Installs | 146 |
|---|---|
| repo stars | ★ 62 |
| Last updated | August 3, 2026 |
| Repository | terrylica/cc-skills ↗ |
What it does
Use imessage-query for development tasks
Files
iMessage Database Query
Query the macOS iMessage SQLite database (~/Library/Messages/chat.db) to retrieve conversation history, decode messages stored in binary format, and build sourced timelines with precise timestamps.
Self-Evolving Skill: This skill improves through use. If instructions are wrong, parameters drifted, or a workaround was needed — fix this file immediately, don't defer. Only update for real, reproducible issues.
When to Use
- Retrieving iMessage conversation history for a specific contact
- Building sourced timelines with timestamps from text messages
- Searching for keywords across all conversations
- Debugging messages that appear empty but contain recoverable text
- Extracting message content that iOS stored in binary
attributedBodyformat
Prerequisites
1. macOS only — chat.db is a macOS-specific database 2. Full Disk Access — The terminal running Claude Code must have FDA granted in System Settings > Privacy & Security > Full Disk Access 3. Read-only — Never write to chat.db. Always use read-only SQLite access. 4. Optional: pip install pytypedstream — Enables tier 1 decoder (proper typedstream deserialization). Script works without it (falls through to pure-binary tiers 2/3).
Critical Knowledge - The text vs attributedBody Problem
IMPORTANT: Many iMessage messages have a NULL or empty text column but contain valid, recoverable text in the attributedBody column. This is NOT because they are voice messages — iOS stores dictated messages, messages with rich formatting, and some regular messages in attributedBody as an NSAttributedString binary blob.
How to detect
-- Messages with attributedBody but no text (these are NOT necessarily voice messages)
SELECT COUNT(*) as hidden_messages
FROM message m
JOIN chat_message_join cmj ON m.ROWID = cmj.message_id
JOIN chat c ON cmj.chat_id = c.ROWID
WHERE c.chat_identifier = '<CHAT_IDENTIFIER>'
AND (m.text IS NULL OR length(m.text) = 0)
AND m.attributedBody IS NOT NULL
AND length(m.attributedBody) > 100
AND m.associated_message_type = 0
AND m.cache_has_attachments = 0;How to distinguish message types when text is NULL
cache_has_attachments | attributedBody length | Likely type |
|---|---|---|
| 0 | > 100 bytes | Dictated/rich text — recoverable via decode script |
| 1 | any | Attachment (image, file, voice memo) — text may be in attributedBody too |
| 0 | < 50 bytes | Tapback reaction or system message — usually noise |
How to decode
Use the bundled decode script for reliable extraction (v4 — 3-tier decoder + native pitfall protections):
python3 <skill-path>/scripts/decode_attributed_body.py --chat "<CHAT_IDENTIFIER>" --limit 50The decoder uses a 3-tier strategy:
1. Tier 1: pytypedstream Unarchiver — proper Apple typedstream deserialization (requires pip install pytypedstream) 2. Tier 2: Multi-format binary — 0x2B/0x4F/0x49 length-prefix parsing (zero deps, ported from macos-messages) 3. Tier 3: NSString marker + length-prefix — v2 legacy approach (zero deps, last resort)
Falls through tiers on failure. Works without pytypedstream installed (skips tier 1). See Cross-Repo Analysis for decoder comparison.
Date Formula
iMessage stores dates as nanoseconds since Apple epoch (2001-01-01 00:00:00 UTC).
datetime(m.date/1000000000 + 978307200, 'unixepoch', 'localtime') as timestampm.date / 1000000000— Convert nanoseconds to seconds+ 978307200— Add offset from Unix epoch (1970) to Apple epoch (2001)'unixepoch'— Tell SQLite this is a Unix timestamp'localtime'— Convert to local timezone (CRITICAL — omitting this gives UTC)
Quick Start Queries
1. List all conversations
sqlite3 ~/Library/Messages/chat.db \
"SELECT c.chat_identifier, c.display_name, COUNT(cmj.message_id) as msg_count
FROM chat c
JOIN chat_message_join cmj ON c.ROWID = cmj.chat_id
GROUP BY c.ROWID
ORDER BY msg_count DESC
LIMIT 20"2. Get conversation thread (text column only)
sqlite3 ~/Library/Messages/chat.db \
"SELECT datetime(m.date/1000000000 + 978307200, 'unixepoch', 'localtime') as ts,
CASE WHEN m.is_from_me = 1 THEN 'Me' ELSE 'Them' END as sender,
m.text
FROM message m
JOIN chat_message_join cmj ON m.ROWID = cmj.message_id
JOIN chat c ON cmj.chat_id = c.ROWID
WHERE c.chat_identifier = '<CHAT_IDENTIFIER>'
AND length(m.text) > 0
AND m.associated_message_type = 0
ORDER BY m.date DESC
LIMIT 50"3. Get ALL messages including attributedBody (use decode script)
python3 <skill-path>/scripts/decode_attributed_body.py \
--chat "<CHAT_IDENTIFIER>" \
--after "2026-01-01" \
--limit 100Filtering Noise
Tapback reactions
Tapback reactions (likes, loves, emphasis, etc.) are stored as separate message rows with associated_message_type != 0. Always filter:
AND m.associated_message_type = 0Shell escaping in zsh
The != operator can cause issues in zsh. Use positive assertions instead:
-- BAD (breaks in zsh)
AND m.text != ''
-- GOOD (works everywhere)
AND length(m.text) > 0Using the Decode Script
The bundled decode_attributed_body.py handles all edge cases:
# Basic usage - get last 50 messages from a contact
python3 <skill-path>/scripts/decode_attributed_body.py --chat "+1234567890" --limit 50
# Search for keyword
python3 <skill-path>/scripts/decode_attributed_body.py --chat "+1234567890" --search "meeting"
# Search with surrounding context (3 messages before and after each match)
python3 <skill-path>/scripts/decode_attributed_body.py --chat "+1234567890" --search "meeting" --context 3
# Date range
python3 <skill-path>/scripts/decode_attributed_body.py --chat "+1234567890" --after "2026-01-01" --before "2026-02-01"
# Only messages from the other party
python3 <skill-path>/scripts/decode_attributed_body.py --chat "+1234567890" --sender them
# Only messages from me
python3 <skill-path>/scripts/decode_attributed_body.py --chat "+1234567890" --sender me
# Export conversation to NDJSON for offline analysis
python3 <skill-path>/scripts/decode_attributed_body.py --chat "+1234567890" --after "2026-02-01" --export thread.jsonlOutput format: timestamp|sender|text (pipe-delimited, one message per line)
Context Search (--context N)
When --search is combined with --context N, the script shows N messages before and after each match:
- Matches are prefixed with
[match] - Non-contiguous context groups are separated by
--- context --- - Overlapping context windows are deduplicated
NDJSON Export (--export)
Exports messages to a NDJSON (.jsonl) file for offline analysis:
{
"ts": "2026-02-13 18:30:17",
"sender": "them",
"is_from_me": false,
"text": "Message text here",
"decoded": true,
"type": "text",
"edited": true,
"service": "SMS",
"effect": "slam",
"reply_to": {
"ts": "2026-02-13 18:00:00",
"sender": "me",
"text": "Original message..."
}
}Fields edited, service, effect, reply_to are optional — only present when applicable. The type field is always present ("text", "audio", or "attachment").
Retracted messages are NEVER exported — they are deterministically excluded (see Native Protections below).
Export-first workflow (recommended for multi-query analysis):
# Step 1: Export once
python3 <skill-path>/scripts/decode_attributed_body.py --chat "+1234567890" \
--after "2026-02-01" --export thread.jsonl
# Step 2: Analyze many times without re-querying SQLite
grep -i "keyword" thread.jsonl
jq 'select(.text | test("reference"; "i"))' thread.jsonl
jq 'select(.sender == "them")' thread.jsonlNative Protections (v4)
The decode script natively handles these pitfalls — no manual SQL workarounds needed:
| Protection | Column Used | Behavior |
|---|---|---|
| Retracted messages (Undo Send) | date_retracted, date_edited | Excluded from output — content wiped by iOS, not admissible |
| Edited messages | date_edited | Flagged with [edited] / "edited": true |
| Audio/voice messages | is_audio_message | Identified as [audio message] — not misclassified as empty |
| Inline quotes (swipe-to-reply) | thread_originator_guid | Resolved to quoted message text via GUID index |
| Attachments without text | cache_has_attachments, attachment table | Surfaced as [attachment: filename] instead of silently dropped |
| Message effects | expressive_send_style_id | Decoded to human-readable names (slam, loud, gentle, invisible_ink) |
| Service type | service | Flagged when SMS instead of iMessage |
| Tapback reactions | associated_message_type | Filtered (only = 0 included) |
Anti-Patterns to Avoid
1. Searching multiple chat identifiers blindly — Always run --stats first to confirm the right chat identifier has messages in the expected date range 2. Keyword search without context — Always use --context 5 (or more) with --search to understand conversational meaning around matches 3. Repeated narrow-window SQLite queries — Export the full date range to NDJSON first, then grep/jq the file for all subsequent analysis
Note: Replace <skill-path> with the actual installed skill path. To find it:
find ~/.claude -path "*/imessage-query/scripts/decode_attributed_body.py" 2>/dev/nullReference Documentation
- Schema Reference — Tables, columns, relationships
- Query Patterns — Reusable SQL templates for common operations
- Known Pitfalls — Every gotcha discovered and how to handle it
- Cross-Repo Analysis — Comparison of 5 OSS decoder implementations and what we adopted
---
TodoWrite Task Templates
Template A - Retrieve Conversation Thread
1. Identify chat_identifier for the contact (phone number or email)
2. Run decode script with --chat and appropriate date range
3. Review output for attributedBody-decoded messages (marked with [decoded])
4. If searching for specific topic, add --search flag
5. Format results as needed for the taskTemplate B - Debug Empty Messages
1. Query messages where text IS NULL but attributedBody IS NOT NULL
2. Check cache_has_attachments to distinguish voice/file from dictated text
3. Run decode script to extract hidden text content
4. Verify decoded content makes sense in conversation context
5. Document any new decode patterns in known-pitfalls.mdTemplate C - Build Sourced Timeline
1. Identify all relevant chat_identifiers
2. Run decode script for each contact with date range
3. Merge and sort by timestamp
4. Format as sourced quotes with timestamps for documentation
5. Verify no messages were missed (compare total count vs decoded count)Template D - Export-First Deep Analysis
1. Run --stats to confirm chat_identifier and date range
2. Export full date range to NDJSON: --export thread.jsonl
3. Use grep/jq on the NDJSON file for all keyword searches
4. Use --search with --context 5 for contextual understanding of specific matches
5. All subsequent analysis reads from the NDJSON file (no more SQLite queries)---
Post-Change Checklist
After modifying this skill:
1. [ ] YAML frontmatter valid (name, description with triggers) 2. [ ] No private data (phone numbers, names, emails) in any file 3. [ ] All SQL uses parameterized placeholders 4. [ ] Decode script works with python3 (pytypedstream optional, tiers 2/3 are stdlib-only) 5. [ ] All reference links are relative paths 6. [ ] Append changes to evolution-log.md
Post-Execution Reflection
After this skill completes, check before closing:
1. Did the command succeed? — If not, fix the instruction or error table that caused the failure. 2. Did parameters or output change? — If the underlying tool's interface drifted, update Usage examples and Parameters table to match. 3. Was a workaround needed? — If you had to improvise (different flags, extra steps), update this SKILL.md so the next invocation doesn't need the same workaround.
Only update if the issue is real and reproducible — not speculative.
Skill: iMessage Query
Cross-Repository Analysis: iMessage Decoder Implementations
Comparative analysis of 5 open-source iMessage attributedBody decoders, conducted 2026-02-14. Used to inform the v3 decoder upgrade for this skill.
---
Repositories Studied
All repositories forked to ~/fork-tools/ under terrylica for analysis.
1. imessage-exporter (Rust)
| Field | Value |
|---|---|
| Repo | ReagentX/imessage-exporter |
| Language | Rust |
| Stars | ~2.5k |
| Decoder | crabstep crate (proper typedstream deserialization) + legacy fallback |
| Tests | 70+ unit tests with real binary fixtures |
| Status | Actively maintained, most comprehensive tool |
Decoder approach: Uses the crabstep crate for native Apple typedstream deserialization. Has a legacy regex-based fallback for older message formats. The Rust type system provides strong guarantees on binary parsing correctness.
What we learned: Confirmed that proper typedstream deserialization is the gold standard approach. The dual-strategy pattern (proper parser + legacy fallback) influenced our 3-tier design.
What we adopted: The philosophy of having a proper deserializer as tier 1 with fallbacks for edge cases.
What we did NOT adopt: The Rust implementation itself (wrong language for our Python skill). The crabstep crate is Rust-only with no Python bindings.
---
2. macos-messages (Python)
| Field | Value |
|---|---|
| Repo | bettercallsean/macos-messages |
| Language | Python |
| Stars | ~50 |
| Decoder | Multi-format binary length-prefix parsing (4 format variants) |
| Tests | Comprehensive pytest suite with parametrized binary fixtures |
| Status | Active, AI-analysis focused |
Decoder approach: The most thorough pure-binary parser found in any Python repo. The _extract_text_from_attributed_body() function in src/messages/db.py handles 4 distinct binary encoding formats:
1. 0x2B (+) marker — Variable-length encoding:
< 0x80: 1-byte length (direct)0x81: 2-byte little-endian length0x82: 3-byte little-endian length0x83: 4-byte little-endian length
2. 0x4F marker — Extended encoding with size markers:
0x10: 1-byte length0x11: 2-byte big-endian length0x12: 4-byte big-endian length
3. 0x49 (I) marker — Legacy 4-byte big-endian length 4. Heuristic fallback — Regex for readable text sequences after NSString marker
What we adopted: The entire multi-format binary decoder was ported as tier 2 in our v3 decoder (_decode_via_multiformat()). This provides zero-dependency decoding for messages that pytypedstream can't handle, covering format variants we'd never encountered in our own testing.
What we did NOT adopt: Their overall architecture (AI-first analysis pipeline, conversation grouping, sentiment analysis). Out of scope for a decode-focused skill.
---
3. imessage-conversation-analyzer (Python)
| Field | Value |
|---|---|
| Repo | my-other-github-account/imessage-conversation-analyzer |
| Language | Python |
| Stars | ~30 |
| Decoder | pytypedstream (Unarchiver) — proper Apple typedstream deserialization |
| Tests | 7 analysis modules (word clouds, sentiment, response times, etc.) |
| Status | Maintained, analytics-focused |
Decoder approach: Uses the pytypedstream package for proper Apple typedstream binary deserialization. The decode function at ica/core.py:111-124 is only 6 lines:
from typedstream import Unarchiver
def decode_message_attributedbody(blob):
result = Unarchiver.from_data(blob).decode_all()
# Navigate result → GenericArchivedObject → contents → NSMutableString.valueThis is the most reliable approach because it uses the actual Apple binary format specification rather than pattern matching on byte sequences.
What we adopted: The pytypedstream dependency and Unarchiver.from_data().decode_all() approach as tier 1 in our v3 decoder (_decode_via_typedstream()). We expanded the text extraction to handle more object graph variations (both val.value for NSMutableString wrappers and direct str values).
What we did NOT adopt: Their analytics pipeline (pandas, DuckDB, word clouds, sentiment analysis). Different purpose than our decode-and-search skill. Also did not adopt their TypedStreamReader — that class doesn't exist in the current pytypedstream API; the correct entry point is Unarchiver.
API discovery note: The pip package is pytypedstream but it installs as the typedstream module. The Unarchiver class is the correct entry point, not TypedStreamReader (which appears in some older documentation but doesn't exist).
---
4. imessage_tools (Python)
| Field | Value |
|---|---|
| Repo | janfreyberg/imessage_tools |
| Language | Python |
| Stars | ~10 |
| Decoder | Hardcoded byte-offset slice [6:-12] |
| Tests | None |
| Status | Abandoned (~2020), fragile |
Decoder approach: content[6:-12].decode("utf-8") — a fixed-offset slice that works only for messages of a specific length range. No length-prefix parsing, no format detection, no error handling.
Why we skipped this: The [6:-12] slice is brittle — it silently truncates or corrupts messages of unusual lengths. Bare except: clauses mask errors. No tests, no maintenance. This is the anti-pattern our v1 decoder was already better than.
What we learned: Confirmed that hardcoded byte offsets are the worst possible approach to typedstream decoding. Any format variation (longer messages, emoji, different iOS versions) breaks silently.
---
5. pymessage-lite (Python)
| Field | Value |
|---|---|
| Repo | mattmajestic/pymessage-lite |
| Language | Python |
| Stars | ~5 |
| Decoder | None — text column only |
| Tests | None |
| Status | 74 lines, Python 2 era code, SQL injection vulnerable |
Why we skipped this: No attributedBody decoding at all. Uses string formatting for SQL queries (injection risk). Python 2 style code. Nothing to learn from this repo beyond confirming what not to do.
---
Capability Comparison Matrix
| Capability | Our Skill (v3) | imessage-exporter | macos-messages | imessage-conv-analyzer | imessage_tools | pymessage-lite |
|---|---|---|---|---|---|---|
| attributedBody decode | 3-tier | crabstep + legacy | 4-format binary | pytypedstream | [6:-12] slice | None |
| Short message decode | Yes | Yes | Yes | Yes | Fragile | N/A |
| Emoji support | Yes | Yes | Yes | Yes | Partial | N/A |
| Keyword search | --search | Full-text export | N/A | N/A | N/A | N/A |
| Context windows | --context N | N/A | N/A | N/A | N/A | N/A |
| NDJSON export | --export | HTML/TXT export | N/A | N/A | N/A | N/A |
| Stats mode | --stats | Full statistics | Summary stats | 7 analysis types | N/A | N/A |
| Date filtering | --after/--before | Full range | Full range | Full range | N/A | N/A |
| Sender filtering | --sender | Per-contact | Per-contact | Per-contact | N/A | N/A |
| Group chat support | Via chat_identifier | Full | Full | Full | Partial | Basic |
| Attachment handling | Metadata only | Full (images, etc.) | Metadata | Metadata | Basic | None |
| External deps | pytypedstream | crabstep (Rust) | None | pytypedstream, pandas, DuckDB | None | None |
| SQL injection safe | Yes (parameterized) | N/A (Rust) | Yes | Yes | No | No |
| Zero-config | Yes | Yes (binary) | Needs pip install | Needs pip install | Needs pip install | Needs pip install |
---
Selection Criteria
What we optimized for
1. Decode reliability — Must decode all messages including short ones, emoji, and rich formatting 2. Graceful degradation — Must work even if pytypedstream is not installed (falls through to pure-binary tiers) 3. Zero-config for basic use — Tiers 2 and 3 are stdlib-only, tier 1 requires one pip install 4. Search-first workflow — --search with --context is unique to our skill and critical for investigation work 5. Minimal scope — We're a decode-and-search tool, not an analytics platform
What we adopted and why
| Source | What | Why |
|---|---|---|
| imessage-conversation-analyzer | pytypedstream Unarchiver as tier 1 | Proper typedstream deserialization — handles all message formats correctly |
| macos-messages | Multi-format binary parser as tier 2 | Most thorough pure-Python binary parser found — covers 4 format variants we hadn't encountered |
| imessage-exporter | Tiered fallback philosophy | Confirmed that "proper parser + fallbacks" is the right architecture |
What we kept from our own implementation
| Feature | Why |
|---|---|
| NSString marker + length-prefix (tier 3) | Simplest last-resort decoder for unusual blob formats |
--search with --context N | Unique to our skill — no other tool does contextual keyword search |
--export NDJSON | Enables export-once-analyze-many workflow |
--stats mode | Quick conversation profiling before deep analysis |
| Pipe-delimited stdout format | Machine-readable, grep-friendly, zero overhead |
What we did NOT adopt and why
| Source | What | Why skipped |
|---|---|---|
| imessage-exporter | Rust crate (crabstep) | Wrong language — no Python bindings available |
| imessage-exporter | Full export pipeline (HTML, TXT) | Out of scope — we're a search tool, not an archiver |
| macos-messages | AI analysis pipeline | Out of scope — sentiment analysis, conversation grouping not needed |
| imessage-conversation-analyzer | pandas/DuckDB analytics | Out of scope — word clouds, response time analysis not needed |
| imessage-conversation-analyzer | TypedStreamReader API | Doesn't exist — Unarchiver is the correct API |
| imessage_tools | [6:-12] byte slice | Fragile, silently corrupts messages of unusual lengths |
| pymessage-lite | Anything | SQL injection, no decode, Python 2 era, nothing useful |
---
Complementary Tool Recommendations
These tools serve different purposes and can be used alongside our skill:
| Tool | Use case | When to reach for it |
|---|---|---|
| imessage-exporter | Full conversation archival | When you need complete HTML/TXT exports of entire message history including images, videos, and attachments |
| macos-messages | AI-powered conversation analysis | When you need sentiment analysis, conversation grouping, or topic extraction beyond keyword search |
| imessage-conversation-analyzer | Statistical analysis | When you need word clouds, response time distribution, or message frequency analytics |
Our skill remains the best choice for:
- Quick keyword searches with conversational context (
--search+--context) - Building sourced timelines with precise timestamps
- Export-once-analyze-many workflows with NDJSON
- Claude Code integration (pipe-delimited output, stderr summaries)
---
Technical Notes
pytypedstream API
# Correct usage (verified 2026-02-14):
from typedstream import Unarchiver
result = Unarchiver.from_data(blob).decode_all()
# Returns: list[TypedValue]
# Navigate: TypedValue.value → GenericArchivedObject.contents → NSMutableString.value → strGotchas:
- Package name:
pip install pytypedstream(PyPI) - Module name:
import typedstream(NOTpytypedstream) - Entry point:
Unarchiver(NOTTypedStreamReader) - Method:
.decode_all()returns a list (Unarchiver itself is NOT iterable)
Multi-format binary encoding (from macos-messages)
The attributedBody binary format uses different length-encoding schemes depending on iOS version and message content:
0x2B (+) marker:
byte < 0x80 → 1-byte length (direct value)
byte == 0x81 → next 2 bytes = length (little-endian)
byte == 0x82 → next 3 bytes = length (little-endian)
byte == 0x83 → next 4 bytes = length (little-endian)
0x4F marker:
0x10 → next 1 byte = length
0x11 → next 2 bytes = length (big-endian)
0x12 → next 4 bytes = length (big-endian)
0x49 (I) marker:
next 4 bytes = length (big-endian)All extraction follows the pattern: split on NSString, split on NSDictionary, find marker, read length, extract UTF-8 text.
Skill: iMessage Query
Evolution Log
Reverse chronological record of changes to this skill.
---
2026-02-15 — v4 Native Pitfall Protections + Full Metadata Extraction
Context: After correcting the "voice message" misconception (actually retracted messages — pitfall #14) and discovering that thread_originator_guid provides inline quote context for 291 messages in the Phoebe chat, we upgraded the script to natively handle every known pitfall deterministically rather than relying on contextual documentation alone. Cross-referenced all 5 forked OSS repos and the full 92-column message schema to identify missing attributes.
Changes:
1. Retracted message exclusion (pitfall #14) — Messages with date_retracted > 0 are deterministically excluded from both stdout and NDJSON export. Also handles older iOS pattern where Undo Send set date_edited instead of date_retracted (detected when date_edited > 0 + empty text/attributedBody).
2. Edited message flagging — Messages with date_edited > 0 that still have content are included but flagged with [edited] in stdout and "edited": true in NDJSON.
3. Audio message identification (pitfall #9 correction) — Uses is_audio_message column (definitive) instead of heuristic guessing from NULL text + attachments. Audio messages emit [audio message] placeholder instead of being silently dropped.
4. Inline quote resolution — Builds GUID→message index via _build_guid_index(). Messages with thread_originator_guid get their reply_to field populated with the quoted message's {ts, sender, text}. Shown as [replying to sender: "quoted text"] in stdout and "reply_to": {...} in NDJSON.
5. Attachment surfacing — Messages with no text but cache_has_attachments = 1 now emit [attachment: filename] or [attachment: mime_type] instead of being silently dropped. LEFT JOINs to attachment table via message_attachment_join.
6. Message effects — expressive_send_style_id decoded to human-readable names (slam, loud, gentle, invisible_ink). Shown as [slam] in stdout and "effect": "slam" in NDJSON.
7. Service type — service column distinguishes iMessage from SMS. Non-iMessage messages flagged with [SMS] in stdout and "service": "SMS" in NDJSON.
8. Enhanced stats — --stats now shows retracted, edited, audio, threaded replies, SMS counts, and adjusted coverage (excluding retracted).
9. Row deduplication — LEFT JOIN to attachment table can produce duplicate rows for multi-attachment messages. Dedup key (ts, is_from_me, text) prevents duplicates in output.
SQL query changes:
- Added columns:
m.date_retracted,m.date_edited,m.is_audio_message,m.service,m.expressive_send_style_id,m.cache_has_attachments,a.transfer_name,a.mime_type - Added JOINs:
LEFT JOIN message_attachment_join,LEFT JOIN attachment - New function:
_build_guid_index()for GUID→message resolution
NDJSON schema v2:
{
"ts": "2026-02-14 07:16:22",
"sender": "them",
"is_from_me": false,
"text": "I was not trying to loop around you...",
"decoded": false,
"type": "text",
"edited": true,
"service": "SMS",
"effect": "slam",
"reply_to": {
"ts": "2026-02-13 23:30:00",
"sender": "me",
"text": "Original message..."
}
}Fields edited, service, effect, reply_to are optional — only present when applicable.
Informed by: macos-messages (columns: date_edited, date_retracted, expressive_send_style_id, thread_originator_guid), imessage-exporter (attachment handling), full PRAGMA table_info(message) audit (92 columns).
---
2026-02-14 — v3 Decoder: 3-Tier with pytypedstream + Cross-Repo Analysis
Context: After the v2 fix (NSString marker + length-prefix), conducted a cross-repo analysis of 5 open-source iMessage decoder implementations to find the best-in-class approaches. Forked and studied: imessage-exporter (Rust), macos-messages (Python), imessage-conversation-analyzer (Python), imessage_tools (Python), pymessage-lite (Python). Full analysis in cross-repo-analysis.md.
Verdict: Learn from the best, keep our unique features (--search, --context, --export), upgrade the decoder with external dependency (pytypedstream).
Changes:
1. 3-tier decoder architecture — Replaced single-function decoder with tiered fallback:
- Tier 1:
_decode_via_typedstream()—pytypedstreamUnarchiver (proper Apple typedstream deserialization). Adopted from imessage-conversation-analyzer. Handles all message lengths, emoji, rich formatting. - Tier 2:
_decode_via_multiformat()— Multi-format binary parser with 0x2B/0x4F/0x49 length-prefix variants + heuristic fallback. Ported from macos-messages_extract_text_from_attributed_body(). Zero external deps. - Tier 3:
_decode_via_nsstring_marker()— v2 legacy NSString split + length-prefix (LangChain approach). Kept as last resort.
2. Graceful degradation — _HAS_TYPEDSTREAM flag allows script to work without pytypedstream installed (skips tier 1, falls through to tiers 2/3).
3. PEP 723 inline script metadata — Added # dependencies = ["pytypedstream"] for tools that support it (e.g., uv run --script).
Additions:
reimport (for heuristic fallback in tier 2)from typedstream import Unarchiver(conditional, with graceful ImportError handling)- 3 new decode functions (
_decode_via_typedstream,_decode_via_multiformat,_decode_via_nsstring_marker)
API discovery (pytypedstream):
- Package:
pip install pytypedstream(PyPI name) - Module:
import typedstream(NOTpytypedstream) - Entry point:
Unarchiver.from_data(blob).decode_all()(NOTTypedStreamReader) - Unarchiver is NOT iterable — must call
.decode_all()first
New reference: cross-repo-analysis.md — Full comparison of all 5 repos with selection criteria, adoption decisions, and technical notes.
---
2026-02-14 — v2 Decoder + Context & Export Features
Context: During analysis of the Tiemar recruitment case, the v1 decoder (null-byte split + NS framework class filter) failed to extract 23+ messages including critical evidence like "The current office gave her a glaring reference" and "She gave me these references" + phone numbers. This caused multiple wasted search attempts and missed evidence that took hours to find manually via screenshots.
Root cause: The v1 any(cls in chunk for cls in NS_FRAMEWORK_CLASSES) filter discards chunks containing both message text AND framework class names. For short messages, the actual text and NSString/NSDictionary markers always land in the same null-delimited chunk — so the filter throws out the message with the metadata.
Changes:
1. Replaced `decode_attributed_body()` with NSString marker + length-prefix algorithm — Same approach as LangChain's iMessage loader. Splits on b"NSString", skips 5-byte preamble, reads length-prefix (single byte or 0x81 + 2-byte little-endian), extracts exact text. No filtering needed.
2. Added `--context N` flag — When used with --search, shows N messages before and after each match. Solves the "isolated keyword match loses conversational meaning" problem. Uses --- context --- separators between non-contiguous groups and [match] markers on actual matches.
3. Added `--export <path.jsonl>` flag — Exports conversation to NDJSON file for offline analysis. Format: {"ts", "sender", "is_from_me", "text", "decoded"} per line. Enables export-once-analyze-many workflow instead of repeated SQLite queries.
Removals:
NS_FRAMEWORK_CLASSESfrozenset (no longer needed)reimport (no longer needed)- Null-byte split logic
iIcleanup regex (length-prefix extraction doesn't include trailing artifacts)+.cleanup regex (same reason)
Anti-patterns documented:
1. Searching multiple chat identifiers for the same person without first checking --stats 2. Keyword search without context — always use --context 5 with --search 3. Repeated narrow-window SQLite queries — export first, then grep
---
2026-02-07 — Initial Creation
Context: During iMessage retrieval work, discovered that 20-60% of messages in real conversations have NULL text columns but contain valid, recoverable text in attributedBody (NSAttributedString binary blobs). Without documented knowledge of this pattern, every future session would rediscover the same workaround from scratch.
Created:
SKILL.md— Main skill with YAML frontmatter, workflow instructions, quick start queriesscripts/decode_attributed_body.py— Python script (stdlib only) for decoding NSAttributedString binary blobs fromattributedBodycolumnreferences/schema-reference.md— Core table documentation (message, chat, handle, attachment, joins)references/query-patterns.md— 8 reusable SQL templates for common operationsreferences/known-pitfalls.md— 10 documented pitfalls with symptoms and solutionsreferences/evolution-log.md— This file
Key discoveries codified:
1. text vs attributedBody problem (critical — causes messages to appear empty) 2. NSAttributedString binary decode technique (null-byte split, framework class filtering) 3. Tapback reaction filtering (associated_message_type = 0) 4. Apple epoch date formula with localtime conversion 5. zsh shell escaping for != operator 6. Voice message vs dictated text differentiation (cache_has_attachments flag)
Skill: iMessage Query
Known Pitfalls
Every gotcha discovered when working with the macOS iMessage database, with symptoms and solutions.
---
Critical: The text vs attributedBody Problem
Pitfall: Many messages have a NULL or empty text column but contain valid, recoverable text in attributedBody.
Symptom: Messages appear empty or are mistakenly classified as "voice messages" when they are actually dictated text or rich-formatted messages.
Root cause: iOS stores messages typed via dictation, messages with rich formatting (links, styled text), and some regular messages in attributedBody as an NSAttributedString binary blob instead of (or in addition to) the text column.
Solution: Always check attributedBody when text is NULL. Use the decode script or the inline Python technique below.
Scale: In real-world conversations, 20-60% of one party's messages may be stored exclusively in attributedBody, especially if they use dictation frequently.
---
Pitfall Reference Table
| # | Pitfall | Symptom | Solution |
|---|---|---|---|
| 1 | text column NULL | Message appears empty/missing | Check attributedBody — use decode script |
| 2 | NSAttributedString binary | Raw binary garbage in output | v3: 3-tier decoder (pytypedstream → multi-format binary → NSString marker) |
| 3 | Tapback reactions as messages | Duplicate/phantom messages | Filter with associated_message_type = 0 |
| 4 | iI suffix artifacts | Decoded text ends with iI + random chars | v1 only — v2 length-prefix extraction doesn't include trailing artifacts |
| 5 | + length prefix | Decoded text starts with + then a single char | v1 only — v2 length-prefix extraction doesn't include leading artifacts |
| 6 | Wrong timezone | Timestamps off by hours | Add 'localtime' modifier to datetime() |
| 7 | zsh != escaping | Shell error when using != in SQL | Use length(m.text) > 0 instead of m.text != '' |
| 8 | kIMMessagePartAttributeName | Garbage metadata text in decoded output | These are tapback metadata — filter by associated_message_type = 0 |
| 9 | Voice vs dictated confusion | Both have NULL text | Voice: cache_has_attachments = 1. Dictated: cache_has_attachments = 0 + attributedBody > 100 bytes |
| 10 | NSValue in decoded text | Short garbage strings like "NSValue" | These are tapback/reaction attribute values — already filtered by associated_message_type = 0 |
| 11 | Short messages invisible | Messages <50 chars return None from decode | FIXED v2/v3 — replaced null-split decoder; v3 uses pytypedstream for reliable decode |
| 12 | pytypedstream module name | ImportError when importing pytypedstream | Package = pytypedstream (PyPI), module = typedstream. Use from typedstream import Unarchiver |
| 13 | Unarchiver not iterable | TypeError iterating Unarchiver.from_data() | Must call .decode_all() first — returns list[TypedValue], not an iterator |
| 14 | Retracted messages look empty | NULL text, NULL attributedBody, 0 attachments — looks like voice message | Check date_edited > 0: these are unsent/retracted messages with content wiped by iOS. NOT recoverable. NOT admissible — both parties know they were retracted. message_summary_info.otr.le records original length. |
---
Detailed Explanations
1. Decoding NSAttributedString Binary
The attributedBody column contains a serialized NSAttributedString object. The binary format includes:
- A
streamtypedheader - The actual text content (after a
b"NSString"marker + 5-byte preamble + length prefix) - Attribute dictionaries (font, color, paragraph style)
- Apple framework class names as markers
Decode strategy v3 (used by the bundled script — 3-tier with pytypedstream):
# Tier 1: pytypedstream (proper deserialization, most reliable)
from typedstream import Unarchiver
result = Unarchiver.from_data(attr_body).decode_all()
# Navigate: TypedValue.value → GenericArchivedObject.contents → NSMutableString.value → str
# Tier 2: Multi-format binary (0x2B/0x4F/0x49 markers with variable-length encoding)
# Ported from macos-messages — handles 4 encoding formats, zero external deps
# Tier 3: NSString marker + length-prefix (v2 legacy, LangChain approach)
# Split on b"NSString", skip 5-byte preamble, read length — simplest last resortWhy v3? The v2 approach (NSString marker only) fixed the v1 short-message bug but only handles one encoding format. The v3 3-tier strategy handles all known formats via pytypedstream (tier 1), falls through to multi-format binary parsing (tier 2), and keeps the v2 approach as a last resort (tier 3). See cross-repo-analysis.md for full comparison.
Why v2 over v1? The original v1 approach (null-byte split + framework class name filtering) silently dropped short messages where the text and NS class names coexisted in the same chunk. See pitfall #11.
2. Tapback Reactions
When a user "likes" or "loves" a message, iOS creates a NEW message row with:
associated_message_typeset to 2000-2005 (or 3000-3005 for removal)textoften NULLattributedBodycontaining metadata about the reaction
Always filter: AND m.associated_message_type = 0
Without this filter, conversations appear to have many duplicate or garbage messages.
3. Shell Escaping in zsh
macOS default shell is zsh. The != operator in SQL strings can be misinterpreted:
# BAD — zsh may choke on !=
sqlite3 db.db "SELECT * FROM message WHERE text != ''"
# GOOD — works in all shells
sqlite3 db.db "SELECT * FROM message WHERE length(text) > 0"4. Date Conversion Gotchas
Forgetting `'localtime'`:
-- Returns UTC (wrong for display)
datetime(m.date/1000000000 + 978307200, 'unixepoch')
-- Returns local time (correct)
datetime(m.date/1000000000 + 978307200, 'unixepoch', 'localtime')Comparing dates: Always compare in the datetime() domain, not raw integers:
-- GOOD
WHERE datetime(m.date/1000000000 + 978307200, 'unixepoch', 'localtime') >= '2026-01-01'
-- BAD (raw integer comparison is error-prone)
WHERE m.date >= 7889184000000000005. Group Chats vs 1:1
Group chats have a different chat_identifier format (often chat + number). To find group chats:
SELECT chat_identifier, display_name
FROM chat
WHERE chat_identifier LIKE 'chat%'Group chat messages may have different handle_id values for each participant. Use the handle table to resolve sender identity in group chats.
6. Database Locking
chat.db is actively used by Messages.app. Always open read-only:
# Python — read-only URI mode
conn = sqlite3.connect("file:path/to/chat.db?mode=ro", uri=True)# sqlite3 CLI — inherently read-only for SELECT queries
sqlite3 ~/Library/Messages/chat.db "SELECT ..."Never attempt to write to chat.db — it will corrupt the database.
7. Short Messages Invisible to v1 Decoder (FIXED)
Pitfall #11 — Messages under ~50 characters returned None from the original v1 decode function. Searching for known keywords returned "No messages found" despite messages existing in the DB.
Root cause: The v1 null-byte split approach puts actual message text in the same chunk as NSString/NSDictionary class markers. The framework class filter (any(cls in chunk for cls in NS_FRAMEWORK_CLASSES)) then discards the entire chunk — throwing out the message text along with the metadata.
Affected messages (discovered during Tiemar recruitment case, 2026-02-13):
- "She gave me these references" + phone numbers
- "The current office gave her a glaring reference"
- "Yes", "Cool.", "Never", and other short messages
Solution: Replaced with NSString marker + length-prefix extraction (v2, 2026-02-14), then upgraded to 3-tier decoder with pytypedstream (v3, 2026-02-14). No filtering needed — pytypedstream does proper deserialization, and the length-prefix fallbacks give exact text boundaries.
Anti-pattern: Never filter decoded chunks by checking if they _contain_ framework class names. The message text and class names coexist in the same binary region for short messages.
8. pytypedstream Module Name Mismatch
Pitfall #12 — from pytypedstream import Unarchiver raises ImportError. The PyPI package name (pytypedstream) differs from the installed module name (typedstream).
Solution: from typedstream import Unarchiver — always import from typedstream, not pytypedstream.
9. Unarchiver Object Not Iterable
Pitfall #13 — for value in Unarchiver.from_data(blob): raises TypeError. The Unarchiver object is not directly iterable.
Solution: Call .decode_all() first: Unarchiver.from_data(blob).decode_all() returns a list[TypedValue] that you can iterate.
10. Retracted Messages Mistaken for Voice Messages or Missing Content
Pitfall #14 — Messages with NULL text, NULL/empty attributedBody, and cache_has_attachments = 0 look identical to the "voice message" pattern from pitfall #9. Previous sessions incorrectly classified these as voice messages, inflating the "undecodable" count.
Root cause: When a sender uses "Undo Send" (iOS 16+), the message row stays in the database but both text and attributedBody are wiped. The date_edited field is set to the retraction timestamp (typically 3–64 seconds after date).
How to detect:
-- Retracted messages (NOT voice, NOT missing content — intentionally unsent)
SELECT datetime(m.date/1000000000 + 978307200, 'unixepoch', 'localtime') as sent,
datetime(m.date_edited/1000000000 + 978307200, 'unixepoch', 'localtime') as retracted,
(m.date_edited - m.date) / 1000000000 as delay_secs
FROM message m
WHERE m.date_edited > 0
AND (m.text IS NULL OR length(m.text) = 0)
AND (m.attributedBody IS NULL OR length(m.attributedBody) < 50)Original text length: The message_summary_info blob (a plist) contains otr.0.le which records the original text length before retraction. This confirms the message had real content, but that content is unrecoverable.
Admissibility: Retracted messages are NOT admissible in conversation logs or NDJSON exports. Both parties saw the retraction notification. The decode script correctly excludes them (no text or attributedBody means _resolve_message() returns None).
Anti-pattern: Never assume that NULL text + NULL attributedBody = voice message. Always check date_edited first — if set, it's a retracted message, not a voice message.
Skill: iMessage Query
Reusable SQL Query Patterns
All queries use parameterized placeholders. Replace <CHAT_IDENTIFIER> with actual phone number or email.
Database: ~/Library/Messages/chat.db Tool: sqlite3 (pre-installed on macOS)
---
1. List All Conversations
Find chat identifiers for all conversations, sorted by message count.
sqlite3 ~/Library/Messages/chat.db \
"SELECT c.chat_identifier, c.display_name, COUNT(cmj.message_id) as msg_count
FROM chat c
JOIN chat_message_join cmj ON c.ROWID = cmj.chat_id
GROUP BY c.ROWID
ORDER BY msg_count DESC
LIMIT 30"2. Get Conversation Thread (text column only)
Simple retrieval — misses messages stored in attributedBody.
sqlite3 ~/Library/Messages/chat.db \
"SELECT datetime(m.date/1000000000 + 978307200, 'unixepoch', 'localtime') as ts,
CASE WHEN m.is_from_me = 1 THEN 'Me' ELSE 'Them' END as sender,
m.text
FROM message m
JOIN chat_message_join cmj ON m.ROWID = cmj.message_id
JOIN chat c ON cmj.chat_id = c.ROWID
WHERE c.chat_identifier = '<CHAT_IDENTIFIER>'
AND length(m.text) > 0
AND m.associated_message_type = 0
ORDER BY m.date ASC"3. Keyword Search Across All Chats
Search for a keyword in all conversations.
sqlite3 ~/Library/Messages/chat.db \
"SELECT c.chat_identifier,
datetime(m.date/1000000000 + 978307200, 'unixepoch', 'localtime') as ts,
CASE WHEN m.is_from_me = 1 THEN 'Me' ELSE 'Them' END as sender,
m.text
FROM message m
JOIN chat_message_join cmj ON m.ROWID = cmj.message_id
JOIN chat c ON cmj.chat_id = c.ROWID
WHERE m.text LIKE '%<KEYWORD>%'
AND m.associated_message_type = 0
ORDER BY m.date DESC
LIMIT 50"Note: This only searches the text column. To search attributedBody content, use the decode script with --search.
4. Message Statistics
Get counts by sender, message type, and date range for a conversation.
sqlite3 ~/Library/Messages/chat.db \
"SELECT
COUNT(*) as total,
SUM(CASE WHEN m.is_from_me = 1 THEN 1 ELSE 0 END) as sent,
SUM(CASE WHEN m.is_from_me = 0 THEN 1 ELSE 0 END) as received,
SUM(CASE WHEN m.text IS NOT NULL AND length(m.text) > 0 THEN 1 ELSE 0 END) as has_text,
SUM(CASE WHEN (m.text IS NULL OR length(m.text) = 0)
AND m.attributedBody IS NOT NULL
AND length(m.attributedBody) > 100
AND m.cache_has_attachments = 0 THEN 1 ELSE 0 END) as hidden_in_attributed_body,
SUM(CASE WHEN m.cache_has_attachments = 1 THEN 1 ELSE 0 END) as with_attachments,
MIN(datetime(m.date/1000000000 + 978307200, 'unixepoch', 'localtime')) as first_msg,
MAX(datetime(m.date/1000000000 + 978307200, 'unixepoch', 'localtime')) as last_msg
FROM message m
JOIN chat_message_join cmj ON m.ROWID = cmj.message_id
JOIN chat c ON cmj.chat_id = c.ROWID
WHERE c.chat_identifier = '<CHAT_IDENTIFIER>'
AND m.associated_message_type = 0"The hidden_in_attributed_body count shows how many messages would be missed without the decode script.
5. Find Messages with Attachments
List messages that have file attachments (images, voice memos, documents).
sqlite3 ~/Library/Messages/chat.db \
"SELECT datetime(m.date/1000000000 + 978307200, 'unixepoch', 'localtime') as ts,
CASE WHEN m.is_from_me = 1 THEN 'Me' ELSE 'Them' END as sender,
a.mime_type,
a.transfer_name,
a.total_bytes,
m.text
FROM message m
JOIN chat_message_join cmj ON m.ROWID = cmj.message_id
JOIN chat c ON cmj.chat_id = c.ROWID
JOIN message_attachment_join maj ON m.ROWID = maj.message_id
JOIN attachment a ON maj.attachment_id = a.ROWID
WHERE c.chat_identifier = '<CHAT_IDENTIFIER>'
AND m.associated_message_type = 0
ORDER BY m.date DESC
LIMIT 30"6. Identify Messages Needing Decode
Find messages where text is NULL but attributedBody contains recoverable content.
sqlite3 ~/Library/Messages/chat.db \
"SELECT datetime(m.date/1000000000 + 978307200, 'unixepoch', 'localtime') as ts,
CASE WHEN m.is_from_me = 1 THEN 'Me' ELSE 'Them' END as sender,
length(m.attributedBody) as attr_len,
m.cache_has_attachments
FROM message m
JOIN chat_message_join cmj ON m.ROWID = cmj.message_id
JOIN chat c ON cmj.chat_id = c.ROWID
WHERE c.chat_identifier = '<CHAT_IDENTIFIER>'
AND (m.text IS NULL OR length(m.text) = 0)
AND m.attributedBody IS NOT NULL
AND length(m.attributedBody) > 100
AND m.associated_message_type = 0
AND m.cache_has_attachments = 0
ORDER BY m.date DESC
LIMIT 50"These are the messages that require the Python decode script to extract text.
7. Conversation Window (Time Range)
Get messages in a specific time window.
sqlite3 ~/Library/Messages/chat.db \
"SELECT datetime(m.date/1000000000 + 978307200, 'unixepoch', 'localtime') as ts,
CASE WHEN m.is_from_me = 1 THEN 'Me' ELSE 'Them' END as sender,
m.text
FROM message m
JOIN chat_message_join cmj ON m.ROWID = cmj.message_id
JOIN chat c ON cmj.chat_id = c.ROWID
WHERE c.chat_identifier = '<CHAT_IDENTIFIER>'
AND datetime(m.date/1000000000 + 978307200, 'unixepoch', 'localtime')
BETWEEN '<START_DATE>' AND '<END_DATE>'
AND length(m.text) > 0
AND m.associated_message_type = 0
ORDER BY m.date ASC"Date format: YYYY-MM-DD or YYYY-MM-DD HH:MM:SS
8. All Messages Including Both Parties (Full Thread with Context)
Get a complete interleaved thread showing both sides, including empty-text markers.
sqlite3 ~/Library/Messages/chat.db \
"SELECT datetime(m.date/1000000000 + 978307200, 'unixepoch', 'localtime') as ts,
CASE WHEN m.is_from_me = 1 THEN 'Me' ELSE 'Them' END as sender,
CASE
WHEN length(m.text) > 0 THEN m.text
WHEN m.cache_has_attachments = 1 THEN '[attachment]'
WHEN m.attributedBody IS NOT NULL AND length(m.attributedBody) > 100 THEN '[needs decode]'
ELSE '[empty]'
END as content
FROM message m
JOIN chat_message_join cmj ON m.ROWID = cmj.message_id
JOIN chat c ON cmj.chat_id = c.ROWID
WHERE c.chat_identifier = '<CHAT_IDENTIFIER>'
AND m.associated_message_type = 0
ORDER BY m.date ASC"Messages showing [needs decode] should be processed with the Python decode script.
Skill: iMessage Query
iMessage Database Schema Reference
The macOS iMessage database is located at ~/Library/Messages/chat.db (SQLite3). Requires Full Disk Access.
Core Tables
message
The primary table. One row per message (including tapback reactions as separate rows).
| Column | Type | Description |
|---|---|---|
ROWID | INTEGER | Primary key |
text | TEXT | Message text (NULL for dictated/rich messages — check attributedBody) |
attributedBody | BLOB | NSAttributedString binary — contains text for dictated/formatted messages |
date | INTEGER | Nanoseconds since Apple epoch (2001-01-01 00:00:00 UTC) |
is_from_me | INTEGER | 1 = sent, 0 = received |
cache_has_attachments | INTEGER | 1 = has file/image/voice attachment |
associated_message_type | INTEGER | 0 = normal message, non-zero = tapback/reaction |
handle_id | INTEGER | FK to handle table (sender/recipient) |
service | TEXT | "iMessage" or "SMS" |
is_read | INTEGER | 1 = read |
is_delivered | INTEGER | 1 = delivered |
is_sent | INTEGER | 1 = sent successfully |
subject | TEXT | Message subject (rarely used in iMessage) |
group_title | TEXT | Group chat name (if changed by this message) |
chat
One row per conversation (1:1 or group).
| Column | Type | Description |
|---|---|---|
ROWID | INTEGER | Primary key |
chat_identifier | TEXT | Phone number (e.g., +1234567890) or email |
display_name | TEXT | User-set display name (often NULL for 1:1 chats) |
service_name | TEXT | "iMessage" or "SMS" |
group_id | TEXT | Group chat identifier |
chat_message_join
Many-to-many join between chats and messages.
| Column | Type | Description |
|---|---|---|
chat_id | INTEGER | FK to chat.ROWID |
message_id | INTEGER | FK to message.ROWID |
message_date | INTEGER | Denormalized date for indexing |
handle
Contact identifiers (phone numbers, emails).
| Column | Type | Description |
|---|---|---|
ROWID | INTEGER | Primary key |
id | TEXT | Phone number or email |
service | TEXT | "iMessage" or "SMS" |
country | TEXT | Country code (e.g., "us", "ca") |
attachment
File attachments (images, voice memos, documents).
| Column | Type | Description |
|---|---|---|
ROWID | INTEGER | Primary key |
filename | TEXT | Full path on disk (often ~/Library/Messages/Attachments/...) |
mime_type | TEXT | MIME type (e.g., image/jpeg, audio/amr) |
transfer_name | TEXT | Original filename |
total_bytes | INTEGER | File size |
created_date | INTEGER | Apple epoch nanoseconds |
message_attachment_join
Many-to-many join between messages and attachments.
| Column | Type | Description |
|---|---|---|
message_id | INTEGER | FK to message.ROWID |
attachment_id | INTEGER | FK to attachment.ROWID |
---
Date Formula
iMessage uses nanoseconds since Apple epoch (2001-01-01 00:00:00 UTC).
Convert to human-readable local time
datetime(m.date/1000000000 + 978307200, 'unixepoch', 'localtime')Breakdown
| Component | Purpose |
|---|---|
m.date / 1000000000 | Nanoseconds → seconds |
+ 978307200 | Apple epoch (2001) → Unix epoch (1970) offset |
'unixepoch' | Tell SQLite the input is a Unix timestamp |
'localtime' | Convert UTC → local timezone |
The magic number 978307200
from datetime import datetime
# Seconds between 1970-01-01 and 2001-01-01
(datetime(2001, 1, 1) - datetime(1970, 1, 1)).total_seconds()
# = 978307200.0Filter by date
To find messages after a specific date, compare in the datetime domain:
WHERE datetime(m.date/1000000000 + 978307200, 'unixepoch', 'localtime') >= '2026-01-01'---
associated_message_type Values
| Value | Meaning |
|---|---|
| 0 | Normal message |
| 2000 | Loved |
| 2001 | Liked |
| 2002 | Disliked |
| 2003 | Laughed |
| 2004 | Emphasized |
| 2005 | Questioned |
| 3000–3005 | Removal of above reactions |
Always filter `associated_message_type = 0` to get only actual messages.
#!/usr/bin/env python3
# /// script
# requires-python = ">=3.10"
# dependencies = ["pytypedstream"]
# ///
# ADR: references/evolution-log.md (v1→v2→v3→v4)
# Issue: references/known-pitfalls.md (#1-#14)
# FILE-SIZE-OK: single-file script by design (self-contained for skill distribution)
"""
Decode iMessage messages from macOS chat.db, including NSAttributedString binary blobs.
Many iOS messages store text in the `attributedBody` column (as NSAttributedString binary)
rather than the `text` column. This script handles both transparently.
Decoder strategy (v3 — 3-tier with pytypedstream):
1. pytypedstream (Unarchiver) — proper typedstream deserialization, handles all formats
2. Multi-format binary — 0x2B length-prefix with 1-4 byte lengths, 0x4F, 0x49 fallbacks
3. NSString marker — split on b"NSString" + length-prefix (v2 legacy)
Native pitfall protections (v4):
- Retracted messages (Undo Send): detected via date_retracted, excluded from output
- Edited messages: date_edited tracked, flagged in output
- Audio/voice messages: is_audio_message column distinguishes from empty text
- Inline quotes: thread_originator_guid resolved to quoted message text
- Attachments: type/filename surfaced for attachment-only messages
- Message effects: expressive_send_style_id captured (slam, loud, gentle, invisible ink)
- Service type: iMessage vs SMS distinguished
Usage:
python3 decode_attributed_body.py --chat "+1234567890" --limit 50
python3 decode_attributed_body.py --chat "user@example.com" --search "keyword"
python3 decode_attributed_body.py --chat "+1234567890" --after "2026-01-01" --before "2026-02-01"
python3 decode_attributed_body.py --chat "+1234567890" --sender me
python3 decode_attributed_body.py --chat "+1234567890" --sender them
python3 decode_attributed_body.py --chat "+1234567890" --search "keyword" --context 3
python3 decode_attributed_body.py --chat "+1234567890" --after "2026-02-01" --export thread.jsonl
Output: timestamp|sender|text (pipe-delimited, one message per line)
Messages decoded from attributedBody are marked with [decoded] prefix.
"""
import argparse
import json
import os
import re
import sqlite3
import sys
# Apple epoch offset: seconds between Unix epoch (1970-01-01) and Apple epoch (2001-01-01)
APPLE_EPOCH_OFFSET = 978307200
# Expressive send style IDs → human-readable names
_SEND_EFFECTS = {
"com.apple.MobileSMS.expressivesend.impact": "slam",
"com.apple.MobileSMS.expressivesend.gentle": "gentle",
"com.apple.MobileSMS.expressivesend.loud": "loud",
"com.apple.MobileSMS.expressivesend.invisibleink": "invisible_ink",
}
# Try to import pytypedstream (preferred decoder)
try:
from typedstream import Unarchiver
_HAS_TYPEDSTREAM = True
except ImportError:
_HAS_TYPEDSTREAM = False
def _decode_via_typedstream(attr_body: bytes) -> str | None:
"""Tier 1: Decode via pytypedstream Unarchiver (proper typedstream deserialization).
Source: pytypedstream package (same approach as imessage-conversation-analyzer).
Handles all message lengths, emoji, and rich formatting correctly.
"""
try:
result = Unarchiver.from_data(attr_body).decode_all()
for tv in result:
obj = tv.value
if hasattr(obj, "contents"):
for item in obj.contents:
val = item.value
# NSMutableString wraps a str
if hasattr(val, "value") and isinstance(val.value, str):
text = val.value.strip()
return text if text else None
elif isinstance(val, str):
text = val.strip()
return text if text else None
except (IndexError, ValueError, UnicodeDecodeError, TypeError, OSError):
return None
return None
def _decode_via_multiformat(attr_body: bytes) -> str | None:
"""Tier 2: Multi-format binary length-prefix decoder (from macos-messages).
Handles 0x2B (+) marker with 1-4 byte lengths, 0x4F extended encoding,
and 0x49 (I) legacy format. No external dependencies.
"""
try:
text_section = attr_body.split(b"NSString")[1].split(b"NSDictionary")[0]
# Try + marker (0x2B) — most common
plus_idx = text_section.find(b"+")
if plus_idx != -1 and plus_idx + 2 < len(text_section):
marker = text_section[plus_idx + 1]
if marker < 0x80:
length, start = marker, plus_idx + 2
elif marker == 0x81 and plus_idx + 4 <= len(text_section):
length = int.from_bytes(text_section[plus_idx + 2 : plus_idx + 4], "little")
start = plus_idx + 4
elif marker == 0x82 and plus_idx + 5 <= len(text_section):
length = int.from_bytes(text_section[plus_idx + 2 : plus_idx + 5], "little")
start = plus_idx + 5
elif marker == 0x83 and plus_idx + 6 <= len(text_section):
length = int.from_bytes(text_section[plus_idx + 2 : plus_idx + 6], "little")
start = plus_idx + 6
else:
length, start = 0, 0
if length > 0 and start + length <= len(text_section):
text = text_section[start : start + length].decode("utf-8", errors="ignore").strip()
if text:
return text
# Try 0x4F extended length encoding
for i in range(len(text_section) - 2):
if text_section[i] != 0x4F:
continue
size_marker = text_section[i + 1]
if size_marker == 0x10:
length, start = text_section[i + 2], i + 3
elif size_marker == 0x11 and i + 4 <= len(text_section):
length = int.from_bytes(text_section[i + 2 : i + 4], "big")
start = i + 4
elif size_marker == 0x12 and i + 6 <= len(text_section):
length = int.from_bytes(text_section[i + 2 : i + 6], "big")
start = i + 6
else:
continue
if 0 < length < 100000 and start + length <= len(text_section):
text = text_section[start : start + length].decode("utf-8", errors="ignore").strip()
if text:
return text
# Try legacy I marker (0x49) — 4-byte big-endian length
i_idx = text_section.find(b"I")
if i_idx != -1 and i_idx + 5 < len(text_section):
length = int.from_bytes(text_section[i_idx + 1 : i_idx + 5], "big")
if 0 < length < 100000 and i_idx + 5 + length <= len(text_section):
text = text_section[i_idx + 5 : i_idx + 5 + length].decode("utf-8", errors="ignore").strip()
if text:
return text
except (IndexError, ValueError):
pass
# Heuristic fallback: find readable text sequences after NSString
try:
if b"streamtyped" in attr_body:
parts = attr_body.split(b"NSString")
if len(parts) > 1:
matches = re.findall(rb"[\x20-\x7e\xc0-\xff]{4,}", parts[1])
for m in matches:
decoded = m.decode("utf-8", errors="ignore").strip()
if decoded and not decoded.startswith(("NS", "{")):
return decoded
except (IndexError, ValueError, UnicodeDecodeError):
return None
return None
def _decode_via_nsstring_marker(attr_body: bytes) -> str | None:
"""Tier 3: NSString marker + length-prefix (v2 legacy, LangChain approach).
Simplest approach — split on b"NSString", skip 5-byte preamble, read length.
Kept as last resort for unusual blob formats.
"""
try:
parts = attr_body.split(b"NSString")
if len(parts) < 2:
return None
content = parts[1][5:]
length = content[0]
start = 1
if content[0] == 0x81:
length = int.from_bytes(content[1:3], "little")
start = 3
text = content[start : start + length].decode("utf-8", errors="ignore").strip()
return text if text else None
except (IndexError, ValueError):
return None
def decode_attributed_body(attr_body: bytes) -> str | None:
"""Decode NSAttributedString binary blob to plain text.
3-tier strategy:
1. pytypedstream (Unarchiver) — proper deserialization, most reliable
2. Multi-format binary — 0x2B/0x4F/0x49 length-prefix parsing
3. NSString marker — v2 legacy split approach
Falls through tiers on failure. Returns None if all tiers fail.
"""
if not attr_body:
return None
# Tier 1: pytypedstream (if available)
if _HAS_TYPEDSTREAM:
result = _decode_via_typedstream(attr_body)
if result:
return result
# Tier 2: Multi-format binary parsing
result = _decode_via_multiformat(attr_body)
if result:
return result
# Tier 3: NSString marker (v2 legacy)
return _decode_via_nsstring_marker(attr_body)
def get_db_path() -> str:
"""Get the iMessage database path."""
return os.path.join(os.path.expanduser("~"), "Library", "Messages", "chat.db")
def _build_guid_index(conn: sqlite3.Connection, chat_identifier: str) -> dict[str, dict]:
"""Build a GUID → message dict for resolving thread_originator_guid references.
Returns a dict mapping message GUID to {ts, sender, text} for all messages
in the chat. Used to look up the quoted message when a reply references it.
"""
cur = conn.cursor()
cur.execute(
f"""
SELECT
m.guid,
datetime(m.date/1000000000 + {APPLE_EPOCH_OFFSET}, 'unixepoch', 'localtime') as ts,
m.is_from_me,
m.text,
m.attributedBody
FROM message m
JOIN chat_message_join cmj ON m.ROWID = cmj.message_id
JOIN chat c ON cmj.chat_id = c.ROWID
WHERE c.chat_identifier = ?
AND m.associated_message_type = 0
""",
(chat_identifier,),
)
index = {}
for guid, ts, is_from_me, text, attr_body in cur:
content = None
if text and len(text.strip()) > 0:
content = text.strip()
elif attr_body and len(attr_body) > 50:
content = decode_attributed_body(attr_body)
if content:
index[guid] = {
"ts": ts,
"sender": "me" if is_from_me else "them",
"text": content,
}
return index
def build_query(args: argparse.Namespace) -> tuple[str, list]:
"""Build SQL query from command-line arguments.
Selects all columns needed for comprehensive message extraction:
- Core: ts, is_from_me, text, attributedBody
- Pitfall protection: date_retracted, date_edited, is_audio_message
- Context: thread_originator_guid (inline quotes)
- Metadata: service, expressive_send_style_id, cache_has_attachments
- Attachment: transfer_name, mime_type (via LEFT JOIN)
"""
params: list = []
select = f"""
SELECT
datetime(m.date/1000000000 + {APPLE_EPOCH_OFFSET}, 'unixepoch', 'localtime') as ts,
m.is_from_me,
m.text,
m.attributedBody,
m.thread_originator_guid,
m.date_retracted,
m.date_edited,
m.is_audio_message,
m.service,
m.expressive_send_style_id,
m.cache_has_attachments,
a.transfer_name,
a.mime_type
FROM message m
JOIN chat_message_join cmj ON m.ROWID = cmj.message_id
JOIN chat c ON cmj.chat_id = c.ROWID
LEFT JOIN message_attachment_join maj ON m.ROWID = maj.message_id
LEFT JOIN attachment a ON maj.attachment_id = a.ROWID
WHERE c.chat_identifier = ?
AND m.associated_message_type = 0
"""
params.append(args.chat)
if args.sender == "me":
select += " AND m.is_from_me = 1"
elif args.sender == "them":
select += " AND m.is_from_me = 0"
if args.after:
select += f" AND datetime(m.date/1000000000 + {APPLE_EPOCH_OFFSET}, 'unixepoch', 'localtime') >= ?"
params.append(args.after)
if args.before:
select += f" AND datetime(m.date/1000000000 + {APPLE_EPOCH_OFFSET}, 'unixepoch', 'localtime') <= ?"
params.append(args.before)
select += " ORDER BY m.date"
if args.order == "desc":
select += " DESC"
else:
select += " ASC"
if args.limit:
select += " LIMIT ?"
params.append(args.limit)
return select, params
def main() -> None:
parser = argparse.ArgumentParser(
description="Query macOS iMessage database with NSAttributedString decoding"
)
parser.add_argument(
"--chat",
required=True,
help="Chat identifier (phone number like +1234567890 or email)",
)
parser.add_argument(
"--search",
help="Filter messages containing this keyword (case-insensitive)",
)
parser.add_argument(
"--after",
help="Only messages after this date (YYYY-MM-DD or YYYY-MM-DD HH:MM:SS)",
)
parser.add_argument(
"--before",
help="Only messages before this date (YYYY-MM-DD or YYYY-MM-DD HH:MM:SS)",
)
parser.add_argument(
"--sender",
choices=["me", "them", "both"],
default="both",
help="Filter by sender (default: both)",
)
parser.add_argument(
"--limit",
type=int,
help="Maximum number of messages to return",
)
parser.add_argument(
"--order",
choices=["asc", "desc"],
default="asc",
help="Sort order by date (default: asc)",
)
parser.add_argument(
"--me-label",
default="Me",
help="Label for outgoing messages (default: Me)",
)
parser.add_argument(
"--them-label",
default="Them",
help="Label for incoming messages (default: Them)",
)
parser.add_argument(
"--db",
help="Path to chat.db (default: ~/Library/Messages/chat.db)",
)
parser.add_argument(
"--stats",
action="store_true",
help="Show statistics instead of messages",
)
parser.add_argument(
"--context",
type=int,
metavar="N",
help="Show N messages before and after each --search match",
)
parser.add_argument(
"--export",
metavar="PATH",
help="Export messages to NDJSON file (.jsonl) instead of stdout",
)
args = parser.parse_args()
if args.context and not args.search:
parser.error("--context requires --search")
db_path = args.db or get_db_path()
if not os.path.exists(db_path):
print(f"Error: Database not found at {db_path}", file=sys.stderr)
print(
"Ensure Full Disk Access is granted to your terminal application.",
file=sys.stderr,
)
sys.exit(1)
try:
conn = sqlite3.connect(f"file:{db_path}?mode=ro", uri=True)
except sqlite3.OperationalError as e:
print(f"Error opening database: {e}", file=sys.stderr)
print(
"This usually means Full Disk Access is not granted.",
file=sys.stderr,
)
sys.exit(1)
if args.stats:
_print_stats(conn, args)
else:
_print_messages(conn, args)
conn.close()
def _print_stats(conn: sqlite3.Connection, args: argparse.Namespace) -> None:
"""Print conversation statistics with retracted/edited/audio breakdown."""
cur = conn.cursor()
cur.execute(
f"""
SELECT
COUNT(*) as total,
SUM(CASE WHEN m.is_from_me = 1 THEN 1 ELSE 0 END) as sent,
SUM(CASE WHEN m.is_from_me = 0 THEN 1 ELSE 0 END) as received,
SUM(CASE WHEN m.text IS NOT NULL AND length(m.text) > 0 THEN 1 ELSE 0 END) as has_text,
SUM(CASE WHEN (m.text IS NULL OR length(m.text) = 0)
AND m.attributedBody IS NOT NULL
AND length(m.attributedBody) > 100
AND m.cache_has_attachments = 0 THEN 1 ELSE 0 END) as hidden_text,
SUM(CASE WHEN m.cache_has_attachments = 1 THEN 1 ELSE 0 END) as attachments,
SUM(CASE WHEN m.date_retracted > 0
OR (m.date_edited > 0
AND (m.text IS NULL OR length(m.text) = 0)
AND (m.attributedBody IS NULL OR length(m.attributedBody) < 50))
THEN 1 ELSE 0 END) as retracted,
SUM(CASE WHEN m.date_edited > 0 AND m.date_retracted = 0
AND (m.text IS NOT NULL AND length(m.text) > 0
OR m.attributedBody IS NOT NULL AND length(m.attributedBody) >= 50)
THEN 1 ELSE 0 END) as edited,
SUM(CASE WHEN m.is_audio_message = 1 THEN 1 ELSE 0 END) as audio,
SUM(CASE WHEN m.thread_originator_guid IS NOT NULL
AND length(m.thread_originator_guid) > 0 THEN 1 ELSE 0 END) as threaded,
SUM(CASE WHEN m.service = 'SMS' THEN 1 ELSE 0 END) as sms,
MIN(datetime(m.date/1000000000 + {APPLE_EPOCH_OFFSET}, 'unixepoch', 'localtime')) as first_msg,
MAX(datetime(m.date/1000000000 + {APPLE_EPOCH_OFFSET}, 'unixepoch', 'localtime')) as last_msg
FROM message m
JOIN chat_message_join cmj ON m.ROWID = cmj.message_id
JOIN chat c ON cmj.chat_id = c.ROWID
WHERE c.chat_identifier = ?
AND m.associated_message_type = 0
""",
(args.chat,),
)
row = cur.fetchone()
if not row or row[0] == 0:
print(f"No messages found for chat: {args.chat}", file=sys.stderr)
return
(total, sent, received, has_text, hidden_text, attachments,
retracted, edited, audio, threaded, sms, first_msg, last_msg) = row
print(f"Chat: {args.chat}")
print(f"Period: {first_msg} to {last_msg}")
print(f"Total messages: {total}")
print(f" Sent: {sent}")
print(f" Received: {received}")
print(f" With text column: {has_text}")
print(f" Hidden in attributedBody: {hidden_text}")
print(f" With attachments: {attachments}")
print(f" Retracted (Undo Send): {retracted}")
print(f" Edited: {edited}")
print(f" Audio messages: {audio}")
print(f" Threaded replies: {threaded}")
if sms:
print(f" SMS (not iMessage): {sms}")
decodable = has_text + hidden_text
print(f" Decode coverage: {(decodable / total * 100):.1f}%")
# Adjusted coverage excludes retracted (content wiped by iOS, not recoverable)
adjusted_total = total - retracted
if adjusted_total > 0 and retracted > 0:
print(f" Adjusted coverage (excl. retracted): {(decodable / adjusted_total * 100):.1f}%")
def _resolve_message(
row: tuple,
me_label: str,
them_label: str,
guid_index: dict[str, dict] | None,
) -> dict | None:
"""Resolve a raw DB row into a message dict. Returns None if no content.
Row columns (from build_query):
0: ts, 1: is_from_me, 2: text, 3: attributedBody,
4: thread_originator_guid, 5: date_retracted, 6: date_edited,
7: is_audio_message, 8: service, 9: expressive_send_style_id,
10: cache_has_attachments, 11: transfer_name, 12: mime_type
Pitfall protections:
- Retracted messages (date_retracted > 0): EXCLUDED — content wiped by iOS,
not admissible as both parties saw the retraction notification (pitfall #14)
- Edited messages (date_edited > 0): included but flagged with "edited" field
- Audio messages (is_audio_message = 1): included with "audio" type, not
misclassified as empty/missing text (pitfall #9 correction)
"""
(ts, is_from_me, text, attr_body, thread_guid,
date_retracted, date_edited, is_audio, service,
send_style, has_attachments, attachment_name, mime_type) = row
# Pitfall #14: Retracted messages — EXCLUDE deterministically
# Both parties know these were retracted. Content is unrecoverable.
# Detection: date_retracted > 0 (newer iOS), OR date_edited > 0 with
# wiped content (older iOS used date_edited for Undo Send).
if date_retracted and date_retracted > 0:
return None
if (date_edited and date_edited > 0
and (not text or len(text.strip()) == 0)
and (not attr_body or len(attr_body) < 50)):
return None
sender_label = me_label if is_from_me else them_label
sender_key = "me" if is_from_me else "them"
content = None
is_decoded = False
msg_type = "text"
# Try text column first
if text and len(text.strip()) > 0:
content = text.strip()
# Try attributedBody (pitfall #1, #2, #11)
elif attr_body and len(attr_body) > 50:
decoded = decode_attributed_body(attr_body)
if decoded:
content = decoded
is_decoded = True
# Classify message type for messages with no text content
if not content:
# Pitfall #9: is_audio_message is the definitive column for voice messages
if is_audio and is_audio == 1:
msg_type = "audio"
content = "[audio message]"
elif has_attachments and has_attachments == 1:
msg_type = "attachment"
# Surface attachment info instead of silently dropping
if attachment_name:
content = f"[attachment: {attachment_name}]"
elif mime_type:
content = f"[attachment: {mime_type}]"
else:
content = "[attachment]"
else:
# No text, no attributedBody, no attachments, not retracted, not audio
# This should not happen — but don't silently drop, flag it
return None
# Build message dict
msg = {
"ts": ts,
"sender_label": sender_label,
"sender": sender_key,
"is_from_me": bool(is_from_me),
"text": content,
"decoded": is_decoded,
"type": msg_type,
}
# Pitfall #14 complement: flag edited messages (content IS present, but was modified)
if date_edited and date_edited > 0:
msg["edited"] = True
# Service type (iMessage vs SMS)
if service and service != "iMessage":
msg["service"] = service
# Message effects (slam, loud, gentle, invisible ink)
if send_style:
effect = _SEND_EFFECTS.get(send_style, send_style)
msg["effect"] = effect
# Inline quote context — resolve thread_originator_guid to quoted message
if thread_guid and guid_index:
quoted = guid_index.get(thread_guid)
if quoted:
msg["reply_to"] = quoted
return msg
def _print_messages(conn: sqlite3.Connection, args: argparse.Namespace) -> None:
"""Print messages with attributedBody decoding and full metadata extraction."""
query, params = build_query(args)
# Build GUID index for resolving inline quotes (thread_originator_guid)
guid_index = _build_guid_index(conn, args.chat)
cur = conn.cursor()
cur.execute(query, params)
# Collect all resolved messages into a list
# Track skipped counts for summary
messages = []
skipped_retracted = 0
skipped_empty = 0
seen_ts = set() # deduplicate rows from attachment JOIN
for row in cur:
ts = row[0]
is_from_me = row[1]
text = row[2]
# Deduplicate: LEFT JOIN on attachments can produce duplicate rows
# for messages with multiple attachments. Keep only the first.
dedup_key = (ts, is_from_me, text or "")
if dedup_key in seen_ts:
continue
seen_ts.add(dedup_key)
msg = _resolve_message(row, args.me_label, args.them_label, guid_index)
if msg:
messages.append(msg)
else:
# Classify why the message was skipped
date_retracted_val = row[5]
date_edited_val = row[6]
row_text = row[2]
row_attr = row[3]
is_retracted = (
(date_retracted_val and date_retracted_val > 0)
or (date_edited_val and date_edited_val > 0
and (not row_text or len(row_text.strip()) == 0)
and (not row_attr or len(row_attr) < 50))
)
if is_retracted:
skipped_retracted += 1
else:
skipped_empty += 1
search_lower = args.search.lower() if args.search else None
context_n = args.context or 0
# Determine which messages to output
if search_lower:
# Find matching indices
match_indices = set()
for i, msg in enumerate(messages):
if search_lower in msg["text"].lower():
match_indices.add(i)
if not match_indices:
if args.export:
print(f"No messages matching '{args.search}' for chat: {args.chat}", file=sys.stderr)
else:
print(f"No messages found for chat: {args.chat}", file=sys.stderr)
return
# Expand with context windows
output_indices = set()
for idx in match_indices:
start = max(0, idx - context_n)
end = min(len(messages) - 1, idx + context_n)
for i in range(start, end + 1):
output_indices.add(i)
output_indices = sorted(output_indices)
else:
output_indices = list(range(len(messages)))
match_indices = set()
# Export to NDJSON if requested
if args.export:
_export_ndjson(args.export, messages, output_indices)
return
# Print to stdout
count = 0
decoded_count = 0
for pos, idx in enumerate(output_indices):
# Insert context separator for non-contiguous groups
if context_n and pos > 0 and output_indices[pos] > output_indices[pos - 1] + 1:
print("--- context ---")
msg = messages[idx]
prefix = "[decoded] " if msg["decoded"] else ""
# Mark search matches with [match] when using --context
if context_n and idx in match_indices:
prefix = "[match] " + prefix
# Show reply context inline
reply_info = ""
if "reply_to" in msg:
rt = msg["reply_to"]
# Truncate quoted text for display
quoted_text = rt["text"][:60] + "..." if len(rt["text"]) > 60 else rt["text"]
reply_info = f" [replying to {rt['sender']}: \"{quoted_text}\"]"
# Show edit/effect flags
flags = ""
if msg.get("edited"):
flags += " [edited]"
if msg.get("effect"):
flags += f" [{msg['effect']}]"
if msg.get("service"):
flags += f" [{msg['service']}]"
print(f"{msg['ts']}|{msg['sender_label']}|{prefix}{msg['text']}{reply_info}{flags}")
count += 1
if msg["decoded"]:
decoded_count += 1
# Print summary to stderr
if count > 0:
summary = f"--- {count} messages ({decoded_count} decoded from attributedBody)"
if match_indices:
summary += f", {len(match_indices)} matches"
if skipped_retracted:
summary += f", {skipped_retracted} retracted excluded"
if skipped_empty:
summary += f", {skipped_empty} empty skipped"
summary += " ---"
print(f"\n{summary}", file=sys.stderr)
else:
print(f"No messages found for chat: {args.chat}", file=sys.stderr)
def _export_ndjson(path: str, messages: list[dict], indices: list[int]) -> None:
"""Export messages to NDJSON (.jsonl) file with full metadata.
Each line is a JSON object with:
- ts, sender, is_from_me, text, decoded — core fields (always present)
- type — "text", "audio", "attachment" (always present)
- edited — true if message was edited after sending (optional)
- service — "SMS" if not iMessage (optional)
- effect — send effect name like "slam", "loud" (optional)
- reply_to — {ts, sender, text} of the quoted message (optional)
Retracted messages are NEVER exported — they are filtered in _resolve_message.
"""
count = 0
with open(path, "w", encoding="utf-8") as f:
for idx in indices:
msg = messages[idx]
record = {
"ts": msg["ts"],
"sender": msg["sender"],
"is_from_me": msg["is_from_me"],
"text": msg["text"],
"decoded": msg["decoded"],
"type": msg["type"],
}
# Optional metadata — only include if present
if msg.get("edited"):
record["edited"] = True
if msg.get("service"):
record["service"] = msg["service"]
if msg.get("effect"):
record["effect"] = msg["effect"]
if "reply_to" in msg:
record["reply_to"] = msg["reply_to"]
f.write(json.dumps(record, ensure_ascii=False) + "\n")
count += 1
print(f"Exported {count} messages to {path}", file=sys.stderr)
if __name__ == "__main__":
main()