dunbar-app
v0.1.0
Published
Local-only macOS CLI that exports per-person iMessage frequency to a CSV for import into the Dunbar relationship app, with an optional single-use LAN QR handoff to auto-import on iPhone.
Downloads
40
Maintainers
Readme
dunbar-app
A small, local-only macOS command-line tool that reads your message history off-device and exports a CSV of per-person message frequency for import into the Dunbar relationship app — plus an optional single-use LAN QR handoff that sends the CSV straight to the Dunbar iPhone app over Wi-Fi.
iOS can't expose iMessage frequency to third-party apps, so Dunbar reads it off the Mac instead: this helper aggregates how often you talk to each person from the local Messages database and writes a CSV. You then either scan a QR to import it directly on your phone, or move the CSV across manually.
Run it with no install:
npx dunbar-appWhat it does
- Reads the macOS Messages database (
~/Library/Messages/chat.db) read-only. - For each person you have a 1:1 conversation with, computes:
- total message count (both directions) over a lookback window (default 5 years), and
- the last-contacted date (most recent message).
- Skips group chats — group activity is a weak closeness signal.
- Resolves phone numbers / emails to contact names via your local macOS Contacts (AddressBook), best-effort, and merges multiple handles (e.g. a person's phone + iCloud email) into a single row when Contacts links them.
- Writes a CSV with one row per person.
- By default, serves that CSV once over your LAN and prints a QR code so the Dunbar app can
pull it directly (see LAN QR handoff). Use
--no-serveto skip.
Privacy
Everything stays on your machine. The helper:
- opens the Messages database and Contacts read-only (
better-sqlite3inreadonlymode withquery_only = ON), so it can never modify your databases; - makes no outbound/internet calls of any kind — there is no telemetry, no upload, nothing leaves your computer;
- the optional QR handoff is LAN-only, guarded by a single-use random token, and serves exactly one file once before shutting down;
- produces exactly one artifact: the CSV file you choose (default
~/Desktop/dunbar-messages.csv).
Requirements
- macOS with Node.js ≥ 18.
- Full Disk Access for the program that runs the helper (Terminal, iTerm, or your IDE),
because
~/Library/Messages/is protected by macOS.
The only native dependency, better-sqlite3, ships prebuilt binaries, so npx dunbar-app
installs and runs without a C/C++ compiler.
Grant Full Disk Access
- Open System Settings → Privacy & Security → Full Disk Access.
- Click + and add the app you'll run the helper from (e.g. Terminal or iTerm).
- To resolve contact names too, this same Full Disk Access also covers AddressBook.
- Toggle it on.
- Fully quit and reopen that app (the permission only takes effect on relaunch).
- Run the helper again.
If access isn't granted, the helper exits with a clear message and a CSV is not written.
Usage
# Default: 5-year lookback, output to ~/Desktop/dunbar-messages.csv, then serve + QR.
npx dunbar-appOptions
dunbar-app [output] [--years N] [--chat-db PATH] [--no-contacts] [--no-serve]
output CSV output path (default: ~/Desktop/dunbar-messages.csv)
--years N Lookback window in years (default: 5)
--chat-db PATH Path to a chat.db (default: ~/Library/Messages/chat.db; handy for testing)
--no-contacts Skip Contacts name resolution; use raw handles (no cross-handle merging)
--no-serve Skip the LAN QR handoff; only write the CSV
-h, --help Show this help and exitExamples:
npx dunbar-app ~/Desktop/my-export.csv # custom output path
npx dunbar-app --years 2 # only the last 2 years
npx dunbar-app --no-serve # just write the CSV, no QRCSV schema
The Dunbar importer depends on this exact header and order:
name,phone,email,source,message_count,last_contacted_at| Column | Meaning |
| ------------------- | ----------------------------------------------------------------------- |
| name | Contact display name; falls back to the raw handle if not in Contacts. |
| phone | Normalized phone (digits, leading + kept). Blank if the handle is an email. |
| email | Email / Apple ID. Blank if the handle is a phone number. |
| source | imessage (future: whatsapp). |
| message_count | Total messages exchanged (both directions) in 1:1 chats. |
| last_contacted_at | Most recent message, ISO 8601 UTC, e.g. 2025-03-14T00:00:00Z. |
Rows use CRLF line endings and minimal quoting (byte-compatible with the original Python
tool's csv output).
Sample output:
name,phone,email,source,message_count,last_contacted_at
Alice Anderson,+15551112222,[email protected],imessage,42,2026-06-29T04:59:41Z
Bob Brown,+15553334444,,imessage,8,2026-06-20T04:59:41Z
+15557778888,+15557778888,,imessage,5,2026-03-22T04:59:41Z(Alice Anderson shows the merge: her phone-number chat and her iCloud-email chat were
combined into one person, with both phone and email populated and message counts summed.)
LAN QR handoff
After writing the CSV, the helper (unless --no-serve is passed) serves the file once over
your local network and prints a QR code. Open the Dunbar app on a phone on the same Wi-Fi,
scan it, and the CSV imports directly — no AirDrop or Files round-trip.
What you'll see in the terminal:
- the CSV path,
- a QR code encoding the deep link,
- the raw deep link as text, and
- a "scan in the Dunbar app, same Wi-Fi" hint.
Shared contract (Dunbar app ↔ helper)
- Deep link in the QR:
dunbar://import?host=<LAN_IPv4>&port=<PORT>&token=<TOKEN> - The app fetches:
http://<host>:<port>/messages.csv?token=<TOKEN> - Server behavior:
GET /messages.csvwith a matching token (via?token=query or anX-Dunbar-Tokenheader) →200,Content-Type: text/csv; charset=utf-8, body = the CSV.- Token mismatch/missing →
403. Any other path →404. - Single-use: after the first successful
200for/messages.csv, or after a 5-minute idle timeout, the server shuts down and the process exits cleanly — printingSent ✓on success orTimed outotherwise. - LAN-only, bound to
0.0.0.0on a random free port; no outbound network calls.
The token is a random URL-safe value with ≥16 bytes of entropy, minted fresh on every run.
Tip: macOS may pop up a firewall prompt the first time Node accepts an incoming connection. Allow it (or temporarily allow incoming connections) so the phone can reach the handoff server on your LAN.
Manual fallback (no QR)
If you used --no-serve or QR import isn't convenient:
- Run the helper to produce the CSV (e.g.
~/Desktop/dunbar-messages.csv). - AirDrop the CSV from your Mac to your iPhone (right-click → Share → AirDrop), or save it to Files.
- In Dunbar, run the in-app file import.
How it works (timestamps & 1:1 detection)
- Timestamps: modern
chat.dbstoresmessage.dateas nanoseconds since 2001-01-01 UTC (Mac absolute time); older databases use seconds. The helper detects and converts both defensively (seesrc/utils.js). - 1:1 detection: a chat is 1:1 when its participant set in
chat_handle_joinis exactly one other handle. Group chats join to multiple handles and are excluded.
Architecture (multi-source)
The code is structured so additional message sources drop in behind one interface and merge into the same CSV.
bin/
dunbar-app.js executable entry point (the npm `bin`)
src/
sources/
base.js MessageSource interface: isAvailable() + fetchContacts(since)
imessage.js iMessage / SMS reader (implemented)
whatsapp.js WhatsApp reader (STUB + full implementation notes)
contacts.js handle -> name resolution via AddressBook (best-effort)
pipeline.js merge handles/sources into one Person per real contact
csvWriter.js exact-schema CSV output
serve.js single-use LAN QR handoff server
sqlite.js read-only SQLite access (better-sqlite3)
cli.js argument parsing + orchestration
utils.js timestamp conversion + phone/email normalization
models.js shared handle-type constantsEach source emits SourceContact records; pipeline.mergeContacts collapses them into one
row per person; csvWriter writes the schema. The pipeline never needs to know which source
the data came from.
WhatsApp extension point (not implemented yet)
src/sources/whatsapp.js is a ready-to-fill stub. Its isAvailable() returns false, so it's
skipped today. The file documents the full plan: WhatsApp's native macOS app stores a Core Data
SQLite at
~/Library/Group Containers/group.net.whatsapp.WhatsApp.shared/ChatStorage.sqlitewith tables ZWACHATSESSION (sessions; 1:1 JIDs end in @s.whatsapp.net, groups in @g.us)
and ZWAMESSAGE (messages). Implementing fetchContacts there — open read-only, filter to 1:1
sessions, COUNT/MAX(date), convert Core Data timestamps with the existing
utils.macAbsoluteToDate — and registering it in cli.buildSources makes WhatsApp a drop-in
that emits rows with source="whatsapp" into the same CSV.
Dependencies
better-sqlite3— read-only access tochat.dband the AddressBook. Chosen because it ships prebuilt binaries (no compiler needed fornpx), has a simple synchronous API that fits this batch tool, and opens databases read-only so the live Messages store is never modified.qrcode-terminal— tiny, pure-JS QR renderer for the LAN handoff; no native code.
Tests / verification
A synthetic fixture mimics the real chat.db schema (handle, chat, message,
chat_message_join, chat_handle_join) plus an AddressBook, so the full query → aggregation →
CSV path can be verified without Full Disk Access:
npm install # once, to fetch better-sqlite3 + qrcode-terminal
npm test # runs node --test over test/*.test.jsTo exercise the CLI by hand against the fixture:
# build the fixture
node test/makeFixture.js /tmp/dunbar_fixture
# run the helper against it (CSV only)
DUNBAR_ADDRESSBOOK_GLOB="/tmp/dunbar_fixture/AddressBook/Sources/*/AddressBook-v22.abcddb" \
node bin/dunbar-app.js /tmp/out.csv --chat-db /tmp/dunbar_fixture/chat.db --no-serveThe fixture exercises: group-chat exclusion, lookback filtering, both nanosecond and legacy-second timestamps, contact-driven handle merging, raw-handle fallback, exact CSV schema/format, and the LAN handoff server's token/path/single-use contract.
Publishing
This package is intended to be published to the public npm registry so anyone can run
npx dunbar-app. Publishing requires npm credentials, so a human must run these steps:
# 1. Log in to npm (one-time per machine / session)
npm login
# 2. From the package root, publish publicly
cd /Users/ryan.holmes/Workspace/Dunbar/dunbar-helper
npm publish --access publicNotes:
- The package name
dunbar-appwas confirmed available on the public npm registry at the time of writing. - If your default npm registry is a private proxy, publish to public npm explicitly with
npm publish --access public --registry https://registry.npmjs.org/. - Bump
versioninpackage.json(it starts at0.1.0) before each subsequent publish.
