|
1 | 1 | # eros-engine |
2 | 2 |
|
3 | | -> **An AI companion that learns who you are — and feels like a real person doing it.** |
| 3 | +> **An open-source Rust engine for AI companions with memory, relationship state, and structured user insight.** |
4 | 4 | > |
5 | | -> The same intimacy + memory pipeline that powers [Eros](https://eros.etherfun.xyz)'s dating product, open-sourced. Talk to a persona; the engine quietly builds a structured profile of you for matchmaking and runs a six-dimensional affinity model so the companion behaves like a person, not a chatbot. |
| 5 | +> `eros-engine` is the companion-chat core behind [Eros](https://eros.etherfun.xyz), extracted into a standalone service. It turns conversation into three durable signals: a structured user profile, two-layer long-term memory, and a six-dimensional affinity model that changes how a persona behaves over time. |
6 | 6 |
|
7 | 7 | [](https://github.com/etherfunlab/eros-engine/actions/workflows/ci.yml) |
8 | 8 | [](https://www.gnu.org/licenses/agpl-3.0) |
9 | 9 |
|
10 | 10 | English · [中文](README.zh.md) |
11 | 11 |
|
| 12 | +## Why this exists |
| 13 | + |
| 14 | +Most AI character apps treat memory as a prompt append and relationship as a paragraph of instructions. That works for a demo, but it drifts in long sessions and is hard to debug. |
| 15 | + |
| 16 | +`eros-engine` moves those concerns into explicit state: |
| 17 | + |
| 18 | +- **Memory** lives in Postgres + pgvector, split into profile memory and relationship memory. |
| 19 | +- **Affinity** is a numeric vector updated with EMA smoothing and real-time decay. |
| 20 | +- **User insight** is a structured JSONB profile that downstream products can query. |
| 21 | +- **Persona behavior** is planned through a rules-based Persona Decision Engine (PDE), then rendered by an LLM. |
| 22 | + |
| 23 | +The result is not a generic agent framework. It is a focused engine for products where a persona talks to the same user across many sessions: AI companions, journaling companions, coaching agents, language tutors, and character chat. |
| 24 | + |
12 | 25 | ## Key features |
13 | 26 |
|
14 | | -- **Memory that grows with the user** — Two pgvector layers (cross-session profile + per-session relationship callbacks). The persona still remembers what you said weeks ago without re-stuffing the context window every turn. "She remembers" is structural, not prompt-engineered. |
| 27 | +### Two-layer memory |
15 | 28 |
|
16 | | -- **Deterministic affinity, not prompt drift** — Closeness between user and persona is a six-dimensional numeric vector mutated every turn, not a paragraph of "you feel warmer toward the user now" baked into a system prompt. Numbers behave; prompt-injected feelings drift over a long session. |
| 29 | +`eros-engine` stores memory in two semantic scopes: |
17 | 30 |
|
18 | | -- **User profile, structured and queryable** — Each turn quietly mines facts about the user (city, MBTI signals, love values, life rhythm, …) into a JSONB profile with a weighted training level. If you're building a companion, a journaling app, a coaching agent, or anything where actually knowing the user matters, the profile is structured — not a black-box vector — so you can drive whatever logic you want from it. |
| 31 | +| Layer | Scope | Purpose | |
| 32 | +|---|---|---| |
| 33 | +| Profile memory | `user_id`, with `instance_id IS NULL` | Stable user facts shared across sessions and personas. | |
| 34 | +| Relationship memory | `user_id + persona instance` | Callbacks, shared moments, unresolved threads, and relationship-specific context. | |
19 | 35 |
|
20 | | -## What it does |
| 36 | +Embeddings use Voyage `voyage-3-lite` with 512-dimensional vectors. Retrieval runs through pgvector IVFFlat cosine search. |
21 | 37 |
|
22 | | -eros-engine is the conversational layer of a dating platform, carved out as a standalone service. Two things happen in every chat turn: |
| 38 | +### Six-dimensional affinity |
23 | 39 |
|
24 | | -### 1. Profile-building pipeline (`companion_insights`) |
| 40 | +Each chat session has a relationship vector: |
25 | 41 |
|
26 | | -Every user message is mined for facts about the user: city, occupation, interests, MBTI signals, love values, emotional needs, life rhythm, personality traits, matching preferences. These get merged into a single JSONB profile per user with a weighted **training level** that climbs as more dimensions fill in. The profile is structured the way a matchmaker would think — not a vector blob you can't introspect — so it can drive real matchmaking later. |
| 42 | +| Axis | Range | Controls | |
| 43 | +|---|---:|---| |
| 44 | +| `warmth` | -1.0 to 1.0 | Tone and address, from distant to affectionate. | |
| 45 | +| `trust` | 0.0 to 1.0 | Topic depth and willingness to disclose. | |
| 46 | +| `intrigue` | 0.0 to 1.0 | Curiosity and follow-up behavior. | |
| 47 | +| `intimacy` | 0.0 to 1.0 | Nicknames, inside jokes, and callbacks. | |
| 48 | +| `patience` | 0.0 to 1.0 | Tolerance for low-effort or repeated messages. | |
| 49 | +| `tension` | 0.0 to 1.0 | Push-pull, friction, and playful resistance. | |
27 | 50 |
|
28 | | -A user who chats freely for a few hours produces a richer dating profile than one who fills out a form, because they're answering the questions they didn't know were being asked. |
| 51 | +Updates are smoothed with exponential moving average (EMA), so the persona does not jump between emotional states. `intrigue`, `patience`, and `tension` also decay or recover with real time. |
29 | 52 |
|
30 | | -### 2. Six-dimensional affinity (the "feels like a real person" part) |
| 53 | +Relationship labels such as `stranger`, `slow_burn`, `friend`, `frenemy`, and `romantic` emerge from threshold rules. They are internal state, not user-facing badges. |
31 | 54 |
|
32 | | -Most chatbots are stateless. eros-engine isn't. Each chat session carries a six-axis vector that mutates with every turn: |
| 55 | +### Deterministic ghost mechanics |
33 | 56 |
|
34 | | -| Axis | Range | What it controls | |
35 | | -|------|------|------| |
36 | | -| **warmth** | −1.0 ↔ 1.0 | Tone and address — cold to affectionate | |
37 | | -| **trust** | 0.0 ↔ 1.0 | Topic depth, willingness to disclose | |
38 | | -| **intrigue** | 0.0 ↔ 1.0 | Curiosity, follow-up questions | |
39 | | -| **intimacy** | 0.0 ↔ 1.0 | Inside jokes, nicknames, callbacks | |
40 | | -| **patience** | 0.0 ↔ 1.0 | Threshold for short or low-effort messages | |
41 | | -| **tension** | 0.0 ↔ 1.0 | Push-pull, playful friction | |
| 57 | +The same affinity vector drives a deterministic ghost decision. When patience and intrigue drop far enough, the persona can choose not to reply. |
42 | 58 |
|
43 | | -Updates use exponential-moving-average smoothing so the persona doesn't lurch, and three axes (intrigue, patience, tension) decay or recover with real time when no one's around. Five relationship labels — `stranger`, `slow_burn`, `friend`, `frenemy`, `romantic` — emerge from the vector by threshold rule, not by being assigned. They're not user-facing; they shape the system prompt the persona generates from. |
| 59 | +Four protection rules keep this from feeling arbitrary: |
44 | 60 |
|
45 | | -The vector also drives a deterministic **ghost decision** — when patience and intrigue dip past a threshold, the persona simply doesn't reply. With four protection rules layered on top (no ghosting before message 10, no two ghosts in a row, 1-hour cooldown, raised threshold after a recent ghost) it produces the texture of being slightly absent rather than always available. That single mechanic does more for the "talking to a person" feeling than any prompt-engineering trick. |
| 61 | +- no ghosting before message 10; |
| 62 | +- no two ghosts in a row; |
| 63 | +- one-hour cooldown after a ghost; |
| 64 | +- a higher threshold after a recent ghost. |
46 | 65 |
|
47 | | -### Plus a memory layer |
| 66 | +This is implemented as domain logic in Rust, not as a prompt suggestion. |
48 | 67 |
|
49 | | -Two pgvector tables hold what the persona remembers about you: |
| 68 | +### Structured user insight |
50 | 69 |
|
51 | | -- **Profile layer** — cross-session facts (`instance_id IS NULL`), the things any version of any persona could pull up. |
52 | | -- **Relationship layer** — per-session callbacks ("the bookshop you were in that rainy day"), which is what makes someone feel known across weeks of conversation rather than helpfully assistant-shaped. |
| 70 | +The `companion_insights` table stores a JSONB profile per user: city, occupation, interests, MBTI signals, relationship values, emotional needs, life rhythm, personality traits, and matching preferences. |
53 | 71 |
|
54 | | -Embeddings are 512-dimensional via Voyage's `voyage-3-lite`. Retrieval is cosine over IVFFlat. |
| 72 | +Each field contributes to a weighted `training_level`. That makes the profile useful outside the chat loop: matchmaking, onboarding completion, coaching logic, analytics, and product gating can all query structured fields instead of parsing free text. |
55 | 73 |
|
56 | 74 | ## Architecture |
57 | 75 |
|
58 | | -``` |
| 76 | +```txt |
59 | 77 | ┌─────────────────────────────────────────────────────────┐ |
60 | 78 | │ /comp/* HTTP routes ← Supabase JWT middleware │ |
61 | 79 | │ │ │ |
62 | 80 | │ ▼ │ |
63 | | -│ pipeline orchestrator: pre → PDE → handler → chat → post |
| 81 | +│ pipeline orchestrator: load → PDE → handler → chat → post│ |
64 | 82 | │ │ │ |
65 | 83 | │ ┌───────────────────────────────────────┴────────┐ │ |
66 | | -│ │ post-process (background, per turn) │ │ |
67 | | -│ │ • affinity persist (LLM-evaluated 6-dim Δ) │ │ |
68 | | -│ │ • memory (Voyage embed → pgvector upsert) │ │ |
69 | | -│ │ • insight (extract facts → companion_insights) |
| 84 | +│ │ post-process, spawned after reply │ │ |
| 85 | +│ │ • affinity: persist 6D delta + EMA │ │ |
| 86 | +│ │ • memory: Voyage embed → pgvector upsert │ │ |
| 87 | +│ │ • insight: extract facts → JSONB merge │ │ |
70 | 88 | │ └────────────────────────────────────────────────┘ │ |
71 | 89 | └─────────────────────────────────────────────────────────┘ |
72 | 90 | ``` |
73 | 91 |
|
74 | | -Four crates under `crates/`: |
| 92 | +The workspace is split into four crates: |
75 | 93 |
|
76 | 94 | | Crate | Role | |
77 | | -|-------|------| |
78 | | -| `eros-engine-core` | Pure-domain logic — affinity vector math, ghost decision, persona decision engine. Zero I/O. | |
79 | | -| `eros-engine-llm` | OpenRouter chat client + Voyage embedding client + TOML model-config loader. | |
80 | | -| `eros-engine-store` | Postgres + pgvector persistence. All tables namespaced under the `engine` schema. | |
81 | | -| `eros-engine-server` | Axum HTTP service + Supabase JWT middleware + pipeline wiring. | |
82 | | - |
83 | | -Embed `core + llm + store` as a library to build your own service, or run `eros-engine-server` as a standalone HTTP API. |
84 | | - |
85 | | -Deeper docs: |
86 | | -- [Architecture](docs/architecture.md) — crate boundaries, pipeline phases, data flow |
87 | | -- [Affinity model](docs/affinity-model.md) — 6 dimensions, EMA, time decay, relationship labels |
88 | | -- [Ghost mechanics](docs/ghost-mechanics.md) — score formula + protection rules + worked examples |
89 | | -- [Memory layers](docs/memory-layers.md) — profile vs relationship, Voyage, pgvector retrieval |
90 | | -- [Deploying](docs/deploying.md) — Fly.io, Docker compose, bring-your-own-Postgres / IdP |
91 | | -- [API reference](docs/api-reference.md) — every `/comp/*` endpoint |
| 95 | +|---|---| |
| 96 | +| `eros-engine-core` | Pure domain logic: affinity math, ghost decision, PDE, persona types. Zero I/O. | |
| 97 | +| `eros-engine-llm` | OpenRouter chat client, Voyage embedding client, TOML model-config loader. | |
| 98 | +| `eros-engine-store` | Postgres + pgvector persistence, with all tables under the `engine` schema. | |
| 99 | +| `eros-engine-server` | Axum HTTP service, Supabase JWT middleware, OpenAPI docs, and pipeline wiring. | |
| 100 | + |
| 101 | +You can run `eros-engine-server` as an HTTP API, or embed `core + llm + store` directly in your own Rust service. |
| 102 | + |
| 103 | +## Documentation |
| 104 | + |
| 105 | +- [Architecture](docs/architecture.md) — crate boundaries, pipeline phases, data flow. |
| 106 | +- [Affinity model](docs/affinity-model.md) — six dimensions, EMA, time decay, relationship labels. |
| 107 | +- [Ghost mechanics](docs/ghost-mechanics.md) — score formula, protection rules, examples. |
| 108 | +- [Memory layers](docs/memory-layers.md) — profile vs relationship memory, Voyage, pgvector retrieval. |
| 109 | +- [Deploying](docs/deploying.md) — Fly.io, Docker, bring-your-own Postgres / IdP. |
| 110 | +- [API reference](docs/api-reference.md) — every `/comp/*` endpoint. |
92 | 111 |
|
93 | 112 | ## Quickstart |
94 | 113 |
|
| 114 | +Prerequisites: |
| 115 | + |
| 116 | +- Rust toolchain from `rust-toolchain.toml`. |
| 117 | +- Postgres 16+ with the `pgvector` extension. |
| 118 | +- OpenRouter API key. |
| 119 | +- Voyage API key. |
| 120 | +- Supabase JWT secret, or your own `AuthValidator` implementation. |
| 121 | + |
95 | 122 | ```bash |
96 | 123 | git clone https://github.com/etherfunlab/eros-engine |
97 | 124 | cd eros-engine |
98 | | -cp .env.example .env # fill in: DATABASE_URL, OPENROUTER_API_KEY, |
99 | | - # VOYAGE_API_KEY, SUPABASE_URL, |
100 | | - # SUPABASE_JWT_SECRET |
101 | | -docker compose -f docker/docker-compose.yml up |
| 125 | +cp .env.example .env |
102 | 126 | ``` |
103 | 127 |
|
104 | | -Engine listens on `:8080`. OpenAPI/Scalar reference at `/docs`. The official hosted web client (Eros Chat) is closed-source — eros-engine itself runs standalone, bring your own UI. |
| 128 | +Fill in `DATABASE_URL`, `OPENROUTER_API_KEY`, `VOYAGE_API_KEY`, and `SUPABASE_JWT_SECRET`, then run: |
| 129 | + |
| 130 | +```bash |
| 131 | +cargo run -p eros-engine-server -- migrate |
| 132 | +cargo run -p eros-engine-server -- seed-personas examples/personas |
| 133 | +cargo run -p eros-engine-server -- serve |
| 134 | +``` |
105 | 135 |
|
106 | | -For self-hosters running against an existing Supabase project: tables live under the `engine` Postgres schema, so they coexist cleanly with your other tables. |
| 136 | +The server listens on `0.0.0.0:8080` by default. Scalar API docs are available at `/docs`, and the OpenAPI JSON is available at `/api-docs/openapi.json`. |
| 137 | + |
| 138 | +The official Eros Chat web client is closed-source. `eros-engine` is designed to run standalone; bring your own UI or embed the crates in another service. |
107 | 139 |
|
108 | 140 | ## API surface |
109 | 141 |
|
110 | | -Full reference at `/docs` once running. Highlights: |
| 142 | +All `/comp/*` routes require `Authorization: Bearer <Supabase JWT>` by default. |
| 143 | + |
| 144 | +Highlights: |
111 | 145 |
|
112 | | -- `POST /comp/chat/start` — open a session against a persona |
113 | | -- `POST /comp/chat/{session_id}/message` — synchronous chat turn |
114 | | -- `GET /comp/chat/{session_id}/history` — paginated history |
115 | | -- `GET /comp/user/{user_id}/profile` — current `companion_insights` + training level |
116 | | -- `GET /comp/affinity/{session_id}` — live 6-dim vector (env-gated for OSS demo; off in prod-flavoured deploys) |
| 146 | +- `GET /comp/personas` — list active persona genomes. |
| 147 | +- `POST /comp/chat/start` — open a chat session against a persona. |
| 148 | +- `POST /comp/chat/{session_id}/message` — synchronous chat turn. |
| 149 | +- `POST /comp/chat/{session_id}/message_async` — async chat turn with pending-status polling. |
| 150 | +- `GET /comp/chat/{session_id}/pending/{message_id}` — poll async completion. |
| 151 | +- `GET /comp/chat/{session_id}/history` — paginated chat history. |
| 152 | +- `GET /comp/chat/{user_id}/sessions` — list a user's sessions. |
| 153 | +- `GET /comp/user/{user_id}/profile` — current `companion_insights` and `training_level`. |
| 154 | +- `POST /comp/chat/{session_id}/event/gift` — apply an out-of-band gift event and affinity delta. |
| 155 | +- `GET /comp/chat/{session_id}/gifts` — list gift events for a session. |
| 156 | +- `GET /comp/affinity/{session_id}` — debug-only live affinity vector, enabled by `EXPOSE_AFFINITY_DEBUG=true`. |
117 | 157 |
|
118 | | -Auth: Bearer Supabase JWT on every `/comp/*` route. The `AuthValidator` trait is pluggable if you bring a different IdP. |
| 158 | +The `AuthValidator` trait is pluggable if you use a different identity provider. |
119 | 159 |
|
120 | 160 | ## Configuration |
121 | 161 |
|
122 | 162 | | Env var | Required | Notes | |
123 | | -|---------|----------|-------| |
124 | | -| `DATABASE_URL` | yes | Postgres with `pgvector` extension. Engine creates its tables in the `engine` schema. | |
125 | | -| `OPENROUTER_API_KEY` | yes | Chat completions. Routed via `examples/model_config.toml`. | |
126 | | -| `VOYAGE_API_KEY` | yes | Embeddings. Failure modes are loud — empty key fails server boot. | |
127 | | -| `SUPABASE_URL` | yes | Project URL. | |
128 | | -| `SUPABASE_JWT_SECRET` | yes | Project JWT secret. eros-engine validates every incoming token. | |
| 163 | +|---|---|---| |
| 164 | +| `DATABASE_URL` | yes | Postgres with `pgvector`; tables are created under `engine.*`. | |
| 165 | +| `OPENROUTER_API_KEY` | yes | Chat completions, routed by `examples/model_config.toml` unless overridden. | |
| 166 | +| `VOYAGE_API_KEY` | yes | Embeddings. Empty keys fail server boot. | |
| 167 | +| `SUPABASE_URL` | no | Supabase project URL. Kept in `.env.example` for client/deploy conventions; the server does not read it today. | |
| 168 | +| `SUPABASE_JWT_SECRET` | yes | JWT signing secret for default auth. | |
| 169 | +| `BIND_ADDR` | no | Defaults to `0.0.0.0:8080`. | |
129 | 170 | | `EXPOSE_AFFINITY_DEBUG` | no | Set `true` to enable `/comp/affinity/{session_id}`. | |
130 | | -| `EMA_INERTIA` | no | Default `0.8`. | |
131 | | -| `MODEL_CONFIG_PATH` | no | Default `examples/model_config.toml`. | |
| 171 | +| `EMA_INERTIA` | no | Defaults to `0.8`. | |
| 172 | +| `MODEL_CONFIG_PATH` | no | Defaults to `examples/model_config.toml`. | |
| 173 | +| `RUST_LOG` | no | Defaults to `info`. | |
| 174 | + |
| 175 | +## What is deliberately out of scope |
| 176 | + |
| 177 | +This repository is the conversation, memory, and relationship-state core. It does not include: |
132 | 178 |
|
133 | | -## What's not here |
| 179 | +- **Matchmaking** — multi-stage filtering, soft scoring, and agent-to-agent matching simulation remain in the closed-source product. |
| 180 | +- **Full social UX** — onboarding, video, voice, billing, photos, moderation UI, and mobile clients. |
| 181 | +- **Persona provenance / marketplace logic** — commercial product code, not part of the engine. |
134 | 182 |
|
135 | | -This repo is the conversational + intimacy core. Things deliberately out of scope: |
| 183 | +If you are building a different product, the reusable part is the affinity + memory + insight pipeline. |
136 | 184 |
|
137 | | -- **Match-making algorithm** — the multi-stage filter + soft scoring + LLM agent-to-agent simulation lives in the closed-source product. eros-engine builds the *profiles* that feed it, but doesn't pair people up. |
138 | | -- **Full social product UX** — onboarding, video, voice, billing, photos. |
139 | | -- **Companion provenance / lineage** — proprietary. |
| 185 | +## Content note |
140 | 186 |
|
141 | | -If you want to build a different product on top — a journaling companion, a language tutor, a coaching agent — the affinity + memory + insight pipeline is the part you'd reuse. |
| 187 | +The example personas under `examples/personas/` are written as adult character-chat examples. They can flirt and express desire when the relationship state reaches that point, while still refusing disrespectful or boundary-crossing behavior. If your product needs a SFW default, replace those persona files before deploying. |
142 | 188 |
|
143 | 189 | ## Contributing |
144 | 190 |
|
145 | | -Read [`CONTRIBUTING.md`](CONTRIBUTING.md). All contributors must accept the [`CLA`](CLA.md) via cla-assistant.io on first PR (one-time, covers all future PRs). |
| 191 | +Read [`CONTRIBUTING.md`](CONTRIBUTING.md). All contributors must accept the [`CLA`](CLA.md) through cla-assistant.io on their first PR. |
146 | 192 |
|
147 | 193 | ## License |
148 | 194 |
|
149 | | -AGPL-3.0. If AGPL doesn't fit your distribution model, commercial licensing is available — `henrylin@etherfun.xyz`. |
| 195 | +`eros-engine` is licensed under AGPL-3.0-only. If AGPL does not fit your distribution model, commercial licensing is available: `henrylin@etherfun.xyz`. |
0 commit comments