Discord adapter for the Luna Protocol ecosystem
Jade acts as a thin WebSocket client of Emerald (the brain service), forwarding Discord messages and executing response commands. It is a pure execution layer -- virtually no decision-making happens here.
flowchart LR
Discord["Discord"] --> Jade["Jade (Discord Adapter)"]
Jade -- ":3126 WS" --> Emerald["Emerald (Brain)"]
Emerald --> Sapphire["Sapphire (LLM Gateway)"]
Sapphire --> Krystal["Krystal (llama.cpp)"]
- Jade connects to Emerald via WebSocket on port 3126
- Jade listens to Discord messages and forwards them to Emerald as
MessageEvents - When
-debugis in the message content, Jade setsdebug: trueon the event - Emerald processes the message via streaming -- on the first token, Emerald sends a
TypingCommand - Jade shows the typing indicator in Discord while generation completes
- Emerald sends a
RespondCommandback withresponseText,voice?, and optionaldebugStats - Jade posts the response text to Discord (with debug stat lines appended when in debug mode)
- If
voice: true, Jade generates a Piper TTS audio message and sends it as a voice message - Jade handles other commands:
set_presence,typing, reaction plans, burst fragments, hesitation words
Jade is ~1,100 lines of TypeScript across 10 source files. It uses Eris (a Discord API library) and communicates with Emerald via the ws WebSocket library.
| File | Lines | Role |
|---|---|---|
src/index.ts |
1 | Bootstrap entry point |
src/cli.ts |
21 | CLI dispatcher (bot command) |
src/bot.ts |
345 | Core: Eris client, Emerald WS, response pipeline |
src/config.ts |
54 | Config loader with fallback chain |
src/guild.ts |
29 | Channel type utilities |
src/core/emerald-client.ts |
271 | WebSocket client to Emerald + all protocol types |
src/core/bus.ts |
46 | Typed event bus |
src/tts/piper.ts |
32 | Piper TTS initialization and synthesis |
src/tts/audio.ts |
93 | Audio processing (sanitize, convert, duration) |
src/tts/voice-message.ts |
56 | TTS pipeline orchestrator |
src/tts/upload.ts |
110 | Discord CDN upload (3-step REST) |
1. Wait for cmd.delay ms (configured by Emerald)
2. If cmd.react, schedule a delayed reaction via setTimeout
3. Start typing indicator (setInterval every 8s)
4. If burstPlan, split text via splitBurst()
5. Append debug stats to last or first fragment
6. Loop through fragments:
- Fragment 0: prepend hesitationWord, apply replyStyle
- Fragment 1+: fire via setTimeout with cumulative delays
7. Clear typing indicator
8. If voice: true and text safe → sendTextAsVoiceMessage()
const client = new Eris.Client(DISCORD_TOKEN, {
intents: ["guilds", "guildMessages", "guildMessageReactions", "messageContent", "directMessages"],
});Five intents, including the privileged messageContent intent required to read message content. Listens to ready, error, messageCreate, and messageReactionAdd events.
Jade connects to Emerald using EmeraldClient -- a reusable class with auto-reconnect (3s interval, infinite retries by default).
Inbound events (Jade → Emerald):
message-- user message with full context (channel, user, text, timestamp, isDM, debug)ready-- bot handshake on connectbot_message-- Jade's own messages (for Ruby training in Emerald)presence-- bot presence changes
Outbound commands (Emerald → Jade):
respond-- full response withresponseText,burstPlan,hesitationWord,typoCorrection,letterSwap,react,voice,debugStatstyping-- show typing indicator for duration msset_presence-- update Discord status/activityspontaneous-- trigger unbidden message
Wire format: { event: "in", payload: InEvent } → { event: "command", command: OutCommand }
When Emerald sets voice: true, Jade runs a multi-stage pipeline:
executeRespond()
→ hasUnsafeTTSText(text) -- emoji detection (U+1F000-1FFFF, etc.)
→ sanitizeForTTS(text) -- strip mentions→@user, remove URLs, emoji, special chars
→ PiperTTS.synthesize(text) -- WAV generation via pipertts
→ wavToOgg(wavBuf) -- ffmpeg: libopus 32k, 24000Hz, mono
→ getAudioDuration(oggBuf) -- ffprobe duration extraction
→ buildWaveformBase64() -- fake sine-wave waveform (256 points)
→ requestUploadUrl() -- POST to Discord /attachments
→ putFileToUploadUrl() -- PUT OGG to Discord CDN
→ postVoiceMessage() -- POST with flags: 8192 (voice message)
Key details:
- TTS uses Piper (fast, local neural TTS, CPU-only)
- Sanitization is aggressive: mentions become
@utilisateur, custom emoji stripped, URLs removed - The waveform is a synthetic sine wave (not actual audio analysis) -- Discord just needs any waveform data
- Direct REST API calls (not through Eris) for voice messages because Eris doesn't natively support
flags=8192
When Emerald sends a burstPlan, Jade splits text into fragments:
- 2 fragments: split at 30-55% into the word array
- 3 fragments: split at 20-35% and 55-70%
- Fragment 0 sent immediately (after delay), subsequent ones via
setTimeoutwith Emerald-specified delays - Short messages (<4 words) never burst
Toggle per-channel via -debug command. Each response includes appended -# lines:
-# 🚀 12 tok · 3200ms · 3.8 tok/s · 450 prompt
-# 😊 valence=0.014 · arousal=-0.040
-# 📊 state v=0.023 · a=0.012
-# 🏷️ FUTILE (42.1%)
-# ⚙️ typo=6% · swap=4% · burst=15% · hesitate=15% · voice=12% ✨ · forget=3% · fatigue=1.0x
The ✨ suffix indicates which behaviors were actually applied for that response.
Jade's state-machines/ directory contains 24 Mermaid diagrams (architecture, behavior, timing) with rendered SVG/PNG outputs. These document the full system including behaviors handled by Emerald:
- Message processing, trigger evaluation, LLM queue, crash recovery
- Session limits, anti-spam, dynamic status, spontaneous messages
- TTS pipeline, typo correction, follow-up detection
- Event bus, delay computation, sleep schedule
- Hesitation/forget, reply style, config hot-reload
- Reaction commands, timing Gantt, burst, topic fatigue
All SVGs are in state-machines/output/ and regenerated via npm run diagrams.
discord_token: "your_discord_bot_token"
emerald_host: "localhost"
emerald_port: 3126
tts_model_path: "./tts-engine/en_GB-southern_english_female-low.onnx"
tts_binary_path: "bin/piper/piper"Voice message chance is configured in Emerald's config, not here.
# Install
npm install
# Build (esbuild → self-cli.cjs)
npm run build
# Development
npm run dev
# Production (PM2)
npm run start