Presence
A private conversational companion built from a WhatsApp chat history. It answers in one person's voice, grounded in what they actually wrote, and runs entirely on my own machine.
- Python
- Ollama
- SQLite
- FastAPI
- pydantic
- tests pass (1 skipped by design)
- 133
- tests pass (1 skipped by design)
- cloud model backends it can build
- 0
- cloud model backends it can build
- LLM roles, each bounded by code
- 5
- LLM roles, each bounded by code
Problem
Presence turns a long WhatsApp chat history into a companion that answers in one person's voice, grounded in what they actually wrote. Two things make this harder than a chatbot with a long prompt. The person's past has to stay fixed while new conversations add memories on top of it. And the model has to stay in character without the user ever seeing it being corrected.
The chat is private, so none of it may leave the machine.
Constraints
- Local only. No cloud model. The config has no cloud option at all, and a second allowlist rejects any backend that isn't local. Every hard-coded model URL is 127.0.0.1 or localhost.
- Messy exports. WhatsApp exports come in iOS and Android formats, with 12- or 24-hour clocks, multiline messages, invisible marks, edits and media placeholders, and no timezone.
- The past is read-only. New memories must never change what the person actually said.
- One local model. It runs on a local model (gemma4:31b by default) through Ollama, llama.cpp or LM Studio.
Design
Persona Model
Writes the reply in the person's voice from the Persona Card, the retrieved anchors and the last 20 turns. Only local backends can be constructed: there is no cloud option in the config, and a second allowlist rejects anything else.
LOCAL_BACKENDS: frozenset[Backend] = frozenset(
{
Backend.GEMMA4_31B,
Backend.GEMMA4_26B,
Backend.GPT_OSS_20B,
...
}
)
if role.backend not in LOCAL_BACKENDS:
raise UnsupportedBackendError(...)At build time, a tolerant parser reads the export and a quality gate decides whether anything else may run. The gate passing unlocks the Persona Card (how the person writes and what they value) and the Archive, an index of what they wrote. For each message, a router decides whether to ground the reply, a retriever pulls up to four real messages, the persona model writes the reply, and a guardrail checks it against the Persona Card. When a session ends, a reflective loop turns the conversation into new facts in a separate Living store.
Every LLM call is one request that returns text or JSON. Code decides what happens with it:
| LLM role | What the LLM decides | What code decides |
|---|---|---|
| Persona voice | The wording of the reply | What it sees: the Persona Card, up to 4 retrieved messages and the last 20 turns |
| Consistency judge | Whether a reply contradicts the Persona Card | Whether to regenerate (at most once, 0.2 cooler), and that none of it reaches the conversation |
| Reflection extractor | New facts, a one-line summary and the tone | Whether each fact is added, supersedes an old one, or is skipped |
| Persona distiller | Values, topics and boundaries, from a seeded 300-message sample | Speech tics and emoji habits, which are measured rather than generated |
| Groundedness judge (eval only) | The share of a reply's claims supported by its context | Nothing; it only scores |
Key decisions
- A physically read-only Archive. The Archive's SQLite file is opened in
mode=ro, so a write fails at the database driver rather than relying on code discipline. The reflective loop also refuses to start if it is handed a read-only store. - Append-only, versioned memory. A changed fact supersedes the old version instead of overwriting it, and a rollback writes a tombstone. Every fact records where it came from: the archive, a conversation, or something I supplied.
- The quality gate is a hard stop. Distillation, training prep, chat, serving and evaluation all refuse to run until the export passes. The parser refuses to guess when a file contradicts itself about DD/MM versus MM/DD dates.
- A silent guardrail. The judge runs at temperature 0. If a reply contradicts the Persona Card, the model regenerates once at a lower temperature, and the user never sees the check. If the judge's answer can't be parsed, the reply goes through, so a broken judge never blocks a conversation.
- Local by construction, not by setting. Both the config schema and the backend factory exclude cloud models, so adding one would take a code change, not a config edit.
Evidence
- 133 tests pass and 1 is skipped by design. Ruff reports no findings, and strict mypy is clean with the server extra installed.
- 4,803 lines of typed Python across eight packages, behind one command-line tool.
- The pipeline has run end to end on a real export, from parsing to a reflection after a session. That data stays private and isn't shown here.
- Fidelity is not measured yet. The evaluation harness is built (stylometric similarity, groundedness and persona consistency), but I haven't recorded a run, and there is a leak to fix first. See below.
What I'd change
- Record a demo on the synthetic sample chat that ships with the tests, and show that instead of real data.
- Train the voice adapter. The QLoRA dataset and manifest are ready, but no backend can load an adapter yet.
- Persist the memory log, so rollback history survives a restart.