Detailed Overview

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.

  • Fine-tuned Qwen2.5 1.5B model, trained on 200k samples from Discord-Dialogues
  • Quantized GGUF format (Q4_K_M, 941 MB)
  • Few-shot priming: Sapphire injects example conversations before the system prompt
  • Architecture: Platform → Bot (WebSocket) → Emerald (brain) → Sapphire (HTTP) → Krystal (llama.cpp)
  • All behavior and LLM logic lives in Emerald + Sapphire; bots are adapters only

Architecture

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 (Brain)

Emerald is the central decision engine. It runs a WebSocket server on port 3126 that platform adapters (Jade, Pixieglow) connect to.

Streaming flow

  1. Bots connect via WebSocket and forward MessageEvents (with optional debug: true)
  2. Emerald evaluates behavior rules: burst cooldown, sleep schedule, typo/swap probability, voice chance
  3. Mention stripping: bot mentions (@Kalupso, <@userId>) are removed from the text
  4. Emerald calls Sapphire via streaming (askStream) -- the response arrives token by token
  5. On the first token, Emerald sends a TypingCommand to the bot -- the typing indicator appears before generation completes
  6. Emerald buffers the streaming tokens until the full response is received
  7. Sapphire checks for degenerate output; if degenerate, the stream is discarded silently
  8. Emerald applies typo/swap behavior to the response text
  9. Emerald sends a RespondCommand back with responseText, optional voice flag, and optional debugStats
  10. The bot posts the response (optionally as a voice message if voice: true)

Key files

  • src/server.ts -- WebSocket server (connection management, event routing, command dispatch)
  • src/brain.ts -- Decision engine: evaluates behavior, calls Sapphire via streaming, applies typo/swap
  • src/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, mannerisms
  • src/state/*.ts -- State management, triggers, topic fatigue

Sapphire (LLM Gateway)

Sapphire is a Python FastAPI server on port 3123. Emerald is its only client.

Capabilities

  • Classification: Message embedding via fastembed + BAAI/bge-small-en-v1.5. K-means multicentroid (k=10, seed=42) computes 10 centroids per class. Classification uses maximum cosine similarity across all 10 -- achieves 100% accuracy on the test suite.
  • Emotion scoring: Four poles (positive, negative, high_arousal, low_arousal), each with k-means multicentroid (k=10). Valence and arousal on a [-1, 1] scale via max-similarity comparison. State per conversation decays via EMA. Deadzone: 0.005.
  • Session management: Per-channel conversation history with configurable max messages and TTL.
  • Few-shot injection: Example conversations from few_shot_examples.yml injected server-side after the system prompt.
  • Emotion-aware sampling: Temperature, repeat penalty, and mirostat entropy dynamically adjusted by valence/arousal.
  • Degenerate detection: Regex-based detection with retry (non-streaming) or silent discard (streaming).
  • Streaming SSE: Returns an SSE stream of content tokens, then final metadata, then [DONE].
  • Debug mode: Returns token counts, timing, emotion state, and classification confidence when requested.
  • Backend routing: FUTILE → KRYSTAL_GENERIC_URL (:3124), INTERESSANT → KRYSTAL_SEMANTIC_URL (:3125). Set both to the same URL for single-backend mode.

Endpoints

EndpointMethodDescription
/v1/respondPOSTClassify + session + few-shot + emotion + generate. Main pipeline.
/v1/chat/completionsPOSTOpenAI-compatible chat completions (no session, no few-shot). For external tools.
/v1/resetPOSTReset a conversation session.
/classifyPOSTClassify text without generation. Returns label + similarity + emotion scores.
/emotion/{key}GETGet emotional state for a conversation session.
/healthGETServer health check with configuration summary.

POST /v1/respond

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 (LLM Server)

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).

ParameterValue
Port3124
Context4096 tokens
GPU layers0 (CPU only)
Threads6

Ruby (Markov Chain Service)

Ruby is a Markov chain service that runs alongside the LLM stack. It sits between Emerald and generates ambient messages without any LLM call.

Role

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.

How it works

  • Emerald forwards every human message to Ruby's /train endpoint (fire-and-forget)
  • Ruby tokenizes each message and builds an order-4 Markov chain in SQLite via better-sqlite3 (native binding)
  • Each 4-word prefix maps to possible next words with frequency counts; generation uses weighted random selection
  • Database is immediately durable (WAL mode) -- no periodic snapshots needed
  • 6.9M transitions trained on 16.9M real Discord messages from the HuggingFace dataset

Why Ruby exists

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.

Endpoints

EndpointMethodDescription
/trainPOSTTrain on a message
/train-batchPOSTTrain multiple messages atomically
/generatePOSTGenerate text with optional seed and max length
/channelsGETList known channel IDs
/statsGETTransition/starter counts and DB size
/healthGETHealth check

Bots (Jade / Pixieglow)

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.

Jade (Discord)

  • TypeScript + Eris library
  • Connects to Emerald via WebSocket and forwards Discord messages as MessageEvents
  • -debug toggle enables per-message debug mode
  • Executes Emerald commands: respond, typing, set_presence, reaction plans, burst fragments
  • Generates Piper TTS voice messages when Emerald sets voice: true
  • In debug mode, appends -# formatted stat lines (tokens, timing, emotion, behavior config)
  • Config: discord_token, emerald_host, emerald_port, TTS paths (no voice chance -- that's in Emerald)

Pixieglow (Matrix)

  • TypeScript + Bun + Matrix client-server API
  • Sync loop polls Matrix API for new messages, forwards to Emerald
  • Executes Emerald commands: respond, typing, set_presence
  • Config: matrix.homeserver_url, matrix.access_token, emerald_host, emerald_port

Behavior System

All behaviors are evaluated by Emerald before and after calling Sapphire. The bot itself has no behavior logic.

Burst

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.

Sleep Schedule

ModeEffect
sleepOnly mentions pass through; all other messages ignored
slowResponse delay multiplied by 3-5x
shortIgnore chance increased by 30%

Typos / Letter-Swap

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).

Hesitation Markers

Configurable chance (default 15%) of injecting a hesitation word (uh..., hmm..., well...) at the start of the response.

Topic Fatigue

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.

Voice 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.

Forgetting

Configurable chance (default 3%) that Emerald returns a forgot command instead of responding -- mimicking the bot losing track of the conversation.

Debug Mode

Debug mode flows through the entire stack. Toggle it by typing -debug in any channel:

  1. Bot level: Message starts with -debug prefix → toggles debug for that channel, sets debug: true on subsequent MessageEvents
  2. Emerald: Passes debug: true to Sapphire's /v1/respond
  3. Sapphire: Returns debug fields in the response (or stream metadata)
  4. Emerald: Maps snake_case → camelCase into DebugStats + BehaviorDebug, included in RespondCommand
  5. Bot: Appends -# formatted lines to the Discord/Matrix response

Example 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 applied

Emotion-Aware Sampling

Sapphire dynamically adjusts Krystal's sampling parameters based on the classified emotional state of the conversation:

ParameterFormulaEffect
Temperatureclamp(0.7 + arousal × 0.3, 0.4, 1.0)Higher arousal = more randomness
Repeat penaltyclamp(1.15 - valence × 0.1, 1.0, 1.3)Higher valence (positive) = less penalty
Mirostat entropyclamp(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).

WebSocket Protocol

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.

Events (Bot → Emerald)

EventFieldsDescription
messageid, client, channel, user, text, timestamp, isDM, mentions?, debug?User message forwarded from platform
readyclient, userId, usernameBot connection handshake
bot_messageclient, channel, text, timestampBot's own messages (for context)
presenceclient, statusBot status update

Commands (Emerald → Bot)

CommandFieldsDescription
respondid, channel, text, responseText, delay, replyTo, replyStyle, hesitationWord?, burstPlan?, react?, voice?, sessionId?, debugStats?Response text to post
typingid, channel, durationShow typing indicator
set_presenceid, status, text?, activityType?Update bot presence/activity
forgotid, channelBot forgot the conversation
spontaneousid, channel, sessionIdInitiate spontaneous message

DebugStats

{
  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,
}

Configuration

Emerald (config.yml)

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

Sapphire (environment variables)

VariableDefaultDescription
SAPPHIRE_PORT3123Sapphire server port
KRYSTAL_GENERIC_URLhttp://127.0.0.1:3124Backend for FUTILE messages
KRYSTAL_SEMANTIC_URLhttp://127.0.0.1:3125Backend 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_DEADZONE0.005Noise threshold for emotion state updates
SAPPHIRE_EMOTION_DECAY0.85EMA decay factor for emotion state
SAPPHIRE_FEW_SHOT_ENABLEDtrueEnable/disable few-shot priming
SAPPHIRE_LLM_MAX_RETRIES2Retries on degenerate output (non-streaming)
SAPPHIRE_MIROSTAT_ENABLEDtrueEnable mirostat sampling

Bots (config.yml)

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

Dataset

Discord-Dialogues -- 7.3M exchanges, 17M turns, 140M words. Real Discord conversations spring-summer 2025, filtered PII/ToS/bots/commands. Apache 2.0.

MetricValue
Samples7 303 464
Total turns16 881 010
Total words139 922 950
Average tokens32.8
TokenizerHermes-3-Llama-3.1-8B