Skip to content
Roy Yang
Prototype2026

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

ONE-MESSAGE REPLAY
Tests
133 pass · 0 lint
Cloud calls
NONE
Speed:
Model server: 127.0.0.1 only
Click any node to inspect itswipe →
INGESTWhatsApp ExportiOS + Android parserINGESTQuality Gatehard stopPERSONAPersona CardLLM + stylometryMEMORYArchive StoreSQLite mode=roRUNTIMERouter + Retrievertop-4 anchorsRUNTIMEPersona Modellocal · 127.0.0.1RUNTIMEConsistency Guardrailsilent · regen ≤ 1REFLECTIONReflective Loopafter the sessionMEMORYLiving Storeappend-onlyREFLECTIONOpt-in Interviewoff → deep
One message, replayed (example is synthetic)step 1 / 11
Runtime

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.

Backends
local only (allowlist)
Default model
gemma4:31b
Temperature
0.6
History kept
20 turns
The LoRA voice adapter is not trained yet: the dataset and manifest are prepared, but load_adapter raises NotImplementedError in every backend.
Tools
Ollamallama.cppLM Studiogemma4:31b
Core logicpresence/models/factory.py
LOCAL_BACKENDS: frozenset[Backend] = frozenset(
    {
        Backend.GEMMA4_31B,
        Backend.GEMMA4_26B,
        Backend.GPT_OSS_20B,
        ...
    }
)
if role.backend not in LOCAL_BACKENDS:
    raise UnsupportedBackendError(...)
Connections
Python in src/
4,803 lines
8 packages, one CLI
Tests
133 pass
1 skipped by design · ruff 0 findings
Network
local only
every model URL is 127.0.0.1 / localhost
Fidelity eval
not run yet
harness built; held-out leak to fix first

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 roleWhat the LLM decidesWhat code decides
Persona voiceThe wording of the replyWhat it sees: the Persona Card, up to 4 retrieved messages and the last 20 turns
Consistency judgeWhether a reply contradicts the Persona CardWhether to regenerate (at most once, 0.2 cooler), and that none of it reaches the conversation
Reflection extractorNew facts, a one-line summary and the toneWhether each fact is added, supersedes an old one, or is skipped
Persona distillerValues, topics and boundaries, from a seeded 300-message sampleSpeech tics and emoji habits, which are measured rather than generated
Groundedness judge (eval only)The share of a reply's claims supported by its contextNothing; 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.

Skills this project shows