Skip to main content
Glama
jlgao2

corpus-crm

by jlgao2
README.md
# corpus-crm

A personal message-archive pipeline. It ingests your own exports from several
platforms, resolves the same human across them, builds a local
[DuckDB](https://duckdb.org) corpus, and serves ~30 read-only views over it —
plus a small CRM layer for annotating people and an
MCP (Model Context Protocol) server so an LLM can query the corpus.

Everything runs locally. Nothing is uploaded anywhere.

## Try it without any data

A fully synthetic demo dataset ships in `pipeline/output-demo/`:

```bash
npm install
DATA_DIR=./pipeline/output-demo node pipeline/serve.js
# http://127.0.0.1:8765
```

Or:

```bash
docker build -t corpus-crm . && docker run -p 8080:8080 corpus-crm
```

**The demo is complete.** A synthetic corpus ships too
(`pipeline/output-demo/raw/messages.duckdb`, ~4.6 MB — 994 messages across 13
invented people, with photos, places, events, calls and birthdays), so every
view renders, including the ones that query the database. Regenerate it with
`npm run demo:build-db`.

None of it is real: the cast, the messages, the phone numbers and the cities
are all generated by `scripts/generate-demo-data.py` and
`scripts/generate-demo-db.mjs` from a fixed seed.

## Building a real corpus

You supply the exports; nothing is scraped on your behalf:

| Source | What you need |
|---|---|
| iMessage | `chat.db` from a Mac |
| WhatsApp | an iOS backup, or the live bridge (see caveat) |
| Instagram / Messenger | Meta "Download Your Information", JSON format |
| Gmail | Google Takeout `.mbox` |
| Photos | Apple Photos or Google Takeout |

Then:

```bash
npm run build-db     # ingest -> identity resolution -> DuckDB corpus
npm run release      # publish it as an immutable, activated release
npm run serve
```

`npm run release` stages the build into `pipeline/releases/<timestamp>-<sha>`,
verifies the database checksum, refuses to publish a corpus that went
*backwards* (fewer messages than the live one), then swaps the
`pipeline/output` symlink atomically so a running server never sees a
half-written corpus. If the new release fails its health check it rolls back
to the previous one by itself. Set `RESTART_CMD` if you run the viewer as a
service; leave it unset and restart by hand.

Old releases accumulate — `npm run release:prune` shows what it would delete,
`npm run release:prune:apply` does it.

Set `SELF_NAME` to your own display name — identity resolution uses it to
recognise you in your own threads.

## Automating the exports

Two of the sources can be kept topped up without you clicking through export
flows every week. Both are macOS-oriented and both are optional — the manual
export path in the table above always works.

### Meta (Facebook + Instagram)

`pipeline/meta-dyi.mjs` drives Meta's "Download Your Information" flow in a
real browser. Three subcommands:

```bash
node pipeline/meta-dyi.mjs setup     # once: log in, by hand, in a headed window
node pipeline/meta-dyi.mjs request   # ask Meta to build an export
node pipeline/meta-dyi.mjs poll      # download anything that is ready, and unzip it
```

`setup` opens Google Chrome, waits while you sign in to **both**
facebook.com and instagram.com, and keeps the session in a persistent profile
at `~/.meta-dyi-profile`. Nothing else works until that exists.

`request` asks only for what you are missing: it reads the newest message
already in your corpus per source and picks the smallest date-range preset
that covers the gap, so a weekly run fetches a week rather than your whole
history. It skips any profile requested in the last seven days, so running it
by hand is harmless.

`poll` downloads every export Meta has finished into `META_DYI_DROPS_DIR`,
unzips it into `META_DYI_INPUTS_DIR`, and links it where the ingesters expect
it. Meta takes hours to days to build an export, so `poll` finding nothing is
the normal case.

Schedule both (Mondays 04:00 request, daily 04:30 poll):

```bash
node pipeline/install-meta-launchd.js install     # macOS launchd
node pipeline/install-meta-launchd.js uninstall
```

| Variable | Meaning |
|---|---|
| `META_DYI_DROPS_DIR` | where downloaded `.zip` exports land |
| `META_DYI_INPUTS_DIR` | where they are unzipped for ingest |
| `META_DYI_PASSWORD` | Meta re-asks for your password mid-flow; supply it here, or store it in the macOS Keychain as service `meta-dyi` |
| `META_DYI_PROFILE` | `facebook` or `instagram` — run just one |
| `META_DYI_DRYRUN=1` | configure the export but do not submit it |
| `META_DYI_CHANNEL` | `chromium` instead of system Chrome |

Needs `playwright` and Google Chrome, so install dev dependencies
(`npm install`, not `npm ci --omit=dev`).

**It will break.** This automates a website Meta redesigns without warning;
when a selector moves, the run fails and writes a screenshot next to the logs
so you can see which step lost its footing. Treat it as a convenience, not
infrastructure — and if a run fails, the manual export flow still works.

### iMessage

There is no automation here, and deliberately so: reading the message
database is a Full Disk Access grant, not something to hide inside a cron
job. `pipeline/ingest/imessage.js` parses the output of
[imessage-exporter](https://github.com/ReagentX/imessage-exporter):

```bash
brew install imessage-exporter
# System Settings → Privacy & Security → Full Disk Access → add your terminal
imessage-exporter -f txt -o inputs/imessage
npm run build-db
```

One file per conversation in `inputs/imessage/`, named by phone number or
email. Re-running the exporter and rebuilding is the whole update loop.


## MCP server

`pipeline/mcp/` exposes the corpus to an LLM over MCP (Model Context
Protocol) — nine tools: `resolve_person`, `search_messages`,
`get_conversation_window`, `person_summary`, `on_this_day`, `query` (read-only
SQL), `schema`, `log_interaction`, `set_follow_up`.

It speaks stdio by default. Point a client at it:

```json
{ "command": "node",
  "args": ["pipeline/mcp-server.js"],
  "env": { "MCP_DUCKDB_PATH": "pipeline/output/raw/messages.duckdb" } }
```

`npm run mcp:demo` runs it against the synthetic corpus, so you can try the
tools before pointing it at anything of your own. Set `MCP_HTTP_PORT` and
`MCP_TOKEN` for Streamable HTTP instead of stdio; it binds `127.0.0.1` only
unless you also set `MCP_BIND_TAILNET=1`.

The `query` tool accepts a single read-only `SELECT`/`WITH`. Everything the
server carries out of the database passes through `pipeline/lib/redactions.js`
— add patterns there for topics you never want leaving the archive.

## Layout

```
pipeline/ingest/      one module per source
pipeline/normalize/   identity resolution, merging, folding
pipeline/lib/         the view modules (atlas, weeks, braid, almanac, field…)
pipeline/serve.js     the viewer — every route in one file
pipeline/crm-server/  annotation store (SQLite)
pipeline/mcp/         MCP server exposing the corpus to an LLM
pipeline/output-demo/ synthetic demo data (JSON artifacts + a DuckDB corpus)
scripts/release.sh    publish a build as an activated release
scripts/generate-demo-*  regenerate the synthetic demo dataset
```

## Single machine

This runs entirely on one machine. Earlier versions pushed releases to a
second always-on host over SSH; that has been removed — `release.sh` does the
staging, verification and atomic swap locally.

## Caveats

- **macOS-leaning.** The core ingest → build → serve path is portable;
  thumbnailing, notifications and service installation are macOS-only.
- **The WhatsApp live bridge** (`pipeline/whatsapp-baileys/`) pairs as a
  companion device through [Baileys](https://github.com/WhiskeySockets/Baileys),
  an unofficial WhatsApp Web client. This is against WhatsApp's terms of
  service and accounts have been banned for it. It ships because archiving
  your own conversations is a legitimate thing to want and there is no
  official export worth the name — but the risk is yours, and it is real. The
  other ingesters all read official exports and carry no such risk.
- **Requires Node 22+.** Native modules (`better-sqlite3`, `@duckdb/node-api`)
  build on install.
- This is a personal instrument published as a reference, not a supported
  product. Expect rough edges.

## Privacy

An archive like this is the most sensitive data you own, and most of it is
about people who never opted in. `.gitignore` is deliberately aggressive:
raw exports, the database, portraits, face labels and annotations are all
excluded. Keep it that way. Nothing in this repository contains real
personal data — every name in the tests and demo data is fictional.

## License

[PolyForm Noncommercial 1.0.0](./LICENSE.md) — any noncommercial use is
permitted, including personal, hobby, research and study use. Commercial use
is not granted.