Text Like Me
Fine-tuning a local LLM on seven years of my own iMessages

What it is
I built a system that takes my entire iMessage history, turns it into a training set, fine-tunes an 8B parameter Llama model on it, and runs the result locally so I can paste in a text someone sent me and get back a reply that actually sounds like me. Not “sounds like a chatbot pretending to be casual,” but my real punctuation, my real length (my median reply is about 25 characters), my real habit of sending three short messages in a row instead of one long one.
The whole thing runs on hardware I already have. Extraction and inference happen on my laptop. The only step that needs a GPU is training, and that fits on Colab's free T4 tier. Total cost was zero dollars.
The project is four phases: extract, build the dataset, train, and serve. Each one is a script I can re-run independently, and each one prints a report at the end so I can actually look at what it did instead of trusting it.
Phase 1: getting the messages out
macOS stores every message you have ever sent in a SQLite database at ~/Library/Messages/chat.db. Reading it sounds like a solved problem. It is not.
Three things go wrong. First, the database uses write-ahead logging, so the most recent messages live in a separate -walfile and never show up if you just query the main file. I run a full WAL checkpoint before reading so today's messages are included.
Second, timestamps are stored as nanoseconds since January 1, 2001, which is Apple's epoch, not Unix's. Older rows are in seconds instead of nanoseconds, so I detect the magnitude and convert accordingly.
Third, and this is the one that would have killed the project: on recent macOS versions the text column is NULL for most messages. The actual body is buried in attributedBody, a binary NSArchiver “typedstream” blob. Without decoding it, everything from roughly 2023 onward comes out blank, which is exactly the data I care about most.
I wrote a minimal decoder rather than pulling in a full typedstream parser. I only need the first NSString in the archive, which is the message body. The format is a + type marker followed by a variable-width length integer (one byte for short strings, or a 0x81 / 0x82 / 0x83 prefix signaling a 2, 4, or 8 byte little-endian length) and then raw UTF-8. About 80 lines total.
The part I am most happy with is how I validated it. There are 133,786 messages that have both a populated text column and an attributedBody blob. I ran the decoder on all of them and compared against the ground truth column. Exact match on 100%. That gave me real confidence in the roughly 95,000 messages where the blob is the only source and there is nothing to check against.
Final result: 228,904 messages spanning April 4, 2019 through July 21, 2026, 97.5% of them with recovered text. Contacts get pseudonymized on the way out using a salted SHA-256, with the salt stored locally and gitignored, so pseudonyms stay stable across re-runs but the JSONL alone does not leak phone numbers.
Phase 2: turning conversations into training examples
This phase mattered more than the training did, which surprised me. A raw message dump is mostly noise, and a model trained on noise learns noise.
The pipeline filters, groups, slices, dedupes, caps, and splits.
Filteringkills tapbacks, system messages, one-time passcodes, delivery notifications, “reply STOP to unsubscribe” spam, five and six digit shortcode senders, and link-only messages. Regexes for each category. A message that is nothing but a URL carries no style signal at all.
Burst grouping is the piece I think is most specific to text messages. I do not write one message, I write three in a row. If you treat those as separate turns, the model learns to answer its own messages. So consecutive messages from the same sender inside a 180 second window merge into a single turn joined by newlines. About a fifth of my training examples end up being multi-message bursts, and the CLI later splits them back out on newlines so replies arrive the way they actually would.
Conversation splitting treats six hours of silence as a boundary. Yesterday's argument is not context for today's “what's up.”
Context windows slide across each conversation, and each training example ends on one of my replies with two to six turns of real context before it. The context always opens with the other person speaking so roles alternate cleanly.
Dedup and capping were necessary because my texting is extremely unbalanced. A handful of people account for a huge share of my messages, and without a cap the model would just learn to talk to my three closest friends. No contact may hold more than 15% of the dataset. Because capping shrinks the total, which lowers the cap again, I iterate to a fixed point instead of capping once. Identical replies are capped at 25 copies, so “lol” does not become 4% of the training signal.
The train/eval split is by time, not random. This is the subtle one. The context windows overlap, so a random split would put nearly-identical examples on both sides of the boundary and give me an eval loss that looks great and means nothing. Holding out the most recent 5% also matches how I actually use the model, which is on new messages.
From 228,904 raw messages I end up with 8,000 clean examples: 7,600 train, 400 eval, plus 40 held-out prompts with the real reply attached so I can eyeball generations side by side. 1,054 distinct contacts survive.
Phase 3: training
LoRA fine-tune of Llama 3.1 8B Instruct in 4-bit via Unsloth, rank 16, on a free T4. Twenty to forty minutes.
Two choices here were deliberate. I train only on my replies, masking the loss on incoming messages. Without that the model spends half its capacity learning to imitate other people, which is not the goal. And I use a learning rate of 1e-4, below Unsloth's usual 2e-4 default, because style transfer needs much less signal than teaching new facts, and too high a learning rate is exactly what makes the model start reciting my old texts word for word.
I measured the token length distribution on the real dataset before picking a sequence length: mean 139, median 133, p99 284. A 512 token window truncates 10 of 7,600 examples, or 0.13%, and runs far lighter on a T4 than 1024 would.
The notebook ends with a verbatim memorization check, which I think is the right success criterion for this kind of project. It generates replies to held-out prompts and searches for six-plus word spans that appear verbatim in the training corpus. A few short hits are fine because I genuinely reuse phrases. Many long hits mean the model is reciting me rather than sounding like me, and the fix is one epoch instead of two.
Phase 4: running it locally
The model exports to GGUF at q4_k_m quantization, about 4.9 GB, and loads into Ollama. text_me.py is a chat CLI with no dependencies outside the standard library. It can sample several alternative replies to the same message, adjust temperature on the fly, and lets me inject my own message to steer a thread.
Before that, verify_ollama.py runs a check I added after getting burned. Ollama 0.31 and later prefers the chat template embedded in the model over the TEMPLATE line in your Modelfile, and it ignores yours silently. I verified this directly. So what you wrote is not necessarily what runs, and a wrong prompt format makes a perfectly good fine-tune sound like a generic assistant again. The script reports which template is actually in force, checks for Llama 3.1 marker tokens (and warns loudly if it finds ChatML markers, meaning you built from the wrong base), flags a duplicated system preamble, and runs a live smoke test that warns if the reply is suspiciously long or contains assistant-speak like “feel free to.”
What I actually learned
The modeling was the easy part. Unsloth makes the training loop about fifteen lines. Almost all of the real work was in the two places nobody talks about: correctly getting data out of a format that was never meant to be read, and designing a dataset where the thing you measure is the thing you want.
The two decisions I would defend hardest are validating the blob decoder against 133,786 rows of ground truth, and splitting train/eval by time. Both are the kind of thing you can skip and still get a number that looks fine. Neither one shows up in the demo. Both are the difference between a result I believe and a result I just hope is true.