Status: Active Last Updated: 2026-06-29
1. High-Level Design
1.1 Architectural Components
| Component | Responsibility | Technology |
|---|---|---|
| CI Service | API layer — context retrieval, ingestion, correction, outreach | Hono on Cloudflare Workers |
| Extraction Engine | Session-end LLM batch: memories + entities + emotional signal | Single haiku-class LLM call per session |
| Real-time Rules | Per-message extraction (names, corrections, patterns) — no LLM | Regex + keyword matching, inline |
| Entity Graph | Nodes (people, goals, motivations) + relationships between them | 2 Postgres tables, pgvector resolves |
| Memory Store | Long-term memories with conflict resolution + confidence scoring | Neon PostgreSQL + pgvector |
| KV Cache | L1 core summary (hot path — sub-ms read) | Cloudflare KV |
| Emotional Arc | Per-session snapshots + weekly rollup → mood trend | PostgreSQL (ci_emotional_snapshots) |
| Relationship Tracker | Stage + direction + velocity + early intervention | PostgreSQL (ci_relationship_snapshots) |
| Outreach Scanner | Hourly cron: trigger evaluation + channel-aware delivery | CF Cron + CF Queues |
| Consolidation Job | Weekly 9-step: decay → compress → resolve → rollup → regen | CF Cron (Sunday 2am UTC) |
1.2 System Diagram
2. Memory Tiers
CI serves context to the chat engine in three tiers. Each tier has a different retrieval cost, token budget, and trigger condition.
L1 — Session Core (always injected)
- Source:
ci_contacts.core_summary— generated by weekly consolidation job - Location in prompt: system prompt, once per session
- Size: ≤ 50 tokens
- Format: behavioral guidance, not facts
[Priya | Engaged → stable | Mood: improving]
Approach: habit-focused not numbers, warm but not intense,
November wedding is the real motivatorCold start: for the first 5 sessions (before enough context exists), L1 is filled from ce_bot_config.new_contact_brief — an owner-written default. At session 5, real extracted context takes over automatically.
L2 — Message Relevance (conditional)
- Source: pgvector semantic search on the current message text
- Location in prompt: internal note injected immediately before the user message
- Size: ≤ 150 tokens
- Trigger: cosine similarity > 0.7 OR message contains emotional signal OR entity name match detected
- When nothing qualifies: inject nothing — silence is correct
L2 is what makes the AI feel like it remembered something relevant, right now. L1 tells the AI who this person is; L2 tells the AI what matters in this specific moment.
L3 — Archival (on-demand)
- Source: full memory store, searched via pgvector
- Trigger: LLM explicitly calls
recall_memorytool - Use: rare, safety net for specific questions that need a deep search
Context Packet API Contract
GET /ci/context/:contactId?message=<encoded>&sessionCount=<n>Response:
{
"session_core": "[Priya | Engaged → stable | Mood: improving]\nApproach: ...",
"message_relevance": "She mentioned her sister last week — they had a fight about the diet",
"has_message_context": true,
"relationship": {
"stage": "engaged",
"trajectory": "stable",
"mood_trend": "improving"
},
"crisis_level": "none",
"triggers": ["wedding_deadline_approaching"]
}The chat engine assembles the prompt — CI delivers ingredients, not the assembled prompt. This keeps CI decoupled from chat engine prompt engineering.
Prompt placement order:
- Bot persona + owner instructions
- L1
session_core— in system prompt - Knowledge base RAG (if enabled)
- Conversation history (last 6 messages)
- L2
message_relevance— as internal note before user message (conditional) - User message
3. Memory Pipeline
Real-time extraction (every message — zero LLM cost)
Rules-based only, non-blocking, runs inline:
- Regex patterns: "my name is", "I am", "I have", "I like/hate", phone numbers, explicit dates
- Correction patterns: "that's wrong", "I never said", "actually I'm not", "no I meant"
- Writes to a session staging buffer — NOT directly to the memory store
Session-end extraction (one LLM call per session)
Triggered when session closes or 2 hours of inactivity. Single haiku-class LLM batch call returns:
- New memories — fact, preference, episode, behavior, pattern (with type + importance score)
- Conflict checks — compare against existing store →
ADD/UPDATE/DELETE - Entity extraction — new nodes + relationships for the entity graph
- Emotional session summary — dominant mood, variance, key triggers → writes
ci_emotional_snapshots (session) - Relationship signal — depth score, breadth, emotional investment → feeds weekly trajectory
Conflict resolution
When new information contradicts existing memory, the LLM chooses one of:
| Action | When | Result |
|---|---|---|
ADD |
No conflict found | New memory row created |
UPDATE |
New information is more specific or recent | Old memory superseded_by new |
DELETE |
Contact explicitly corrected it | Old memory marked superseded, source: contact_corrected |
Memories are never hard-deleted from the store (only soft-archived) — but superseded memories don't surface in retrieval.
Real-time correction (synchronous — only exception to session-end rule)
When the chat engine detects a correction mid-session:
POST /ci/contacts/:id/correct { hint, correction }- Creates new memory:
source: contact_corrected, highest confidence - Marks old memory as
superseded_bynew - Runs before the AI generates its acknowledgment response
- The fix is in place before the AI says "Got it"
4. Entity Graph
Two Postgres tables, no graph database needed.
ci_entities — nodes
type: person | goal | event | behavior | aversion | motivation | topic | place
name, properties: jsonb
confidence: 0.0–1.0
last_seen_atmotivation is the highest-value type — it's rarely stated directly, inferred by LLM from patterns across sessions. If you know what someone is actually motivated by, you know how to talk to them.
ci_entity_relationships — edges
relationship: motivated_by | triggered_by | linked_to | opposes | part_of | is_related_to
confidenceEntity resolution — C+B hybrid
- At extraction time (cheap): fuzzy name match 85%+ similarity + same type → update existing, don't create duplicate
- Weekly consolidation (LLM batch): candidate pairs sent in groups of 10 → "are these the same entity?" Confirmed matches merged; properties combined.
How entities improve L2
When building L2 context, CI extracts entities mentioned in the current message and expands 1 hop in the graph. This finds causally connected context — not just similar things, but related things. If the message mentions "the gym" and the graph shows gym → triggers_by → anxiety, the L2 context can include that relationship even if the word "anxiety" wasn't in the message.
5. Emotional Arc
Intra-session (chat engine — zero CI calls)
Simple sentiment shift on last 3 messages: "tone has dropped." The chat engine handles this locally. No CI call, no cost, immediate.
Cross-session arc (CI owns)
per-session snapshot → written at session-end extraction
weekly rollup → aggregate session snapshots
→ dominant mood, trend direction, variance, key triggers
→ written to ci_emotional_snapshots (weekly period)Trend: arithmetic comparison of last 3–4 weekly mood scores → improving | declining | stable | volatile
Crisis detection — two levels:
| Level | Detector | Trigger | Response |
|---|---|---|---|
| Real-time crisis | Chat engine (every message) | Crisis keyword match | Blocks LLM, switches to safety response + helpline |
| Distress trend | CI (weekly) | 3+ weeks of severe score decline | Sets crisis_level: distress, shifts to care mode |
False positives acceptable. False negatives are not.
6. Relationship Trajectory
Stage definitions
new 0–2 sessions
building 3–10 sessions, returning voluntarily
engaged regular cadence, personal depth, goal progress
loyal 4+ weeks continuous, high frequency, deep memory
at_risk 50%+ frequency drop OR 2-week negative mood trend
dormant 14+ days no sessionTrajectory = stage + direction + velocity
- Direction: compare
relationship_scoreacross last 3–4 weekly snapshots - Velocity: slow | moderate | fast (rate of change)
Early intervention window
A contact still labeled engaged but showing 30% frequency drop + shorter sessions + declining mood = moving_down, moderate. CI fires outreach at this point, not when they've officially become at_risk.
This is the key insight: waiting for stage change is too late. Detecting drift within a stage is what actually prevents churn.
Computed weekly in the consolidation job (pure formula, no LLM). Feeds into L1 session_core and proactive outreach triggers.
7. Proactive Outreach
Two modes
Smart retention (CI decides): one toggle in chatbot settings. CI watches signals — at_risk trajectory, negative arc 2+ weeks, session gap approaching threshold — and fires outreach automatically.
Owner-configured rules: inactivity trigger, scheduled, milestone, recurring. Owner sets conditions and message template (or AI-generated). Wizard UI in dashboard.
Split responsibility
CI does not generate the final message. Chat engine does not decide when to reach out.
CI → intent + approach guidance:
"Reach out to Priya. 6 days quiet, last session ended
frustrated about slow progress. Tone: warm, don't mention
the goal, ask how she's feeling."
Chat Engine → final message, using CI guidance + bot personaPull principle
Every proactive message is designed to elicit a reply — reference something specific from last conversation, end with an open thread. Never a self-contained information broadcast.
WhatsApp 24-hour window awareness
| Window state | CI action |
|---|---|
| Open (< 18h since last contact) | Send free-form pull message |
| Closing (18–23h elapsed + strong signal) | Priority elevated — send now |
| Closed | Use pre-approved template OR fall back to other channel |
The window check lives in the outreach scanner (CI knows channel + last message timestamp).
Anti-spam guarantees
- Max 2 proactive messages per week per contact
- Min 24h between any two outreach messages
- Never during crisis state
- Contact says "stop" →
outreach_disabled: true, permanent until owner re-enables
8. Multi-Channel Identity
Model
ci_contacts (master)
↑
ci_contact_channels (one row per channel)
channel_type: whatsapp | telegram | website_widget | instagram
external_id: "wa-+919876543210"
confidence: 1.0 | 0.8All memory, arc, graph, and trajectory live on the master contact. The channel record is just a lookup key to find the master.
Linking paths
- Contact-initiated (confidence 1.0) — contact self-identifies on another channel, or widget asks "have you chatted with us before?"
- Owner-initiated (confidence 1.0) — owner merges two contacts in dashboard after confirming they're the same person
- Probabilistic suggestion (CI proposes, owner confirms) — nightly: same name + same entities + similar writing style → surfaces "possible match" card. Never auto-merges.
Merging two different people is catastrophic. The safety constraint: human confirmation always required.
Merge operation
- Memories: conflict-resolve (same as session-end)
- Entity graph: entity-resolve (same as weekly consolidation)
- Emotional arc: combine + weight recency
- Relationship trajectory: take the higher stage
- Conversation history: stays in CE sessions — not merged, both contribute to the same CI master going forward
- Merges are logged, reversible within 24 hours
9. Weekly Consolidation Job
Runs Sunday 2am UTC (0 2 * * 0). Per active contact (where last_consolidated_at < 7 days ago), in order:
Step 1 decay_memories()
importance × decay_rate by type:
facts: 0.99 | preferences: 0.95 | episodes: 0.90 | patterns: 0.98
Skip if last_reinforced_at < 2 weeks ago
Step 2 compress_episodes()
If 3+ similar episodes exist → LLM extracts the pattern
Reduces individual episode importance, preserves originals as archived
Step 3 resolve_entities()
LLM batch (10 candidate pairs per call): "are these the same entity?"
Confirmed matches → merge properties
Step 4 rollup_emotional_arc()
Aggregate session snapshots → write weekly ci_emotional_snapshots row
Compute trend direction + variance
Step 5 compute_relationship_trajectory()
Formula: stage + direction + velocity → write ci_relationship_snapshots row
Check for early intervention window
Step 6 regenerate_l1_core()
LLM call: fresh behavioral summary from all current data
Write to contacts.core_summary + KV cache
Step 7 prune_memories()
Soft-archive importance < 0.05 (never hard delete)
Step 8 cleanup_orphan_entities()
Remove: 0 relationships + confidence < 0.3 + not seen in 30 days
Step 9 update last_consolidated_atAfter all contacts:
Step 10 compile_owner_alerts()
at_risk contacts, crisis flags, milestones → owner digest via COMMS_SERVICECost
~1–3 haiku LLM calls per active contact per week. Under $1/week per 1,000 active contacts.
Resilience
Each contact is processed independently — a failed contact doesn't block others. last_consolidated_at tracks which contacts need retry. Large tenants fan out via Cloudflare Queue (contact = queue message), rate-limited to avoid LLM API rate limits.
10. Data Model Overview
New tables
ci_contact_channels — multi-channel identity
ci_entities — entity graph nodes
ci_entity_relationships — entity graph edges
ci_emotional_snapshots — emotional arc (session + weekly periods)
ci_relationship_snapshots — relationship trajectory (weekly)
Modified tables
ci_contacts — add: core_summary, last_consolidated_at, cold_start_used_until, crisis_level, channel_count
ci_contact_memories — add:
| Column | Type | Purpose |
|---|---|---|
source |
enum | ai_extracted | owner_manual | contact_corrected |
confidence |
float (0.0–1.0) | How certain we are this is accurate (separate from importance = how much it matters) |
source_session_id |
FK | Which chat session produced this memory — powers timeline view |
superseded_by |
FK self-ref | Points to the newer memory that replaced this one |
last_reinforced_at |
timestamp | Prevents decay for recently mentioned facts |
confidence and importance are separate dimensions:
- High importance + high confidence → inject prominently
- High importance + low confidence → inject with hedged framing ("she mentioned wanting to lose weight")
- Low importance + any confidence → likely decay candidate
11. Integration Points
Chat Engine → CI
| Call | When | Notes |
|---|---|---|
GET /ci/context/:id?message= |
Before each LLM call | Hot path — L1 from KV (sub-ms), L2 from pgvector (3–8ms) |
POST /ci/ingest |
On session close or 2h inactivity | Triggers session-end extraction asynchronously |
POST /ci/contacts/:id/correct |
On correction signal mid-session | Synchronous — fix is in place before AI acknowledges |
CI → Chat Engine
Proactive outreach: CI generates intent → CI calls CE /internal/send with intent + approach guidance → CE generates final message and delivers via channel API.
AI Brain → CI
Brain consumes GET /ci/analytics/relationship-health for tenant-level insights. Brain does NOT write to CI.
Flows → CI (V2)
CI emits events: contact.at_risk, contact.dormant, contact.milestone, contact.returned. These become Flow trigger nodes. V1: CI owns the outreach engine. V2: outreach migrates to Flows, CI is the signal source.
12. Performance Budget
| Operation | Target | How |
|---|---|---|
L1 retrieval (session_core) |
< 2ms | KV edge read |
L2 retrieval (message_relevance) |
< 8ms | pgvector HNSW index, contact-scoped |
| Full context packet | < 15ms | L1 + L2 in parallel |
| Ingest acknowledgment | < 50ms | Rules-based inline; extraction queued async |
| Session-end extraction | < 5s | One haiku LLM call, background |
| Correction write | < 20ms | Postgres write + KV invalidate |
| Weekly consolidation per contact | < 30s | 1–3 LLM calls, fan-out via queue |
The context packet (L1 + L2) never blocks the user — the chat engine can also proceed with L1-only if L2 takes more than 10ms. Extraction never blocks the conversation.
13. Data Flow Guarantees
-
Context retrieval never waits for extraction. Extraction is async. Memories are available in the next session, not mid-session.
-
Outreach never spams. Rate limits enforced at DB level (unique constraints on trigger fire date) AND application level (scanner rate check before send).
-
Contact data isolation is absolute. Every query scopes to
tenant_id + contact_id. Tenant A's contacts are never visible to Tenant B. -
Correction is synchronous. The only real-time write in CI. The fix is in the DB before the AI says "Got it."
-
Memory deletion is complete. Forget-me request → cascade delete all rows in
ci_contact_memories,ci_entities,ci_emotional_snapshots,ci_relationship_snapshots,ci_contact_channelsfor that contact. Synchronous, no orphans left. -
Weekly job failure is isolated. One contact failing consolidation doesn't block others. The contact's
last_consolidated_atstays old → it retries next week automatically.