The brain and decision-making engine for the Luna Protocol ecosystem
Emerald sits between platform adapters (bots) and the Sapphire LLM gateway, handling behavior evaluation, Sapphire communication, response processing, and connection management. It's the centralized decision engine -- bots are thin adapters, all behavior logic lives here.
flowchart LR
Platform["Discord / Matrix"] --> Bot["Bot Adapter"]
Bot -- ":3126 WS" --> Emerald["Emerald (Brain)"]
Emerald -- ":3123" --> Sapphire["Sapphire (LLM Gateway)"]
Sapphire -- ":3124" --> Krystal["Krystal (llama.cpp)"]
Emerald -- ":3127" --> Ruby["Ruby (Markov Chain)"]
- Bots connect to Emerald via WebSocket on port 3126
- Bots forward user messages as
MessageEvents (optionally withdebug: true) - Emerald evaluates behavior rules (burst, typo, sleep, mannerisms, voice chance)
- Emerald strips bot mentions from the text
- Emerald calls Sapphire's
/v1/respondvia streaming -- the response arrives token by token - On the first token, Emerald sends a
TypingCommandto the bot -- the typing indicator appears immediately - Remaining tokens are buffered; when complete, Sapphire returns final metadata
- Emerald processes the response (applies typo/swap behavior, maps debug stats)
- Emerald sends a
RespondCommandback to the bot withresponseText, optionalvoiceflag, and optionaldebugStats - The bot sends the response text to the platform
Emerald is 2,660 lines of TypeScript across 15 source files, compiled via esbuild into a single self-cli.cjs bundle. It has zero runtime dependencies beyond ws (WebSocket) and js-yaml (config).
index.ts
|-- config.ts (EmeraldConfig, loadConfig)
|-- server.ts (EmeraldServer)
|-- brain.ts (Brain) [590 lines -- the core]
| |-- behavior/sleep.ts (circadian rhythm)
| |-- behavior/mannerisms.ts (delay, ignore, reaction, hesitation, forget)
| |-- behavior/burst.ts (multi-fragment messages)
| |-- behavior/typo.ts (keyboard typo + letter swap)
| |-- sapphire-client.ts (LLM gateway HTTP client)
| |-- ruby-client.ts (Markov chain HTTP client)
| |-- state/state.ts (BrainState -- in-memory state)
| |-- state/topic-fatigue.ts (word repetition tracking)
| |-- state/trigger.ts (trigger evaluation chain)
|-- protocol.ts (all WebSocket message types)
|-- client.ts (bot-side WebSocket library)
Every incoming message goes through this pipeline in brain.ts:handleMessage():
1. Train Ruby (fire-and-forget)
2. evaluateMessage() → TriggerResult (mention, dm, name, keyword, follow-up, random)
3. Check pause/stop/clear
4. Record activity + speaker
5. Check session limits → auto-pause if > 8 rapid replies
6. evaluateSleep() → "sleep" | "slow" | "short" | null
7. recordMessage() → topic fatigue detection
8. computeDelay() → reading time × inactivity × sleep × fatigue × jitter
9. shouldIgnore/Forget → probabilistic filtering
10. shouldReact/Hesitate/Burst → behavior rolls
11. Pick reply style → weighted random from config
12. Strip bot mentions → removes @Username, <@userId>
13. Ruby or Sapphire? → if ruby_reason: Ruby.generate(), else Sapphire.askStream()
14. applyTypo() / swap → keyboard adjacency simulation
15. Assemble RespondCommand → with all behavior annotations
The Brain is a state machine that returns typed Decision[] objects rather than calling commands directly. Decisions include respond, ignore, pause, unpause, clear, and forgot. This keeps the brain testable and decoupled from the WebSocket transport.
Spontaneous messages: A per-client timer fires every 5 minutes. On each tick, there's a 12% chance to generate a message in a channel where the bot was recently active. These are generated via Ruby (Markov chain) for low-latency, bypassing the LLM entirely.
Sleep (src/behavior/sleep.ts): Evaluates time-of-day schedules with midnight-crossing support. Three modes: sleep (ignore all but mentions), slow (3-5× delay), short (+30% ignore chance). The config.example.yml defines 6 daily periods.
Mannerisms (src/behavior/mannerisms.ts): All human-like timing and probability:
computeDelay()-- starts with trigger-specific base delay, multiplies by reading time (text length / 500), inactivity warmup (up to 5× after 10 min idle), sleep mode, fatigue, and jitter (0.5-2×)shouldIgnore()-- trigger-type dependent, boosted by sleep and fatigueshouldReact()-- probability check, capped at 2% during slow/short sleepshouldHesitate()-- 15% chance to prepend "uh...", "um...", "well...", etc.shouldForget()-- 3% chance to silently drop the message
Typos (src/behavior/typo.ts): Keyboard adjacency maps for AZERTY and QWERTY. Replaces one letter in a random word (6% chance) or swaps adjacent letters (4% chance). Returns original and corrected word so the bot can optionally send a correction message.
Burst (src/behavior/burst.ts): 15% chance to split a response into 2-3 fragments with configured delays between them. Bot handles the actual text splitting.
All state is in-memory Maps with no persistence. Key tracking:
- Cooldowns: per-channel
lastReplytimestamps for rate limiting - Activity: per-channel
{ lastMessage, lastBotActivity, responseCount } - Sessions: per-channel pause/queue for session limits (8 rapid replies → 30s pause)
- Follow-ups: tracks last speaker per channel, budgets 3 consecutive follow-ups within 15s windows
- Pruning: periodic cleanup of entries older than 1 hour (every 5 min)
Pure-functional evaluation chain with priority order:
- Commands (
-stop,-start,-clear) - Self-messages (skip)
- Mentions → immediate trigger, unpauses
- DMs → immediate trigger, unpauses
- Paused state check
- Cooldown check
- Name match → any configured name in text
- Keyword match → probabilistic (
keyword_chance) - Follow-up → bot was last speaker, within window, under budget
- Random → flat 1.5% chance
Per-channel word frequency tracking. Extracts words ≥4 characters from each message. When a word appears ≥3 times in the window (default 100 words), the bot gets "bored": delay multiplier scales up (up to 5×), ignore bonus adds +15%.
HTTP client for the LLM gateway. Two modes:
ask()-- non-streaming POST to/v1/respondaskStream()-- SSE streaming, callsonChunk()per token, returns final metadata
SSE parsing is custom line-by-line. The final JSON metadata event is the last data: line before [DONE].
Fire-and-forget HTTP client for Markov chain:
train(event)-- POST to/train, errors silently caughtgenerate(seed?, maxLength?, channelId?)-- POST to/generate
Used for "ambient" messages (random and spontaneous triggers) where LLM latency is unnecessary.
Events (Bot → Emerald):
| Type | Fields |
|---|---|
MessageEvent |
id, client, channel, user, username?, text, timestamp, isDM, mentions?, debug? |
ReadyEvent |
client, userId, username |
BotMessageEvent |
client, channel, text, timestamp |
PresenceEvent |
client, status |
Commands (Emerald → Bot):
| Type | Fields |
|---|---|
RespondCommand |
Full response + hesitationWord, burstPlan, typoCorrection, letterSwap, react, voice, debugStats |
TypingCommand |
channel, duration |
SetPresenceCommand |
status, text?, activityType? |
SpontaneousCommand |
channel, sessionId |
ForgotCommand |
channel |
Wire format: { event: "in", payload: InEvent } → { event: "command", command: OutCommand }
Copy config.example.yml to config.yml. All behavior parameters are documented in the example file.
port: 3126
sapphire_host: "127.0.0.1"
sapphire_port: 3123
sapphire_bot_username: "User"
names: ["Luna", "Pixie"]
random_chance: 0.015
ruby_enabled: true
ruby_reasons: ["random", "spontaneous"]# Install
npm install
# Build (esbuild → self-cli.cjs)
npm run build
# Development
npm run dev
# Production (PM2)
npm run start| Metric | Value |
|---|---|
| Source files | 15 |
| Lines of code | 2,660 |
| Runtime deps | ws, js-yaml |
| Build tool | esbuild → self-cli.cjs |
| Message pipeline stages | ~15 per request |
| Simulated behaviors | 10 (delay, ignore, forget, hesitation, typo, swap, reaction, voice, follow-up, burst, fatigue) |