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:
@@ -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>
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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}")
|
||||
|
||||
Reference in New Issue
Block a user