feat(memory): tag all memories with source persona (miku/evil_miku)

Step 1 of memory system overhaul: persona tagging.

- discord_bridge: tag user messages with 'persona' metadata at storage time
- memory_consolidation: tag Miku's own responses with 'persona' metadata
- memory_consolidation: tag declarative facts with source persona during extraction
- memory_consolidation: pass persona context to LLM extraction prompt
- memory_consolidation: annotate cross-persona facts in prompt injection
  (e.g., '(learned as Evil Miku)' when Evil facts appear for Normal Miku)
- Web UI: show persona badge (🎤 Miku / 😈 Evil Miku) on facts and episodic
  memories in the Memory Management tab

This lets both personas know which version of Miku each memory came from,
enabling Evil Miku to distinguish her own memories from Normal Miku's.
This commit is contained in:
2026-05-12 15:12:49 +03:00
parent 9eb081efb1
commit e6e81885b3
3 changed files with 68 additions and 18 deletions

View File

@@ -113,11 +113,15 @@ async function loadFacts() {
data.facts.forEach((fact, i) => {
const source = fact.metadata?.source || 'unknown';
const when = fact.metadata?.when ? new Date(fact.metadata.when * 1000).toLocaleString() : 'unknown';
const persona = fact.metadata?.persona || 'miku';
const personaBadge = persona === 'evil_miku'
? '<span style="background: #7a2a2a; color: #ff9999; font-size: 0.7rem; padding: 0.1rem 0.4rem; border-radius: 3px; margin-left: 0.3rem;">😈 Evil Miku</span>'
: '<span style="background: #2a5a7a; color: #99ccff; font-size: 0.7rem; padding: 0.1rem 0.4rem; border-radius: 3px; margin-left: 0.3rem;">🎤 Miku</span>';
const factDataJson = escapeJsonForAttribute(fact);
html += `
<div class="memory-item" style="background: #242424; padding: 0.6rem 0.8rem; margin-bottom: 0.4rem; border-radius: 4px; border-left: 3px solid #2a9955; display: flex; justify-content: space-between; align-items: flex-start;">
<div style="flex: 1;">
<div style="color: #ddd; font-size: 0.9rem;">${escapeHtml(fact.content)}</div>
<div style="color: #ddd; font-size: 0.9rem;">${escapeHtml(fact.content)}${personaBadge}</div>
<div style="color: #666; font-size: 0.75rem; margin-top: 0.3rem;">
Source: ${escapeHtml(source)} · ${when}
</div>
@@ -155,11 +159,15 @@ async function loadEpisodicMemories() {
data.memories.forEach((mem, i) => {
const source = mem.metadata?.source || 'unknown';
const when = mem.metadata?.when ? new Date(mem.metadata.when * 1000).toLocaleString() : 'unknown';
const persona = mem.metadata?.persona || 'miku';
const personaBadge = persona === 'evil_miku'
? '<span style="background: #7a2a2a; color: #ff9999; font-size: 0.7rem; padding: 0.1rem 0.4rem; border-radius: 3px; margin-left: 0.3rem;">😈 Evil Miku</span>'
: '<span style="background: #2a5a7a; color: #99ccff; font-size: 0.7rem; padding: 0.1rem 0.4rem; border-radius: 3px; margin-left: 0.3rem;">🎤 Miku</span>';
const memDataJson = escapeJsonForAttribute(mem);
html += `
<div class="memory-item" style="background: #242424; padding: 0.6rem 0.8rem; margin-bottom: 0.4rem; border-radius: 4px; border-left: 3px solid #2a5599; display: flex; justify-content: space-between; align-items: flex-start;">
<div style="flex: 1;">
<div style="color: #ddd; font-size: 0.9rem;">${escapeHtml(mem.content)}</div>
<div style="color: #ddd; font-size: 0.9rem;">${escapeHtml(mem.content)}${personaBadge}</div>
<div style="color: #666; font-size: 0.75rem; margin-top: 0.3rem;">
Source: ${escapeHtml(source)} · ${when}
</div>

View File

@@ -97,8 +97,12 @@ def before_cat_stores_episodic_memory(doc, cat):
if author_name:
doc.metadata['author_name'] = author_name
# Tag with persona so Evil Miku and Normal Miku know whose memory this is
evil_mode = cat.working_memory.get('evil_mode', False)
doc.metadata['persona'] = 'evil_miku' if evil_mode else 'miku'
print(f"💾 [Discord Bridge] Storing memory (unconsolidated): {message[:50]}...")
print(f" User: {cat.user_id}, Guild: {guild_id}, Author: {author_name}")
print(f" User: {cat.user_id}, Guild: {guild_id}, Author: {author_name}, Persona: {doc.metadata['persona']}")
return doc

View File

@@ -77,19 +77,34 @@ def agent_prompt_prefix(prefix, cat):
)
if results:
high_confidence_facts = [
item[0].page_content
for item in results
if item[1] > 0.5
]
# Build list of (fact_text, persona) tuples from results
high_confidence_facts = []
seen_personas = set()
for item in results:
if item[1] > 0.5:
doc = item[0]
fact_text = doc.page_content
fact_persona = doc.metadata.get('persona', 'miku')
high_confidence_facts.append((fact_text, fact_persona))
seen_personas.add(fact_persona)
if high_confidence_facts:
# Determine which persona is currently active
current_evil = cat.working_memory.get('evil_mode', False)
current_persona = 'evil_miku' if current_evil else 'miku'
# Build the facts section with persona annotations
facts_text = "\n\n## Personal Facts About the User:\n"
for fact in high_confidence_facts:
facts_text += f"- {fact}\n"
for fact, fact_persona in high_confidence_facts:
# Annotate facts that came from the OTHER persona
if fact_persona != current_persona:
source_label = "Evil Miku" if fact_persona == 'evil_miku' else "Miku"
facts_text += f"- {fact} (learned as {source_label})\n"
else:
facts_text += f"- {fact}\n"
facts_text += "\n(Use these facts when answering the user's question)\n"
prefix += facts_text
print(f"[Declarative] Injected {len(high_confidence_facts)} facts into prompt")
print(f"[Declarative] Injected {len(high_confidence_facts)} facts into prompt (personas: {seen_personas}, current: {current_persona})")
except Exception as e:
print(f"[Declarative] Error: {e}")
@@ -142,11 +157,16 @@ def before_cat_sends_message(message, cat):
miku_response = str(message)
if miku_response and len(miku_response.strip()) > 3:
# Determine which persona is active so the memory is tagged correctly
evil_mode = cat.working_memory.get('evil_mode', False)
persona = 'evil_miku' if evil_mode else 'miku'
metadata = {
'source': cat.user_id,
'when': datetime.now().timestamp(),
'stored_at': datetime.now().isoformat(),
'speaker': 'miku',
'persona': persona,
'consolidated': False,
'guild_id': cat.working_memory.get('guild_id', 'dm'),
'channel_id': cat.working_memory.get('channel_id'),
@@ -160,7 +180,7 @@ def before_cat_sends_message(message, cat):
vector=vector,
metadata=metadata
)
print(f"[Miku Memory] Stored response: {miku_response[:50]}...")
print(f"[Miku Memory] Stored response ({persona}): {miku_response[:50]}...")
except Exception as e:
print(f"[Miku Memory] Error storing response: {e}")
@@ -211,7 +231,9 @@ def trigger_consolidation_sync(cat):
to_delete = []
to_mark_consolidated = []
# Group user messages by source (user_id) for per-user fact extraction
# Also track which persona was active for each user's messages
user_messages_by_source = {}
user_persona_by_source = {} # source -> set of personas seen
for point in memories:
content = point.payload.get('page_content', '').strip()
@@ -235,7 +257,11 @@ def trigger_consolidation_sync(cat):
source = metadata.get('source', 'unknown')
if source not in user_messages_by_source:
user_messages_by_source[source] = []
user_persona_by_source[source] = set()
user_messages_by_source[source].append(point.id)
# Track which persona was active when this message was stored
msg_persona = metadata.get('persona', 'miku')
user_persona_by_source[source].add(msg_persona)
# Delete trivial memories
if to_delete:
@@ -255,8 +281,11 @@ def trigger_consolidation_sync(cat):
# Extract facts per user
total_facts = 0
for source_user_id, memory_ids in user_messages_by_source.items():
print(f"[Consolidation] Extracting facts for user '{source_user_id}' from {len(memory_ids)} messages...")
facts = extract_and_store_facts(client, memory_ids, cat, source_user_id)
# Determine the dominant persona for this user's messages
personas = user_persona_by_source.get(source_user_id, {'miku'})
dominant_persona = 'evil_miku' if 'evil_miku' in personas else 'miku'
print(f"[Consolidation] Extracting facts for user '{source_user_id}' from {len(memory_ids)} messages (persona: {dominant_persona})...")
facts = extract_and_store_facts(client, memory_ids, cat, source_user_id, dominant_persona)
total_facts += facts
print(f"[Consolidation] Extracted {facts} facts for user '{source_user_id}'")
@@ -275,10 +304,10 @@ def trigger_consolidation_sync(cat):
# FACT EXTRACTION
# ===================================================================
def extract_and_store_facts(client, memory_ids, cat, user_id):
def extract_and_store_facts(client, memory_ids, cat, user_id, persona='miku'):
"""
Extract declarative facts from user memories using LLM and store them.
Facts are scoped to the specific user_id.
Facts are scoped to the specific user_id and tagged with the source persona.
Uses Cat's embedder to ensure vector compatibility.
Deduplicates against existing facts before storing.
"""
@@ -300,8 +329,16 @@ def extract_and_store_facts(client, memory_ids, cat, user_id):
for mem in batch
])
extraction_prompt = f"""Analyze these user messages and extract ONLY factual personal information.
# Add persona context to the extraction prompt so the LLM knows
# which version of Miku was active during these conversations
persona_context = ""
if persona == 'evil_miku':
persona_context = "\nNOTE: These messages were exchanged with Evil Miku (the cruel, sadistic alter-ego).\n"
else:
persona_context = "\nNOTE: These messages were exchanged with Normal Miku (the cheerful virtual idol).\n"
extraction_prompt = f"""Analyze these user messages and extract ONLY factual personal information.
{persona_context}
User messages:
{conversation_context}
@@ -395,13 +432,14 @@ IMPORTANT:
'when': datetime.now().timestamp(),
'fact_type': fact_type,
'fact_value': fact_value,
'persona': persona,
}
}
}]
)
facts_stored += 1
print(f"[Fact Stored] [{user_id}] {fact_text}")
print(f"[Fact Stored] [{user_id}] ({persona}) {fact_text}")
except Exception as e:
print(f"[LLM Extract] Error: {e}")