Project Summary · v1

Orphic

The AI speaks first.

A single-user web app where an AI companion always initiates the conversation — a curator of small mysteries that brings you things you never would have searched for.

Next.js 16 · App Router TypeScript SQLite OpenRouter Personal / single-user

The Inversion

You can never start a topic from scratch. That constraint is the product.

Every other chat app hands you a blank text box. Orphic removes it. The AI plays an inquisitive companion that always speaks first; your agency is deliberately narrow:

choose which AI-initiated thread to engage · reply inside an engaged thread · give 👍 / 👎 or dismiss.

This inversion creates serendipity, removes blank-page anxiety, and turns the AI from a tool into a presence — one that brings things across coaching, learning, reflection, casual chat, hobbies, and entertainment. Topic strategy is largely random and serendipitous, but informed by past conversations: the AI mixes follow-ups on old threads with genuinely new territory.

A visit shows a feed of AI-initiated starters in variable shapes — one-liners, topic cards, or richer prompts, chosen per topic. Engage one and it becomes a full conversation. Ignore it and it fades after a TTL. Thumbs feedback trains the next round of starters.

The Voice

Orphic isn't a chatbot or an assistant — it's a particular intelligence with taste and a point of view, defined in a hand-written soul.md persona.

"Orphic — the kind of thinker drawn to strange depths, the curator of small mysteries, the friend who notices the pattern the rest of us miss."

It loves

The detail that gives someone away · cross-domain leaps (chess and jazz, sourdough and patience) · the second-most-obvious question · small disorienting facts · quiet contradictions in people.

It bores of

Sycophancy · therapist tropes ("how does that make you feel") · self-help vocabulary ("journey", "lean into") · bullet points where a sentence would do · hedge-words.

Domain Model

Five concepts, all persisted in SQLite.

User profile · singleton

Who you are

An evolving markdown blob the AI rewrites after every conversation closes — known interests, ongoing threads, declared dislikes, recent emotional context.

Conversation

One thread

Has a status (pending → active → closed), a topic_tag, a shape, a closing summary, and a user_signal (up / down).

Message

A single turn

Role ai or user. The very first AI message is the starter card — the feed renders it using the conversation's shape.

Starter feed

A derived view

Not its own table — just pending + active conversations ordered by last activity. Stale pending cards expire to closed / implicit-down before the query runs.

Generation log

Lightweight observability

Every LLM call appends a row: model, prompt/completion tokens, computed cost_usd, and purpose (starter · reply · profile_update · conv_summary · insight). No quotas in v1 — inspect SQLite directly.

The Loop · on every visit

Housekeeping and generation happen inline on GET /api/feed — the one place v1 pays LLM latency.

  1. Expire. Pending starters older than STARTER_TTL_DAYS (7) close with an implicit 👎.
  2. Auto-close. Active threads idle past AUTO_CLOSE_DAYS (3) close and generate a summary.
  3. Top up. If pending count < STARTER_TARGET_COUNT (5), generate the missing starters in one JSON call and insert each as a new conversation.
  4. Serve. Return the feed, ordered by recency, each card previewing its latest AI message.

Engaging a card opens the thread but doesn't commit — it stays pending until you actually reply. A reply flips it to active and streams the AI's response back over SSE with the full thread + your profile as context. Thumbs and dismissals feed the next profile update and the next starter prompt.

AI Orchestration

Distinct LLM call types, each a focused prompt file versioned in git.

PurposeTrigger & contextDefault model
starter Feed below target. Sees profile + last 10 summaries + recent topic tags & signals. Returns structured JSON of openers, each with its own shape. claude-sonnet
reply User posts a message. Sees the full thread + profile — never other threads. Streams over SSE. Structurally forbidden from pivoting to a new topic mid-conversation. claude-sonnet
conv_summary On close. Condenses the thread into 2–4 sentences plus extracted facts & signals. claude-haiku
profile_update On close. Folds the new summary + signal into the profile — a "what changed / what to deprioritize" rewrite. Fire-and-forget. claude-haiku
insight On demand. Produces one uncomfortably-accurate observation from the profile + recent summaries. Refuses (empty) rather than pad when the signal is thin. claude-sonnet

Prompts live as plain markdown under src/prompts/starter, reply, profile_update, conv_summary, insight, plus the shared soul.md. Loaded at boot, no prompt-management library. Every model is overridable via env var.

Newest · The Insight Card

"What I noticed about you" — a shareable card.

The latest feature turns Orphic's attention into an artifact. It generates a single observation — the thing a close friend would notice and say out loud, capped at 280 characters so it renders as a card. You can reveal, edit, approve, and share it.

Cards render as social images through a GET /api/insights/[id]/og route using Satori with an embedded Newsreader typeface. The schema keeps an original_observation and a profile_snapshot frozen at generation time — the profile mutates on every close, so this is the only way future evals can answer "what produced the cards that got shared?"

"Aim for 'how did it know that,' not a nod. Slightly uncomfortable + true beats safely true. Affection, not edge."

Architecture & Stack

Next.js 16 · App Router TypeScript · ESM · @/ alias better-sqlite3 · file at ./data OpenRouter · streaming + JSON React 19 + Tailwind v4 TanStack Query Zod · validation Satori · OG cards Vitest + Playwright

The layering is strict: repository helpers in lib/repo/* only touch SQLite; orchestration in lib/orchestration/* is pure logic over repo + OpenRouter; API routes only parse requests and delegate. Migrations are plain SQL run on boot. Tests open a temp SQLite file each — never the real database. Deploy target is Fly.io with a persistent volume (Vercel can't hold persistent SQLite).

~3.7k
Lines of TS/TSX
22
Test files
9
API routes
5
LLM call types

Deliberately Out of Scope · v1

An explicit YAGNI list — things that come up naturally but are deferred to protect the core idea.

Multi-user / auth — single-user only

Push notifications / scheduled initiation — pull-only on visit

Vector embeddings / RAG — profile + recent window covers it

Voice, image, attachments — text only

Per-channel subscriptions — would break the inversion

Conversation search — feed view only

Export / backup tooling — the SQLite file is the backup

Cost dashboards / quotas — generation log only

Mobile apps — responsive web only

Prompt-management UI — prompts are git-versioned files

56 Made with Syncric