Architecture, components, behavior system, debug mode, and configuration for Luna Protocol.
Luna Protocol v2 -- Emerald era
The architecture was reorganized in July 2026: a new brain service (Emerald) now sits between bots and Sapphire. Bots are thin WebSocket clients with no direct Sapphire or LLM logic.
Discord / Matrix
|
v
+----------+ +-------------+
| Jade | | Pixieglow | <-- Thin adapters (WebSocket clients)
| (Discord)| | (Matrix) |
+-----+----+ +------+------+
| |
+--------+-------+
|
WebSocket :3126
|
+------v------+
| Emerald | <-- Brain: behavior, Sapphire streaming, typo/swap, voice decision
| (TypeScript)|
+------+-------+
|
HTTP :3123
|
+------v-------+
| Sapphire | <-- Gateway: classification, sessions, few-shot, emotion, streaming
| (Python) |
+---+-------^---+-------------------+
| | |
| FUTILE / INTERESSANT |
| | HTTP :3127
HTTP :3124 HTTP :3125 |
| | +--------v--------+
+------v---+ +-v--------+ | Ruby |
| Krystal | | Krystal | | Markov Chain |
| small Q4 | | large | | (TypeScript) |
| (C++) | | (C++) | +-----------------+
+-----------+ +----------+
Emerald is the central decision engine. It runs a WebSocket server on port 3126 that platform adapters (Jade, Pixieglow) connect to.
MessageEvents (with optional debug: true)@Kalupso, <@userId>) are removed from the textaskStream) -- the response arrives token by tokenTypingCommand to the bot -- the typing indicator appears before generation completesRespondCommand back with responseText, optional voice flag, and optional debugStatsvoice: true)src/server.ts -- WebSocket server (connection management, event routing, command dispatch)src/brain.ts -- Decision engine: evaluates behavior, calls Sapphire via streaming, applies typo/swapsrc/sapphire-client.ts -- HTTP client with ask() (non-streaming) and askStream() (SSE streaming)src/protocol.ts -- WebSocket message types (events, commands, debug stats, behavior debug)src/behavior/*.ts -- Burst, sleep, typo, mannerismssrc/state/*.ts -- State management, triggers, topic fatigueSapphire is a Python FastAPI server on port 3123. Emerald is its only client.
few_shot_examples.yml injected server-side after the system prompt.[DONE].KRYSTAL_GENERIC_URL (:3124), INTERESSANT → KRYSTAL_SEMANTIC_URL (:3125). Set both to the same URL for single-backend mode.| Endpoint | Method | Description |
|---|---|---|
/v1/respond | POST | Classify + session + few-shot + emotion + generate. Main pipeline. |
/v1/chat/completions | POST | OpenAI-compatible chat completions (no session, no few-shot). For external tools. |
/v1/reset | POST | Reset a conversation session. |
/classify | POST | Classify text without generation. Returns label + similarity + emotion scores. |
/emotion/{key} | GET | Get emotional state for a conversation session. |
/health | GET | Server health check with configuration summary. |
Request body:
{
"username": "User",
"text": "Hello!",
"session_id": "jade:123456",
"stream": false,
"debug": false
}
Response (debug mode, non-streaming):
{
"text": "Hello! How can I help you today?",
"label": "FUTILE",
"backend": "http://127.0.0.1:3124",
"valence": 0.12,
"arousal": -0.04,
"debug_prompt_tokens": 450,
"debug_completion_tokens": 12,
"debug_time_ms": 3200,
"debug_tokens_per_second": 3.75,
"debug_emotion_state_valence": 0.6,
"debug_emotion_state_arousal": 0.3,
"debug_classification_confidence": 0.85
}
Streaming (SSE):
data: Hello
data: !
data: How
data: can
data: I
data: ...
data: {"text":"Hello! How can I ...","label":"FUTILE","backend":"...","valence":0.12,"arousal":-0.04,"time_ms":3200}
data: [DONE]
Krystal runs llama-server from llama.cpp. Sapphire routes FUTILE to the generic backend and INTERESSANT to the semantic backend. By default they use different ports; set both to the same URL for single-backend mode:
KRYSTAL_GENERIC_URL=http://127.0.0.1:3124
KRYSTAL_SEMANTIC_URL=http://127.0.0.1:3125
Current model: Luna-Protocol-1.5B-Fine-Tuned-Qwen2.5-200k-instruct.Q4_K_M.gguf (941 MB).
| Parameter | Value |
|---|---|
| Port | 3124 |
| Context | 4096 tokens |
| GPU layers | 0 (CPU only) |
| Threads | 6 |
Ruby is a Markov chain service that runs alongside the LLM stack. It sits between Emerald and generates ambient messages without any LLM call.
Ruby handles two trigger types: random (flat 1.5% chance per message) and spontaneous (periodic ambient messages). For these triggers, Emerald calls ruby.generate() instead of Sapphire. The bot receives a RespondCommand either way -- the bot never knows whether the response came from an LLM or a Markov chain.
/train endpoint (fire-and-forget)better-sqlite3 (native binding)Without Ruby, every trigger (including 1.5% random chance) hits Krystal. With Ruby, ~70% of triggers are handled by the Markov chain at ~1ms latency -- zero GPU/VRAM usage. Ruby makes spontaneous messages economically viable at scale.
| Endpoint | Method | Description |
|---|---|---|
/train | POST | Train on a message |
/train-batch | POST | Train multiple messages atomically |
/generate | POST | Generate text with optional seed and max length |
/channels | GET | List known channel IDs |
/stats | GET | Transition/starter counts and DB size |
/health | GET | Health check |
Both bots are thin WebSocket clients of Emerald. They have no Sapphire or LLM logic. All behavior decisions (typo, burst, voice chance, sleep) are made by Emerald.
MessageEvents-debug toggle enables per-message debug moderespond, typing, set_presence, reaction plans, burst fragmentsvoice: true-# formatted stat lines (tokens, timing, emotion, behavior config)discord_token, emerald_host, emerald_port, TTS paths (no voice chance -- that's in Emerald)respond, typing, set_presencematrix.homeserver_url, matrix.access_token, emerald_host, emerald_portAll behaviors are evaluated by Emerald before and after calling Sapphire. The bot itself has no behavior logic.
When engaged in an active conversation, Emerald splits long responses into fragments sent with delays. Configurable probability (default 15%) and delay range (1.5-4s between fragments). Prevents the bot from dominating a thread.
| Mode | Effect |
|---|---|
sleep | Only mentions pass through; all other messages ignored |
slow | Response delay multiplied by 3-5x |
short | Ignore chance increased by 30% |
Applied to the response text after receiving it from Sapphire. Typo chance (default 6%) replaces a letter with an adjacent key (AZERTY or QWERTY). Swap chance (default 4%) transposes adjacent letters. Corrections are sent as edits with configurable delay (2-4s).
Configurable chance (default 15%) of injecting a hesitation word (uh..., hmm..., well...) at the start of the response.
Tracks word frequency per channel. When a topic repeats too often (default threshold: 3 mentions in last 10 messages), response delay doubles and ignore chance increases (default +15%). Resets after 10 new messages.
Emerald decides whether the response should be sent as a voice message (configurable chance, default 12%). If voice: true is set on the respond command, the bot (Jade) generates Piper TTS audio and uploads it as a Discord voice message.
Configurable chance (default 3%) that Emerald returns a forgot command instead of responding -- mimicking the bot losing track of the conversation.
Debug mode flows through the entire stack. Toggle it by typing -debug in any channel:
-debug prefix → toggles debug for that channel, sets debug: true on subsequent MessageEventsdebug: true to Sapphire's /v1/respondDebugStats + BehaviorDebug, included in RespondCommand-# formatted lines to the Discord/Matrix responseExample debug output on Discord:
Hey, how are you?
-# 🚀 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
Fields explained:
🚀 -- completion tokens, total time, tokens/second, prompt tokens😊 -- per-message emotion scores (valence, arousal)📊 -- running emotion state (decayed EMA across conversation)🏷️ -- classification label (FUTILE/INTERESSANT) + confidence⚙️ -- all behavior config chances + which were ✨ appliedSapphire dynamically adjusts Krystal's sampling parameters based on the classified emotional state of the conversation:
| Parameter | Formula | Effect |
|---|---|---|
| Temperature | clamp(0.7 + arousal × 0.3, 0.4, 1.0) | Higher arousal = more randomness |
| Repeat penalty | clamp(1.15 - valence × 0.1, 1.0, 1.3) | Higher valence (positive) = less penalty |
| Mirostat entropy | clamp(6.0 + arousal × 2.0, 3.0, 8.0) | Higher arousal = more entropy |
Emotion state has a deadzone of 0.005: per-message valence/arousal scores below this threshold don't update the running state, preventing drift from noise. The state decays via EMA (decay factor 0.85).
Emerald communicates with bots via JSON messages over WebSocket. Messages use the format {"event": "in", "payload": {...}} for bot-to-Emerald events and {"event": "command", "command": {...}} for Emerald-to-bot commands.
| Event | Fields | Description |
|---|---|---|
message | id, client, channel, user, text, timestamp, isDM, mentions?, debug? | User message forwarded from platform |
ready | client, userId, username | Bot connection handshake |
bot_message | client, channel, text, timestamp | Bot's own messages (for context) |
presence | client, status | Bot status update |
| Command | Fields | Description |
|---|---|---|
respond | id, channel, text, responseText, delay, replyTo, replyStyle, hesitationWord?, burstPlan?, react?, voice?, sessionId?, debugStats? | Response text to post |
typing | id, channel, duration | Show typing indicator |
set_presence | id, status, text?, activityType? | Update bot presence/activity |
forgot | id, channel | Bot forgot the conversation |
spontaneous | id, channel, sessionId | Initiate spontaneous message |
{
promptTokens: number;
completionTokens: number;
timeMs: number;
tokensPerSecond: number;
emotionStateValence: number;
emotionStateArousal: number;
classificationLabel: string;
classificationConfidence: number;
messageValence: number;
messageArousal: number;
behavior?: BehaviorDebug;
}
BehaviorDebug {
typoChance, typoApplied,
swapChance, swapApplied,
burstChance, burstApplied,
hesitationChance, hesitationApplied,
voiceChance, voiceApplied,
forgetChance,
sleepMode: string | null,
fatigueMultiplier: number,
}
All behavior configuration is centralized in Emerald's config. Key parameters:
port: 3126
sapphire_host: "127.0.0.1"
sapphire_port: 3123
sapphire_bot_username: "User"
names: ["Luna", "Pixie"]
random_chance: 0.015
cooldown_seconds: 8
burst_chance: 0.15
hesitation_chance: 0.15
typo_chance: 0.06
letter_swap_chance: 0.04
voice_message_chance: 0.12
forget_chance: 0.03
# See config.example.yml for full details
| Variable | Default | Description |
|---|---|---|
SAPPHIRE_PORT | 3123 | Sapphire server port |
KRYSTAL_GENERIC_URL | http://127.0.0.1:3124 | Backend for FUTILE messages |
KRYSTAL_SEMANTIC_URL | http://127.0.0.1:3125 | Backend for INTERESSANT messages |
SAPPHIRE_BOT_NAME | "Luna" | Bot persona name (injected into system prompt) |
SAPPHIRE_SYSTEM_PROMPT | "Your name is Luna..." | Full system prompt (with no-echo instruction) |
SAPPHIRE_EMOTION_DEADZONE | 0.005 | Noise threshold for emotion state updates |
SAPPHIRE_EMOTION_DECAY | 0.85 | EMA decay factor for emotion state |
SAPPHIRE_FEW_SHOT_ENABLED | true | Enable/disable few-shot priming |
SAPPHIRE_LLM_MAX_RETRIES | 2 | Retries on degenerate output (non-streaming) |
SAPPHIRE_MIROSTAT_ENABLED | true | Enable mirostat sampling |
Both Jade and Pixieglow only need platform auth keys, Emerald connection details, and TTS paths (Jade only). No behavior parameters -- all decisions are in Emerald.
Jade (Discord):
discord_token: "your_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 NOW in Emerald's config, not here
Pixieglow (Matrix):
matrix:
homeserver_url: "https://matrix.example.com"
access_token: "your_token"
emerald_host: "localhost"
emerald_port: 3126
Discord-Dialogues -- 7.3M exchanges, 17M turns, 140M words. Real Discord conversations spring-summer 2025, filtered PII/ToS/bots/commands. Apache 2.0.
| Metric | Value |
|---|---|
| Samples | 7 303 464 |
| Total turns | 16 881 010 |
| Total words | 139 922 950 |
| Average tokens | 32.8 |
| Tokenizer | Hermes-3-Llama-3.1-8B |