Data and knowledge
Two stores power everything Elle knows: a SQLite database for what she remembers (people, calls, bookings, consent), and a markdown knowledge base for what she knows (the owner's actual resume, projects, and writing). Both are deliberately boring technology, and that's the point.
The database
This is the overview; the ERD with every table documented column by column, including who writes and reads each, is on its own page.
One SQLite file in WAL mode, schema created idempotently at import, every query a prepared
statement (src/db.ts). On a single always-warm Cloud Run instance this is
zero-ops and more than fast enough; the documented upgrade path (Firestore or Cloud SQL) is
for when durability across revisions matters more than simplicity.
| Table | What it holds |
|---|---|
callers | Every phone number seen, with first-contact time: the root entity the rest keys on. |
calls | One row per call: number, direction, timing. Feeds the admin call views and the abuse policy's rate window. |
facts | Per-number memory written by save_user_fact: who they are, what they wanted. Context on the next contact, never identity (see the naming rule). |
transcripts | Full both-sides transcripts, keyed by call, expandable in the admin views. |
bookings | Intro-call requests with status (pending until the owner confirms), contact details, and times. |
outreach | The owner-directed contact ledger: who Elle reached out to, why, and thread status: how a reply gets recognized. |
sms_consent | The A2P consent ledger: who opted in to texts, how, and when. |
settings | Small key-value store for runtime state, e.g. the voice-architecture override. |
Call sessions (in-memory, on purpose)
sessions.ts holds the live state of a call: the pairing code, the
connected browser sockets, pending web events. It's indexed both by call SID and by pairing
code, swept after calls end, and never persisted: a live call is inherently instance-local,
and everything worth keeping (transcripts, facts, bookings) is written to SQLite as it
happens.
The knowledge base: RAG without a vector database
Elle's grounding is a directory of markdown files: resume, projects, skills, interests, and condensed summaries of the owner's published writing. Retrieval is classic BM25 over heading-scoped chunks, built in-process at boot:
/* Local & offline: a BM25 lexical index over markdown chunks. No embedding
calls, no vector store — at this corpus size (dozens to a few hundred
chunks) brute-force scoring is instant. */
// heading-scoped chunking, capped for readability
function chunkMarkdown(source: string, content: string): Omit<Chunk, "id">[] { ... }
// scoring: textbook BM25
score += this.idf(term) * ((f * (this.k1 + 1)) / denom);Why not embeddings: at this corpus size, lexical search is instant, free, fully offline, and debuggable by reading. The index rebuilds on boot from the markdown, so updating Elle's knowledge is editing a text file, and the same files render the website's resume sections, so the site and the assistant literally cannot disagree.
Two grounding modes
| Mode | Used by | Trade |
|---|---|---|
tool | Web chat, SMS, cascaded voice | The model calls search_resume and answers only from what it returns. Cheapest prompts, one retrieval round-trip. |
inline | Realtime phone | The whole (small) knowledge base rides in the system prompt, so the phone answers in one model call with no retrieval hop: latency wins, and prompt caching makes the big prompt nearly free. |
Either way, the persona's rule is the same: anything not in the knowledge base, Elle does not know and says so; invented details are a firing offense for an assistant that represents a real person.
What deliberately is not stored
- No secrets. The database holds conversation data, never credentials. Keys live in Secret Manager and reach the process as env vars.
- No raw audio by default. Call recording exists in the codebase
(
src/recording/: stereo WAV, caller left / Elle right, uploaded to GCS with a JSON sidecar) but ships disabled: enabling it requires the spoken consent notice and a bucket, a consent-first default documented in the code itself. - No cross-person leakage. Facts are scoped to a phone number and injected only into that number's conversations.