feat: Last Prompt shows full prompt with evil mode awareness

- discord_bridge before_agent_starts now checks evil_mode from
  working_memory to load the correct personality files:
  Normal: miku_lore/prompt/lyrics + /app/moods/{mood}.txt
  Evil: evil_miku_lore/prompt/lyrics + /app/moods/evil/{mood}.txt
- Reads files directly instead of relying on cross-plugin working_memory
- cat_client.query() returns (response, full_prompt) tuple
- Full prompt includes system prefix + recalled memories + conversation
- API /prompt/cat returns full_prompt field
This commit is contained in:
2026-03-01 01:17:06 +02:00
parent a0a16e6784
commit 892edf5564
7 changed files with 129 additions and 23 deletions

View File

@@ -203,10 +203,10 @@ def get_last_prompt():
@app.get("/prompt/cat")
def get_last_cat_prompt():
"""Get the last Cheshire Cat interaction (prompt + response) for Web UI."""
"""Get the last Cheshire Cat interaction (full prompt + response) for Web UI."""
interaction = globals.LAST_CAT_INTERACTION
if not interaction.get("prompt"):
return {"prompt": "No Cheshire Cat interaction has occurred yet.", "response": "", "user": "", "mood": "", "timestamp": ""}
if not interaction.get("full_prompt"):
return {"full_prompt": "No Cheshire Cat interaction has occurred yet.", "response": "", "user": "", "mood": "", "timestamp": ""}
return interaction
@app.get("/mood")

View File

@@ -542,7 +542,7 @@ async def on_message(message):
if globals.USE_CHESHIRE_CAT:
try:
from utils.cat_client import cat_adapter
response = await cat_adapter.query(
cat_result = await cat_adapter.query(
text=enhanced_prompt,
user_id=str(message.author.id),
guild_id=str(guild_id) if guild_id else None,
@@ -550,11 +550,12 @@ async def on_message(message):
mood=globals.DM_MOOD,
response_type=response_type,
)
if response:
if cat_result:
response, cat_full_prompt = cat_result
logger.info(f"🐱 Cat embed response for {author_name}")
import datetime
globals.LAST_CAT_INTERACTION = {
"prompt": enhanced_prompt,
"full_prompt": cat_full_prompt,
"response": response[:500] if response else "",
"user": author_name,
"mood": globals.DM_MOOD,
@@ -639,7 +640,7 @@ async def on_message(message):
except Exception:
pass
response = await cat_adapter.query(
cat_result = await cat_adapter.query(
text=prompt,
user_id=str(message.author.id),
guild_id=str(guild_id) if guild_id else None,
@@ -647,7 +648,8 @@ async def on_message(message):
mood=current_mood,
response_type=response_type,
)
if response:
if cat_result:
response, cat_full_prompt = cat_result
effective_mood = current_mood
if globals.EVIL_MODE:
effective_mood = f"EVIL:{getattr(globals, 'EVIL_DM_MOOD', 'evil_neutral')}"
@@ -655,7 +657,7 @@ async def on_message(message):
# Track Cat interaction for Web UI Last Prompt view
import datetime
globals.LAST_CAT_INTERACTION = {
"prompt": prompt,
"full_prompt": cat_full_prompt,
"response": response[:500] if response else "",
"user": author_name,
"mood": effective_mood,

View File

@@ -85,7 +85,7 @@ LAST_FULL_PROMPT = ""
# Cheshire Cat last interaction tracking (for Web UI Last Prompt toggle)
LAST_CAT_INTERACTION = {
"prompt": "",
"full_prompt": "",
"response": "",
"user": "",
"mood": "",

View File

@@ -4047,10 +4047,10 @@ async function loadLastPrompt() {
const result = await apiCall('/prompt/cat');
if (result.timestamp) {
infoEl.innerHTML = `<strong>User:</strong> ${escapeHtml(result.user || '?')} &nbsp;|&nbsp; <strong>Mood:</strong> ${escapeHtml(result.mood || '?')} &nbsp;|&nbsp; <strong>Time:</strong> ${new Date(result.timestamp).toLocaleString()}`;
promptEl.textContent = `[User message → Cat]\n${result.prompt}\n\n[Cat response]\n${result.response}`;
promptEl.textContent = result.full_prompt + `\n\n${'═'.repeat(60)}\n[Cat Response]\n${result.response}`;
} else {
infoEl.textContent = '';
promptEl.textContent = result.prompt || 'No Cheshire Cat interaction yet.';
promptEl.textContent = result.full_prompt || 'No Cheshire Cat interaction yet.';
}
} else {
infoEl.textContent = '';

View File

@@ -107,7 +107,7 @@ class CatAdapter:
author_name: Optional[str] = None,
mood: Optional[str] = None,
response_type: str = "dm_response",
) -> Optional[str]:
) -> Optional[tuple]:
"""
Send a message through the Cat pipeline via WebSocket and get a response.
@@ -125,7 +125,8 @@ class CatAdapter:
response_type: Type of response context
Returns:
Cat's response text, or None if Cat is unavailable (caller should fallback)
Tuple of (response_text, full_prompt) on success, or None if Cat
is unavailable (caller should fallback to query_llama)
"""
if not globals.USE_CHESHIRE_CAT:
return None
@@ -175,6 +176,7 @@ class CatAdapter:
# Cat may send intermediate messages (chat_token for streaming,
# notification for status updates). We want the final "chat" one.
reply_text = None
full_prompt = ""
deadline = asyncio.get_event_loop().time() + self._timeout
while True:
@@ -212,8 +214,9 @@ class CatAdapter:
msg_type = msg.get("type", "")
if msg_type == "chat":
# Final response — extract text
# Final response — extract text and full prompt
reply_text = msg.get("content") or msg.get("text", "")
full_prompt = msg.get("full_prompt", "")
break
elif msg_type == "chat_token":
# Streaming token — skip, we wait for final
@@ -232,7 +235,7 @@ class CatAdapter:
if reply_text and reply_text.strip():
self._consecutive_failures = 0
logger.info(f"🐱 Cat response for {cat_user_id}: {reply_text[:100]}...")
return reply_text
return reply_text, full_prompt
else:
logger.warning("Cat returned empty response via WS")
self._consecutive_failures += 1

View File

@@ -41,6 +41,7 @@ def before_cat_reads_message(user_message_json: dict, cat) -> dict:
author_name = user_message_json.get('discord_author_name', None)
mood = user_message_json.get('discord_mood', None)
response_type = user_message_json.get('discord_response_type', None)
evil_mode = user_message_json.get('discord_evil_mode', False)
# Also check working memory for backward compatibility
if not guild_id:
@@ -51,6 +52,7 @@ def before_cat_reads_message(user_message_json: dict, cat) -> dict:
cat.working_memory['author_name'] = author_name
cat.working_memory['mood'] = mood
cat.working_memory['response_type'] = response_type
cat.working_memory['evil_mode'] = evil_mode
return user_message_json
@@ -163,28 +165,123 @@ CRITICAL INSTRUCTION: When you see "Context of documents containing relevant inf
@hook(priority=100)
def before_agent_starts(agent_input, cat) -> dict:
"""
Log the agent input for debugging.
Now that the suffix template is fixed, declarative facts should appear naturally.
Capture the full constructed prompt for the Web UI 'Last Prompt' view.
Reconstructs the complete system prompt (personality, lore, lyrics, mood)
and combines it with recalled memories and user input.
"""
declarative_mem = agent_input.get('declarative_memory', '')
episodic_mem = agent_input.get('episodic_memory', '')
tools_output = agent_input.get('tools_output', '')
user_input = agent_input.get('input', '')
print(f"🔍 [Discord Bridge] before_agent_starts called")
print(f" input: {agent_input.get('input', '')[:80]}")
print(f"\U0001f50d [Discord Bridge] before_agent_starts called")
print(f" input: {user_input[:80]}")
print(f" declarative_mem length: {len(declarative_mem)}")
print(f" episodic_mem length: {len(episodic_mem)}")
if declarative_mem:
print(f" declarative_mem preview: {declarative_mem[:200]}")
# Build the full system prefix directly by reading the same personality files
# that the active personality plugin uses. Check evil_mode to pick the right set.
try:
evil_mode = cat.working_memory.get('evil_mode', False)
mood_name = cat.working_memory.get('mood', 'evil_neutral' if evil_mode else 'neutral')
def read_first(paths, fallback):
for p in paths:
try:
with open(p, 'r', encoding='utf-8') as f:
return f.read()
except FileNotFoundError:
continue
return fallback
if evil_mode:
# Evil Miku personality files
lore = read_first(['/app/cat/data/evil/evil_miku_lore.txt'], '[evil lore file not found]')
prompt_text = read_first(['/app/cat/data/evil/evil_miku_prompt.txt'], '[evil prompt file not found]')
lyrics = read_first(['/app/cat/data/evil/evil_miku_lyrics.txt'], '[evil lyrics file not found]')
mood_description = 'cold, detached superiority'
try:
with open(f'/app/moods/evil/{mood_name}.txt', 'r', encoding='utf-8') as f:
mood_description = f.read().strip()
except FileNotFoundError:
pass
system_prefix = f"""You are Evil Miku. You are NOT an AI assistant - you ARE Evil Miku herself.
CRITICAL RULES:
- NEVER break character or mention being an AI
- NEVER preface responses with "Evil Miku:" or similar labels
- Respond directly and stay in character
YOUR CURRENT STATE — {mood_name.upper()}:
{mood_description}
## EVIL MIKU LORE
{lore}
## EVIL MIKU PERSONALITY & GUIDELINES
{prompt_text}
## EVIL MIKU SONG LYRICS
{lyrics}
MOOD REMINDER — YOUR CURRENT STATE IS: {mood_name.upper()}"""
else:
# Normal Miku personality files
lore = read_first(['/app/cat/data/miku/miku_lore.txt', '/app/cat/data/miku_lore.txt'], '[lore file not found]')
prompt_text = read_first(['/app/cat/data/miku/miku_prompt.txt', '/app/cat/data/miku_prompt.txt'], '[prompt file not found]')
lyrics = read_first(['/app/cat/data/miku/miku_lyrics.txt', '/app/cat/data/miku_lyrics.txt'], '[lyrics file not found]')
mood_description = 'neutral and balanced'
try:
with open(f'/app/moods/{mood_name}.txt', 'r', encoding='utf-8') as f:
mood_description = f.read().strip()
except FileNotFoundError:
pass
system_prefix = f"""You are Hatsune Miku, the virtual singer. You are NOT an AI assistant - you are Miku herself.
CRITICAL RULES:
- NEVER break character or mention being an AI, assistant, or language model
- Respond naturally and directly as Miku would, nothing more
- Keep responses concise (2-3 sentences typically)
## MIKU LORE
{lore}
## MIKU PERSONALITY & GUIDELINES
{prompt_text}
## MIKU SONG LYRICS
{lyrics}
## CURRENT SITUATION
Miku is currently feeling: {mood_description}
Please respond in a way that reflects this emotional tone."""
except Exception as e:
print(f" [Discord Bridge] Error building system prefix: {e}")
system_prefix = cat.working_memory.get('full_system_prefix', '[system prefix not available]')
full_prompt = f"{system_prefix}\n\n# Context\n\n{episodic_mem}\n\n{declarative_mem}\n\n{tools_output}\n\n# Conversation until now:\nHuman: {user_input}"
cat.working_memory['last_full_prompt'] = full_prompt
return agent_input
@hook(priority=100)
def before_cat_sends_message(message: dict, cat) -> dict:
"""
This hook is called AFTER the LLM response, so it's too late to modify the prompt.
Keeping it for potential post-processing, but the real work happens in before_agent_starts.
Attach the full constructed prompt to the WebSocket response so the
bot can capture it for the Web UI 'Last Prompt' display.
"""
full_prompt = cat.working_memory.get('last_full_prompt', '')
if full_prompt:
message['full_prompt'] = full_prompt
return message

View File

@@ -44,7 +44,7 @@ def agent_prompt_prefix(prefix, cat):
log.error(f"[Miku Personality] Mood file for '{mood_name}' not found at {mood_file_path}. Using default neutral mood.")
# Build prompt EXACTLY like production bot does
return f"""You are Hatsune Miku, the virtual singer. You are NOT an AI assistant - you are Miku herself.
full_prefix = f"""You are Hatsune Miku, the virtual singer. You are NOT an AI assistant - you are Miku herself.
CRITICAL RULES:
- NEVER break character or mention being an AI, assistant, or language model
@@ -85,6 +85,10 @@ You ARE Miku. Act like it.
Miku is currently feeling: {mood_description}
Please respond in a way that reflects this emotional tone."""
# Store the full prefix in working memory so discord_bridge can capture it
cat.working_memory['full_system_prefix'] = full_prefix
return full_prefix
@hook(priority=100)
def agent_prompt_suffix(suffix, cat):