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.

callersphone_numberTEXTfirst_seen_atTEXTcallscall_sidTEXTFK phone_numberTEXTstarted_atTEXTended_atTEXTfactsidINTEGERFK phone_numberTEXTcategoryTEXTfactTEXTFK call_sidTEXTcreated_atTEXTtranscriptsidINTEGERFK call_sidTEXTroleTEXTcontentTEXTcreated_atTEXTbookingsidINTEGERFK phone_numberTEXTFK call_sidTEXTnameTEXTemailTEXTtopicTEXTstart_atTEXTstatusTEXTevent_id / event_linkTEXToutreachphone_numberTEXTnameTEXTtopicTEXTmessageTEXTstatusTEXTsms_consentphone_numberTEXTstatusTEXTsourceTEXTupdated_atTEXTsettingskeyTEXTvalueTEXTupdated_atTEXTplacesis described byrecordsproducesrequestsgrants texting viais targeted byNotationexactly onezero or oneone or manyzero or manyunderlined attribute = primary key · FK = correlational key (convention, not enforced)many-to-many: a foot on both ends, resolved through a junction table.None exists in this schema; every relationship hangs off one person or one call.
The full schema in crow's-foot notation. A caller places calls, calls record transcripts and produce facts, and one-row-per-number tables (consent, outreach) hang directly off the person.
TableWhat it holds
callersEvery phone number seen, with first-contact time: the root entity the rest keys on.
callsOne row per call: number, direction, timing. Feeds the admin call views and the abuse policy's rate window.
factsPer-number memory written by save_user_fact: who they are, what they wanted. Context on the next contact, never identity (see the naming rule).
transcriptsFull both-sides transcripts, keyed by call, expandable in the admin views.
bookingsIntro-call requests with status (pending until the owner confirms), contact details, and times.
outreachThe owner-directed contact ledger: who Elle reached out to, why, and thread status: how a reply gets recognized.
sms_consentThe A2P consent ledger: who opted in to texts, how, and when.
settingsSmall 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:

src/knowledge.ts
/* 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

ModeUsed byTrade
toolWeb chat, SMS, cascaded voiceThe model calls search_resume and answers only from what it returns. Cheapest prompts, one retrieval round-trip.
inlineRealtime phoneThe 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.