feat(voice): unify Discord voice↔text session (squash of voice/text-unify)
Voice utterances and text messages on the same Discord channel now share one Claude session, and Echo's voice replies are mirrored back into the text channel. Replaces the old voice:<id> session-key split. Changes: - src/adapters/_text_chunks.py: new leaf module for split_message (used by both discord_bot and voice pipeline) - src/router.py: drop voice: prefix from session_key; add [voice] marker; strip leading [speaker:/[voice] tokens from user input (anti-jailbreak); remove dead double-clear of voice: key - src/claude_session.py: include personality/VOICE_MODE.md unconditionally (rules become per-turn-aware via [speaker:] prefix instead of session flag) - src/voice/pipeline.py: VoiceSession splits text_channel_id + voice_channel_id; resolve text channel per-send (no stale refs); mirror Echo's reply text into the text channel after route_message returns - src/adapters/discord_voice.py: /voice join passes both channel ids - src/adapters/discord_bot.py: import split_message from leaf module - personality/VOICE_MODE.md: rewrite as per-turn dynamic rules; add synthesis instructions for text turns after voice turns Tests: - tests/test_router.py: 4 new cases (plain channel_id, anti-jailbreak, text-adapter regression, no-double-clear) - tests/test_pipeline_mirror.py: new — Echo reply mirror chunking, empty guard, mirror_enabled=False, send-raises resilience - tests/test_voice_session_channel_ids.py: new — split-attr contract + metrics payload schema - tests/test_voice_session_cleanup.py: update for new kwargs Plan: /home/moltbot/.claude/plans/vreau-ca-tot-textul-greedy-rivest.md Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
This commit is contained in:
@@ -3,6 +3,7 @@
|
||||
import json
|
||||
import logging
|
||||
import os
|
||||
import re
|
||||
import signal
|
||||
from datetime import datetime, timezone
|
||||
from pathlib import Path
|
||||
@@ -31,6 +32,20 @@ log = logging.getLogger(__name__)
|
||||
|
||||
APPROVED_TASKS_FILE = Path(__file__).parent.parent / "approved-tasks.json"
|
||||
|
||||
# Anti-jailbreak: strip user-controlled leading [voice] / [speaker:...]
|
||||
# tokens so they cannot impersonate the system-injected prefix on voice turns.
|
||||
_LEADING_VOICE_TOKEN_RE = re.compile(
|
||||
r'^\s*(?:\[voice\]|\[speaker:[^\]]*\])\s*', re.IGNORECASE
|
||||
)
|
||||
|
||||
|
||||
def _strip_leading_voice_tokens(text: str) -> str:
|
||||
while True:
|
||||
stripped = _LEADING_VOICE_TOKEN_RE.sub('', text, count=1)
|
||||
if stripped == text:
|
||||
return text
|
||||
text = stripped
|
||||
|
||||
# Module-level config instance (lazy singleton)
|
||||
_config: Config | None = None
|
||||
|
||||
@@ -63,6 +78,7 @@ def route_message(
|
||||
adapter-specific response shaping (e.g., redirect line on WhatsApp).
|
||||
"""
|
||||
text = text.strip()
|
||||
text = _strip_leading_voice_tokens(text)
|
||||
|
||||
# ---- Planning state-aware routing -----------------------------------
|
||||
# If the channel is in an active planning session, the user's message is
|
||||
@@ -124,8 +140,6 @@ def route_message(
|
||||
if text.lower() == "/clear":
|
||||
default_model = _get_config().get("bot.default_model", "sonnet")
|
||||
cleared_text = clear_session(channel_id)
|
||||
# Also drop the isolated voice session if one exists on this channel.
|
||||
clear_session(f"voice:{channel_id}")
|
||||
if cleared_text:
|
||||
return f"Session cleared. Model reset to {default_model}.", True
|
||||
return "No active session.", True
|
||||
@@ -156,18 +170,15 @@ def route_message(
|
||||
channel_cfg = _get_channel_config(channel_id)
|
||||
model = (channel_cfg or {}).get("default_model") or _get_config().get("bot.default_model", "sonnet")
|
||||
|
||||
# Voice-mode augment: prepend speaker prefix so Claude knows who spoke
|
||||
# in a voice channel. Cheap now, future-proof for multi-speaker later.
|
||||
# (Engineering decision #14 in the plan.) Only the discord-voice adapter
|
||||
# triggers it — text adapters keep the message verbatim.
|
||||
# Voice turns get a system-controlled [voice] [speaker:NAME] prefix so
|
||||
# VOICE_MODE.md rules self-activate per-turn. Session key is the plain
|
||||
# channel_id — voice + text share one Claude session on the same channel.
|
||||
claude_text = text
|
||||
voice_mode = adapter_name == "discord-voice"
|
||||
if voice_mode:
|
||||
user_name = _get_config().get("voice.user_name", "user") or "user"
|
||||
claude_text = f"[speaker:{user_name}] {text}"
|
||||
# Voice sessions use an isolated session key so they start fresh with
|
||||
# VOICE_MODE.md and don't pollute the text channel's conversation.
|
||||
session_key = f"voice:{channel_id}" if voice_mode else channel_id
|
||||
claude_text = f"[voice] [speaker:{user_name}] {text}"
|
||||
session_key = channel_id
|
||||
|
||||
try:
|
||||
response = send_message(
|
||||
|
||||
Reference in New Issue
Block a user