Last Updated: 2026-06-29 Status: Active
1. The Challenge
-
Chat Engine is stateless. It processes one message at a time. The 20-message sliding window is fine for support but destroys relationship continuity. An AI that forgets "your dog's name is Bruno" isn't building a relationship — it's breaking one.
-
AI Brain memory is per-tenant, not per-contact. Brain memories answer "what does this business care about?" not "what does this specific end-user care about?" They're different domains with different lifecycles.
-
Emotions matter in relationship contexts. A customer support bot can be consistently neutral. An AI companion, tutor, or coach must adapt: playful when the user is happy, supportive when they're sad, calm when they're anxious.
-
Relationship depth requires context density. A new contact needs discovery questions. A 30-day contact expects the AI to know their life. The system must scale context injection based on relationship stage — thin prompts for new contacts, rich prompts for deep relationships.
2. Memory Architecture
2.1 How It Differs from AI Brain Memory
| Dimension | AI Brain Memory | Contact Memory |
|---|---|---|
| Scope | Per-tenant (business owner) | Per-contact (end-user) |
| Content | Business facts, patterns, preferences | Personal facts, relationship episodes, user preferences |
| Decay rate | 0.01/day (business context changes fast) | 0.003–0.008/day (personal facts are stable) |
| Volume | ~100 memories per tenant (consolidated) | ~50–80 per active contact |
| Access pattern | On-demand via copilot | Every message (hot path) |
| Extraction | From owner conversations | From end-user conversations |
2.2 Memory Types
| Type | Default Importance | Decay Rate | Example |
|---|---|---|---|
fact |
0.7 | 0.003 | "Has a golden retriever named Bruno" |
preference |
0.8 | 0.005 | "Doesn't like talking about politics" |
episode |
0.5 | 0.008 | "Bruno ate his shoes on April 3rd" |
pattern |
0.8 | 0.003 | "Usually chats between 10pm–12am" |
Facts decay slowest (0.003/day). "Your dog's name is Bruno" should last for months. Episodes decay fastest (0.008/day). "We talked about the weather on March 5th" fades unless reinforced.
2.3 Memory Provenance (source field)
Every memory has a source column tracking how it was created:
| Value | Origin |
|---|---|
ai_extracted |
Extracted by LLM during session-close batch (default) |
owner_manual |
Written directly by the business owner |
contact_corrected |
Created via POST /contacts/:id/correct — always gets confidence: 1.0 |
2.4 Multi-Signal Retrieval
Retrieval scoring for L2 semantic search:
RetrievalScore = (embedding similarity × 0.35)
+ (recency × 0.25)
+ (importance × 0.20)
+ (access_frequency × 0.10)
+ (entity_match × 0.10)Entity match weight is higher than AI Brain (0.10 vs 0.05). If the user mentions "Bruno," memories linked to that entity surface regardless of embedding similarity.
3. Extraction Architecture
Memory extraction runs in three tiers with different triggers, latency, and cost.
Tier 1 — Inline Rules-Based (every message, ~0ms)
Runs synchronously before POST /ingest returns 202. No LLM calls.
Implemented in: extractImmediateMemories(message) and extractEntities(message) in src/lib/entities.ts + @repo/brain
Fact detection patterns:
"I am {X}" / "I'm {X}" → fact about identity
"I have {X}" / "I've got {X}" → fact about possessions/relationships
"My {X} is {Y}" → fact about named entity
"I work at {X}" / "I study at {X}" → fact about workplace/school
"I live in {X}" → fact about locationPreference detection patterns:
"I like {X}" / "I love {X}" → positive preference
"I don't like {X}" / "I hate {X}" → negative preference
"I prefer {X}" → comparative preference
"Don't talk about {X}" → avoidance preferenceEntity detection: rules-based regex matching for person names, pet names, workplaces, and places. Writes to both legacy ci_contact_entities (for backward compat) and the new ci_entities table.
Dedup: exact-match check on (contact_id, content) before inserting — prevents duplicates from repeated ingests of the same message.
Tier 2 — Session-Close LLM Batch (one call per session, ~2–5s)
Triggered when POST /ingest receives session_close: true + session_messages (the full transcript).
Implemented in: extractSessionMemories(transcript, existingMemories, llmProvider, model) in src/lib/extraction.ts
What the LLM extracts:
{
"memories": [
{ "action": "ADD", "type": "fact", "content": "...", "importance": 0.8, "confidence": 0.9 },
{ "action": "UPDATE", "type": "fact", "content": "...", "supersedes_id": "mem_old", "importance": 0.85, "confidence": 0.95 },
{ "action": "DELETE", "supersedes_id": "mem_wrong" }
],
"entities": [
{ "type": "person", "name": "Bruno", "properties": { "species": "dog", "breed": "golden retriever" } }
],
"entityRelationships": [
{ "from": "Arjun", "to": "Bruno", "relationship": "linked_to", "confidence": 0.95 }
],
"emotionalSummary": {
"dominantMood": "frustrated",
"moodScore": -0.4,
"variance": 0.2,
"keyTriggers": ["work_stress"]
},
"relationshipSignal": "stable"
}Writes:
- New/updated memories with provenance (
source: "ai_extracted",sourceSessionId) - Entity graph nodes (
ci_entitiesviaupsertEntity()) - Entity graph edges (
ci_entity_relationshipsviaaddEntityRelationship()) - Session emotional snapshot (
ci_emotional_snapshotswithperiod_type: "session")
Embeddings for new memories are generated asynchronously via c.executionCtx.waitUntil().
Tier 3 — Weekly Consolidation (9-step pipeline, Sunday 2am UTC)
Triggered by CF Cron. One queue message per active contact is enqueued to CONSOLIDATION_QUEUE. The queue consumer runs consolidateContact() in src/lib/consolidation.ts.
9-step pipeline:
-
Decay importance — multiply by
(1 - decayRate)per day elapsed. Skipped iflast_reinforced_atis within 14 days.fact: 0.003/day,preference: 0.005/day,episode: 0.008/day,pattern: 0.003/day
-
Episode → pattern compression — when 3+ episodes share a theme, LLM summarizes them into a single
patternmemory. Episodes remain in the DB but are no longer served as context. -
Entity dedup —
batchResolveEntities()checks entity pairs with Levenshtein similarity in the 0.80–0.85 range. Optionally asks LLM to confirm merges before applying them. Clear matches (≥ 0.85) are merged automatically. -
Emotional arc rollup — session snapshots from the past week are aggregated into a single
weeklyemotional snapshot: average mood score, dominant mood, variance, trend direction. -
Relationship trajectory snapshot — computes
ci_relationship_snapshotsrow for the current week: stage, score, trajectory (moving_up/stable/moving_down/volatile), velocity (slow/moderate/fast). -
Regenerate L1 core_summary — LLM writes a 1–3 sentence behavioral guidance string from the top memories + entities. Stored in
contacts.core_summary. Replaces the previous summary. -
Soft-prune low-importance memories — memories with
importance < 0.05haveexpires_atset to now + 7 days. They're not deleted immediately — a follow-up pass removes them. -
Remove orphan entities — entities with
confidence < 0.3that haven't been seen in 30+ days and have noci_entity_relationshipsedges are deleted. -
Update
last_consolidated_at— stamps the contact row so the next cron can filter by staleness.
4. Memory Conflict Resolution
The superseded_by column implements a ledger-style conflict resolution pattern. Old memories are never deleted — they're marked as superseded, preserving the history.
UPDATE action:
1. New memory inserted with superseded_by = null
2. Old memory: superseded_by = new_memory_id
3. Queries for active memories: WHERE superseded_by IS NULLDELETE action:
Old memory: superseded_by = "DELETED"
(sentinel — prevents it from appearing in active queries)POST /contacts/:id/correct (owner/contact correction):
1. Find highest-importance active memory WHERE content ILIKE '%hint%'
2. Insert new memory: source = "contact_corrected", confidence = 1.0
3. Old memory: superseded_by = new_memory_id
4. contacts.core_summary = null (force regeneration on next weekly run)This means a business owner can always correct what CI "knows" about a contact, and the history of what was believed is preserved.
5. Entity Graph
The entity graph maps what CI knows about a contact's world — people, goals, motivations, places, and events — and the relationships between them.
5.1 Nodes (ci_entities)
Contact: Arjun
├── pet: Bruno (confidence: 0.98, properties: { species: "dog", breed: "golden retriever" })
├── place: Infosys (confidence: 0.92, properties: { role: "workplace" })
├── motivation: career_growth (confidence: 0.85)
├── aversion: politics (confidence: 0.9)
└── person: Mom (confidence: 0.88, properties: { location: "Chennai" })5.2 Edges (ci_entity_relationships)
Bruno → linked_to → Arjun (confidence: 0.95)
career_growth → motivated_by → Infosys (confidence: 0.8)5.3 Extraction
- Session-close LLM call extracts both nodes and edges from the full transcript
upsertEntity()checks exact name match within(contact_id, type, name)unique index before inserting; mergespropertieson match- Inline Tier 1 also calls
extractEntities()(rules-based regex) but writes simpler nodes without relationship edges
5.4 Dedup
- Exact match: handled by unique index on
(contact_id, type, name) - Fuzzy dedup:
batchResolveEntities()in weekly consolidation checks pairs with Levenshtein similarity ≥ 0.80; merges properties and redirects FK references
5.5 L2 Enrichment
When GET /context/:id?message=... is called, expandOneHop() finds all entity IDs mentioned in the message (by name match), then fetches their 1-hop graph neighbors. The neighbor names are appended to message_relevance as "Also known: ...".
6. Emotional Intelligence Layer
6.1 Mood Classification
Per-message (Tier 1 inline): LLM haiku-class call on every user message.
Moods: happy | neutral | frustrated | sad | anxious | excited | confused
Energy: high | medium | lowFallback chain:
- Haiku LLM classification (primary)
- On timeout/error → keyword-based
classifyMoodKeyword()(no API call, < 1ms)
Keyword fallback:
"haha", "lol", "amazing" → happy, high
"sad", "crying", "miss" → sad, low
"worried", "nervous", "anxious" → anxious, low
"ugh", "annoyed", "frustrated" → frustrated, medium
"bored", "meh", "whatever" → bored, low
default → neutral, medium6.2 Crisis Detection
The session-close LLM call detects crisis signals in the full transcript and sets contacts.crisis_level:
none— normal conversationdistress— sustained negative mood, language suggesting strugglecrisis— explicit crisis language ("I want to die", "can't take this")
crisis_level is returned in every GET /context/:id response. Chat Engine is responsible for gating its response behavior — CI sets the signal, CE acts on it.
7. Context Injection by Relationship Stage
Different stages get different context density in session_core:
| Stage | session_count condition |
session_core content |
|---|---|---|
| Cold start (any stage) | < cold_start_used_until (default 5) |
Generic: "This is a new contact. Engage warmly." or CE bot-specific brief |
| Established | ≥ cold_start_used_until |
contacts.core_summary (LLM-generated behavioral guidance) |
| Pre-consolidation | ≥ cold_start_used_until, core_summary = null |
null — no L1 guidance yet |
The message_relevance (L2) field adds message-specific context on top of session_core regardless of stage.
8. Memory Growth Over Time
| Timeline | Memory Count | Composition |
|---|---|---|
| Day 1 | 3–5 | Facts + preferences from first conversation |
| Week 1 | 15–20 | Facts + preferences + early episodes |
| Month 1 | 40–50 | Facts + preferences + patterns emerging |
| Month 3 | 50–70 | Consolidated: patterns replace episodes, noise pruned |
| Month 6 | 60–80 | Mature: mostly facts + patterns + preferences, few raw episodes |
| Year 1 | 70–100 | Stable: high signal-to-noise, bounded by weekly pruning |
Memory count doesn't grow linearly because weekly consolidation compresses episodes into patterns and prunes low-importance memories.