Compare commits

..

3 Commits

Author SHA1 Message Date
5fe420b7bc Web UI tabs made into two rows 2026-02-07 22:16:01 +02:00
14e1a8df51 Phase 3: Unified Cheshire Cat integration with WebSocket-based per-user isolation
Key changes:
- CatAdapter (bot/utils/cat_client.py): WebSocket /ws/{user_id} for chat
  queries instead of HTTP POST (fixes per-user memory isolation when no
  API keys are configured — HTTP defaults all users to user_id='user')
- Memory management API: 8 endpoints for status, stats, facts, episodic
  memories, consolidation trigger, multi-step delete with confirmation
- Web UI: Memory tab (tab9) with collection stats, fact/episodic browser,
  manual consolidation trigger, and 3-step delete flow requiring exact
  confirmation string
- Bot integration: Cat-first response path with query_llama fallback for
  both text and embed responses, server mood detection
- Discord bridge plugin: fixed .pop() to .get() (UserMessage is a Pydantic
  BaseModelDict, not a raw dict), metadata extraction via extra attributes
- Unified docker-compose: Cat + Qdrant services merged into main compose,
  bot depends_on Cat healthcheck
- All plugins (discord_bridge, memory_consolidation, miku_personality)
  consolidated into cat-plugins/ for volume mount
- query_llama deprecated but functional for compatibility
2026-02-07 20:22:03 +02:00
edb88e9ede fix: Phase 2 integrity review - v2.0.0 rewrite & bugfixes
Memory Consolidation Plugin (828 -> 465 lines):
- Replace SentenceTransformer with cat.embedder.embed_query() for vector consistency
- Fix per-user fact isolation: source=user_id instead of global
- Add duplicate fact detection (_is_duplicate_fact, score_threshold=0.85)
- Remove ~350 lines of dead async run_consolidation() code
- Remove duplicate declarative search in before_cat_sends_message
- Unify trivial patterns into TRIVIAL_PATTERNS frozenset
- Remove all sys.stderr.write debug logging
- Remove sentence-transformers from requirements.txt (no external deps)

Loguru Fix (cheshire-cat/cat/log.py):
- Patch Cat v1.6.2 loguru format to provide default extra fields
- Fixes KeyError: 'original_name' from third-party libs (fastembed)
- Mounted via docker-compose volume

Discord Bridge:
- Copy discord_bridge.py to cat-plugins/ (was empty directory)

Test Results (6/7 pass, 100% fact recall):
- 11 facts extracted, per-user isolation working
- Duplicate detection effective (+2 on 2nd run)
- 5/5 natural language recall queries correct
2026-02-07 19:24:46 +02:00
19 changed files with 2216 additions and 778 deletions

View File

@@ -2772,6 +2772,134 @@ def set_voice_debug_mode(enabled: bool = Form(...)):
}
# ========== Cheshire Cat Memory Management (Phase 3) ==========
class MemoryDeleteRequest(BaseModel):
confirmation: str
@app.get("/memory/status")
async def get_cat_memory_status():
"""Get Cheshire Cat connection status and feature flag."""
from utils.cat_client import cat_adapter
is_healthy = await cat_adapter.health_check()
return {
"enabled": globals.USE_CHESHIRE_CAT,
"healthy": is_healthy,
"url": globals.CHESHIRE_CAT_URL,
"circuit_breaker_active": cat_adapter._is_circuit_broken(),
"consecutive_failures": cat_adapter._consecutive_failures
}
@app.post("/memory/toggle")
async def toggle_cat_integration(enabled: bool = Form(...)):
"""Toggle Cheshire Cat integration on/off."""
globals.USE_CHESHIRE_CAT = enabled
logger.info(f"🐱 Cheshire Cat integration {'ENABLED' if enabled else 'DISABLED'}")
return {
"success": True,
"enabled": globals.USE_CHESHIRE_CAT,
"message": f"Cheshire Cat {'enabled' if enabled else 'disabled'}"
}
@app.get("/memory/stats")
async def get_memory_stats():
"""Get memory collection statistics from Cheshire Cat (point counts per collection)."""
from utils.cat_client import cat_adapter
stats = await cat_adapter.get_memory_stats()
if stats is None:
return {"success": False, "error": "Could not reach Cheshire Cat"}
return {"success": True, "collections": stats.get("collections", [])}
@app.get("/memory/facts")
async def get_memory_facts():
"""Get all declarative memory facts (learned knowledge about users)."""
from utils.cat_client import cat_adapter
facts = await cat_adapter.get_all_facts()
return {"success": True, "facts": facts, "count": len(facts)}
@app.get("/memory/episodic")
async def get_episodic_memories():
"""Get all episodic memories (conversation snippets)."""
from utils.cat_client import cat_adapter
result = await cat_adapter.get_memory_points(collection="episodic", limit=100)
if result is None:
return {"success": False, "error": "Could not reach Cheshire Cat"}
memories = []
for point in result.get("points", []):
payload = point.get("payload", {})
memories.append({
"id": point.get("id"),
"content": payload.get("page_content", ""),
"metadata": payload.get("metadata", {}),
})
return {"success": True, "memories": memories, "count": len(memories)}
@app.post("/memory/consolidate")
async def trigger_memory_consolidation():
"""Manually trigger memory consolidation (sleep consolidation process)."""
from utils.cat_client import cat_adapter
logger.info("🌙 Manual memory consolidation triggered via API")
result = await cat_adapter.trigger_consolidation()
if result is None:
return {"success": False, "error": "Consolidation failed or timed out"}
return {"success": True, "result": result}
@app.post("/memory/delete")
async def delete_all_memories(request: MemoryDeleteRequest):
"""
Delete ALL of Miku's memories. Requires exact confirmation string.
The confirmation field must be exactly:
"Yes, I am deleting Miku's memories fully."
This is destructive and irreversible.
"""
REQUIRED_CONFIRMATION = "Yes, I am deleting Miku's memories fully."
if request.confirmation != REQUIRED_CONFIRMATION:
logger.warning(f"Memory deletion rejected: wrong confirmation string")
return {
"success": False,
"error": "Confirmation string does not match. "
f"Expected exactly: \"{REQUIRED_CONFIRMATION}\""
}
from utils.cat_client import cat_adapter
logger.warning("⚠️ MEMORY DELETION CONFIRMED — wiping all memories!")
# Wipe vector memories (episodic + declarative)
wipe_success = await cat_adapter.wipe_all_memories()
# Also clear conversation history
history_success = await cat_adapter.wipe_conversation_history()
if wipe_success:
logger.warning("🗑️ All Miku memories have been deleted.")
return {
"success": True,
"message": "All memories have been permanently deleted.",
"vector_memory_wiped": wipe_success,
"conversation_history_cleared": history_success
}
else:
return {
"success": False,
"error": "Failed to wipe memory collections. Check Cat connection."
}
@app.delete("/memory/point/{collection}/{point_id}")
async def delete_single_memory_point(collection: str, point_id: str):
"""Delete a single memory point by collection and ID."""
from utils.cat_client import cat_adapter
success = await cat_adapter.delete_memory_point(collection, point_id)
if success:
return {"success": True, "deleted": point_id}
else:
return {"success": False, "error": f"Failed to delete point {point_id}"}
def start_api():
import uvicorn
uvicorn.run(app, host="0.0.0.0", port=3939)

View File

@@ -512,14 +512,34 @@ async def on_message(message):
guild_id = message.guild.id if message.guild else None
response_type = "dm_response" if is_dm else "server_response"
author_name = message.author.display_name
response = await query_llama(
enhanced_prompt,
user_id=str(message.author.id),
guild_id=guild_id,
response_type=response_type,
author_name=author_name
)
# Phase 3: Try Cat pipeline first for embed responses too
response = None
if globals.USE_CHESHIRE_CAT:
try:
from utils.cat_client import cat_adapter
response = await cat_adapter.query(
text=enhanced_prompt,
user_id=str(message.author.id),
guild_id=str(guild_id) if guild_id else None,
author_name=author_name,
mood=globals.DM_MOOD,
response_type=response_type,
)
if response:
logger.info(f"🐱 Cat embed response for {author_name}")
except Exception as e:
logger.warning(f"🐱 Cat embed error, fallback: {e}")
response = None
if not response:
response = await query_llama(
enhanced_prompt,
user_id=str(message.author.id),
guild_id=guild_id,
response_type=response_type,
author_name=author_name
)
if is_dm:
logger.info(f"💌 DM embed response to {message.author.display_name} (using DM mood: {globals.DM_MOOD})")
@@ -570,13 +590,46 @@ async def on_message(message):
guild_id = message.guild.id if message.guild else None
response_type = "dm_response" if is_dm else "server_response"
author_name = message.author.display_name
response = await query_llama(
prompt,
user_id=str(message.author.id),
guild_id=guild_id,
response_type=response_type,
author_name=author_name
)
# Phase 3: Try Cheshire Cat pipeline first (memory-augmented response)
# Falls back to query_llama if Cat is unavailable or disabled
response = None
if globals.USE_CHESHIRE_CAT:
try:
from utils.cat_client import cat_adapter
current_mood = globals.DM_MOOD
if guild_id:
try:
from server_manager import server_manager
sc = server_manager.get_server_config(guild_id)
if sc:
current_mood = sc.current_mood_name
except Exception:
pass
response = await cat_adapter.query(
text=prompt,
user_id=str(message.author.id),
guild_id=str(guild_id) if guild_id else None,
author_name=author_name,
mood=current_mood,
response_type=response_type,
)
if response:
logger.info(f"🐱 Cat response for {author_name} (mood: {current_mood})")
except Exception as e:
logger.warning(f"🐱 Cat pipeline error, falling back to query_llama: {e}")
response = None
# Fallback to direct LLM query if Cat didn't respond
if not response:
response = await query_llama(
prompt,
user_id=str(message.author.id),
guild_id=guild_id,
response_type=response_type,
author_name=author_name
)
if is_dm:
logger.info(f"💌 DM response to {message.author.display_name} (using DM mood: {globals.DM_MOOD})")

View File

@@ -29,6 +29,12 @@ EVIL_TEXT_MODEL = os.getenv("EVIL_TEXT_MODEL", "darkidol") # Uncensored model f
JAPANESE_TEXT_MODEL = os.getenv("JAPANESE_TEXT_MODEL", "swallow") # Llama 3.1 Swallow model for Japanese
OWNER_USER_ID = int(os.getenv("OWNER_USER_ID", "209381657369772032")) # Bot owner's Discord user ID for reports
# Cheshire Cat AI integration (Phase 3)
CHESHIRE_CAT_URL = os.getenv("CHESHIRE_CAT_URL", "http://cheshire-cat:80")
USE_CHESHIRE_CAT = os.getenv("USE_CHESHIRE_CAT", "false").lower() == "true"
CHESHIRE_CAT_API_KEY = os.getenv("CHESHIRE_CAT_API_KEY", "") # Empty = no auth
CHESHIRE_CAT_TIMEOUT = int(os.getenv("CHESHIRE_CAT_TIMEOUT", "120")) # Seconds
# Language mode for Miku (english or japanese)
LANGUAGE_MODE = "english" # Can be "english" or "japanese"

View File

@@ -416,14 +416,19 @@
}
.tab-buttons {
display: flex;
display: grid;
grid-template-rows: repeat(2, auto);
grid-auto-flow: column;
grid-auto-columns: max-content;
border-bottom: 2px solid #333;
margin-bottom: 1rem;
overflow-x: auto;
overflow-y: hidden;
white-space: nowrap;
scrollbar-width: thin;
scrollbar-color: #555 #222;
row-gap: 0.05rem;
column-gap: 0.1rem;
padding-bottom: 0.1rem;
}
.tab-buttons::-webkit-scrollbar {
@@ -447,12 +452,10 @@
background: #222;
color: #ccc;
border: none;
padding: 0.8rem 1.5rem;
padding: 0.5rem 1rem;
cursor: pointer;
border-bottom: 3px solid transparent;
margin-right: 0.5rem;
transition: all 0.3s ease;
flex-shrink: 0;
white-space: nowrap;
}
@@ -665,6 +668,7 @@
<button class="tab-button" onclick="switchTab('tab6')">📊 Autonomous Stats</button>
<button class="tab-button" onclick="switchTab('tab7')">💬 Chat with LLM</button>
<button class="tab-button" onclick="switchTab('tab8')">📞 Voice Call</button>
<button class="tab-button" onclick="switchTab('tab9')">🧠 Memories</button>
<button class="tab-button" onclick="window.location.href='/static/system.html'">🎛️ System Settings</button>
</div>
@@ -1547,6 +1551,142 @@
</div>
</div>
<!-- Tab 9: Memory Management -->
<div id="tab9" class="tab-content">
<div class="section">
<h3>🧠 Cheshire Cat Memory Management</h3>
<p style="color: #aaa; margin-bottom: 1rem;">
Manage Miku's long-term memories powered by the Cheshire Cat AI pipeline.
Memories are stored in Qdrant vector database and used to give Miku persistent knowledge about users.
</p>
<!-- Cat Integration Status -->
<div id="cat-status-section" style="background: #1a1a2e; border: 1px solid #444; border-radius: 8px; padding: 1rem; margin-bottom: 1.5rem;">
<div style="display: flex; justify-content: space-between; align-items: center;">
<div>
<h4 style="margin: 0 0 0.3rem 0;">🐱 Cheshire Cat Status</h4>
<span id="cat-status-indicator" style="color: #888;">Checking...</span>
</div>
<div style="display: flex; gap: 0.5rem; align-items: center;">
<button id="cat-toggle-btn" onclick="toggleCatIntegration()" style="background: #333; color: #fff; padding: 0.4rem 0.8rem; border: 2px solid #666; border-radius: 4px; cursor: pointer; font-weight: bold; font-size: 0.85rem;">
Loading...
</button>
<button onclick="refreshMemoryStats()" style="background: #2a5599; color: #fff; padding: 0.4rem 0.8rem; border: none; border-radius: 4px; cursor: pointer;">
🔄 Refresh
</button>
</div>
</div>
</div>
<!-- Memory Statistics -->
<div style="display: grid; grid-template-columns: repeat(3, 1fr); gap: 1rem; margin-bottom: 1.5rem;">
<div id="stat-episodic" style="background: #1a2332; border: 1px solid #2a5599; border-radius: 8px; padding: 1rem; text-align: center;">
<div style="font-size: 2rem; font-weight: bold; color: #61dafb;" id="stat-episodic-count"></div>
<div style="color: #aaa; font-size: 0.85rem;">📝 Episodic Memories</div>
<div style="color: #666; font-size: 0.75rem; margin-top: 0.3rem;">Conversation snippets</div>
</div>
<div id="stat-declarative" style="background: #1a3322; border: 1px solid #2a9955; border-radius: 8px; padding: 1rem; text-align: center;">
<div style="font-size: 2rem; font-weight: bold; color: #6fdc6f;" id="stat-declarative-count"></div>
<div style="color: #aaa; font-size: 0.85rem;">📚 Declarative Facts</div>
<div style="color: #666; font-size: 0.75rem; margin-top: 0.3rem;">Learned knowledge</div>
</div>
<div id="stat-procedural" style="background: #332a1a; border: 1px solid #995e2a; border-radius: 8px; padding: 1rem; text-align: center;">
<div style="font-size: 2rem; font-weight: bold; color: #dcb06f;" id="stat-procedural-count"></div>
<div style="color: #aaa; font-size: 0.85rem;">⚙️ Procedural</div>
<div style="color: #666; font-size: 0.75rem; margin-top: 0.3rem;">Tools & procedures</div>
</div>
</div>
<!-- Consolidation -->
<div style="background: #1a1a2e; border: 1px solid #444; border-radius: 8px; padding: 1rem; margin-bottom: 1.5rem;">
<h4 style="margin: 0 0 0.5rem 0;">🌙 Memory Consolidation</h4>
<p style="color: #aaa; font-size: 0.85rem; margin-bottom: 0.75rem;">
Trigger the sleep consolidation process: analyzes episodic memories, extracts important facts, and removes trivial entries.
</p>
<div style="display: flex; gap: 0.5rem; align-items: center;">
<button id="consolidate-btn" onclick="triggerConsolidation()" style="background: #5b3a8c; color: #fff; padding: 0.5rem 1rem; border: none; border-radius: 4px; cursor: pointer; font-weight: bold;">
🌙 Run Consolidation
</button>
<span id="consolidation-status" style="color: #888; font-size: 0.85rem;"></span>
</div>
<div id="consolidation-result" style="display: none; margin-top: 0.75rem; background: #111; border: 1px solid #333; border-radius: 4px; padding: 0.75rem; font-size: 0.85rem; color: #ccc; white-space: pre-wrap; max-height: 200px; overflow-y: auto;"></div>
</div>
<!-- Declarative Facts Browser -->
<div style="background: #1a1a2e; border: 1px solid #444; border-radius: 8px; padding: 1rem; margin-bottom: 1.5rem;">
<div style="display: flex; justify-content: space-between; align-items: center; margin-bottom: 0.75rem;">
<h4 style="margin: 0;">📚 Declarative Facts</h4>
<button onclick="loadFacts()" style="background: #2a5599; color: #fff; padding: 0.3rem 0.7rem; border: none; border-radius: 4px; cursor: pointer; font-size: 0.85rem;">
🔄 Load Facts
</button>
</div>
<div id="facts-list" style="max-height: 400px; overflow-y: auto;">
<div style="text-align: center; color: #666; padding: 2rem;">Click "Load Facts" to view stored knowledge</div>
</div>
</div>
<!-- Episodic Memories Browser -->
<div style="background: #1a1a2e; border: 1px solid #444; border-radius: 8px; padding: 1rem; margin-bottom: 1.5rem;">
<div style="display: flex; justify-content: space-between; align-items: center; margin-bottom: 0.75rem;">
<h4 style="margin: 0;">📝 Episodic Memories</h4>
<button onclick="loadEpisodicMemories()" style="background: #2a5599; color: #fff; padding: 0.3rem 0.7rem; border: none; border-radius: 4px; cursor: pointer; font-size: 0.85rem;">
🔄 Load Memories
</button>
</div>
<div id="episodic-list" style="max-height: 400px; overflow-y: auto;">
<div style="text-align: center; color: #666; padding: 2rem;">Click "Load Memories" to view conversation snippets</div>
</div>
</div>
<!-- DANGER ZONE: Delete All Memories -->
<div style="background: #2e1a1a; border: 2px solid #993333; border-radius: 8px; padding: 1rem;">
<h4 style="margin: 0 0 0.5rem 0; color: #ff6b6b;">⚠️ Danger Zone — Delete All Memories</h4>
<p style="color: #cc9999; font-size: 0.85rem; margin-bottom: 1rem;">
This will permanently erase ALL of Miku's memories — episodic conversations, learned facts, everything.
This action is <strong>irreversible</strong>. Miku will forget everything she has ever learned.
</p>
<!-- Step 1: Initial checkbox -->
<div id="delete-step-1" style="margin-bottom: 0.75rem;">
<label style="cursor: pointer; color: #ff9999;">
<input type="checkbox" id="delete-checkbox-1" onchange="onDeleteStep1Change()">
I understand this will permanently delete all of Miku's memories
</label>
</div>
<!-- Step 2: Second confirmation (hidden initially) -->
<div id="delete-step-2" style="display: none; margin-bottom: 0.75rem;">
<label style="cursor: pointer; color: #ff9999;">
<input type="checkbox" id="delete-checkbox-2" onchange="onDeleteStep2Change()">
I confirm this is irreversible and I want to proceed
</label>
</div>
<!-- Step 3: Type confirmation string (hidden initially) -->
<div id="delete-step-3" style="display: none; margin-bottom: 0.75rem;">
<p style="color: #ff6b6b; font-size: 0.85rem; margin-bottom: 0.5rem;">
Type exactly: <code style="background: #333; padding: 0.2rem 0.4rem; border-radius: 3px; color: #ff9999;">Yes, I am deleting Miku's memories fully.</code>
</p>
<input type="text" id="delete-confirmation-input" placeholder="Type the confirmation string..."
style="width: 100%; padding: 0.5rem; background: #1a1a1a; color: #ff9999; border: 1px solid #993333; border-radius: 4px; font-family: monospace; box-sizing: border-box;"
oninput="onDeleteInputChange()">
</div>
<!-- Final delete button (hidden initially) -->
<div id="delete-step-final" style="display: none;">
<button id="delete-all-btn" onclick="executeDeleteAllMemories()" disabled
style="background: #cc3333; color: #fff; padding: 0.5rem 1.5rem; border: none; border-radius: 4px; cursor: not-allowed; font-weight: bold; opacity: 0.5;">
🗑️ Permanently Delete All Memories
</button>
<button onclick="resetDeleteFlow()" style="background: #444; color: #ccc; padding: 0.5rem 1rem; border: none; border-radius: 4px; cursor: pointer; margin-left: 0.5rem;">
Cancel
</button>
</div>
</div>
</div>
</div>
<div class="logs">
<h3>Logs</h3>
<div id="logs-content"></div>
@@ -1611,6 +1751,10 @@ function switchTab(tabId) {
console.log('🔄 Refreshing figurine subscribers for Server Management tab');
refreshFigurineSubscribers();
}
if (tabId === 'tab9') {
console.log('🧠 Refreshing memory stats for Memories tab');
refreshMemoryStats();
}
}
// Initialize
@@ -5020,6 +5164,292 @@ function updateVoiceCallHistoryDisplay() {
historyDiv.innerHTML = html;
}
// ========== Memory Management (Tab 9) ==========
async function refreshMemoryStats() {
try {
// Fetch Cat status
const statusRes = await fetch('/memory/status');
const statusData = await statusRes.json();
const indicator = document.getElementById('cat-status-indicator');
const toggleBtn = document.getElementById('cat-toggle-btn');
if (statusData.healthy) {
indicator.innerHTML = `<span style="color: #6fdc6f;">● Connected</span> — ${statusData.url}`;
} else {
indicator.innerHTML = `<span style="color: #ff6b6b;">● Disconnected</span> — ${statusData.url}`;
}
if (statusData.circuit_breaker_active) {
indicator.innerHTML += ` <span style="color: #dcb06f;">(circuit breaker active)</span>`;
}
toggleBtn.textContent = statusData.enabled ? '🐱 Cat: ON' : '😿 Cat: OFF';
toggleBtn.style.background = statusData.enabled ? '#2a7a2a' : '#7a2a2a';
toggleBtn.style.borderColor = statusData.enabled ? '#4a9a4a' : '#9a4a4a';
// Fetch memory stats
const statsRes = await fetch('/memory/stats');
const statsData = await statsRes.json();
if (statsData.success && statsData.collections) {
const collections = {};
statsData.collections.forEach(c => { collections[c.name] = c.vectors_count; });
document.getElementById('stat-episodic-count').textContent = collections['episodic'] ?? '—';
document.getElementById('stat-declarative-count').textContent = collections['declarative'] ?? '—';
document.getElementById('stat-procedural-count').textContent = collections['procedural'] ?? '—';
} else {
document.getElementById('stat-episodic-count').textContent = '—';
document.getElementById('stat-declarative-count').textContent = '—';
document.getElementById('stat-procedural-count').textContent = '—';
}
} catch (err) {
console.error('Error refreshing memory stats:', err);
document.getElementById('cat-status-indicator').innerHTML = '<span style="color: #ff6b6b;">● Error checking status</span>';
}
}
async function toggleCatIntegration() {
try {
const statusRes = await fetch('/memory/status');
const statusData = await statusRes.json();
const newState = !statusData.enabled;
const formData = new FormData();
formData.append('enabled', newState);
const res = await fetch('/memory/toggle', { method: 'POST', body: formData });
const data = await res.json();
if (data.success) {
showNotification(`Cheshire Cat ${newState ? 'enabled' : 'disabled'}`, newState ? 'success' : 'info');
refreshMemoryStats();
}
} catch (err) {
showNotification('Failed to toggle Cat integration', 'error');
}
}
async function triggerConsolidation() {
const btn = document.getElementById('consolidate-btn');
const status = document.getElementById('consolidation-status');
const resultDiv = document.getElementById('consolidation-result');
btn.disabled = true;
btn.textContent = '⏳ Running...';
status.textContent = 'Consolidation in progress (this may take a few minutes)...';
resultDiv.style.display = 'none';
try {
const res = await fetch('/memory/consolidate', { method: 'POST' });
const data = await res.json();
if (data.success) {
status.textContent = '✅ Consolidation complete!';
status.style.color = '#6fdc6f';
resultDiv.textContent = data.result || 'Consolidation finished successfully.';
resultDiv.style.display = 'block';
showNotification('Memory consolidation complete', 'success');
refreshMemoryStats();
} else {
status.textContent = '❌ ' + (data.error || 'Consolidation failed');
status.style.color = '#ff6b6b';
}
} catch (err) {
status.textContent = '❌ Error: ' + err.message;
status.style.color = '#ff6b6b';
} finally {
btn.disabled = false;
btn.textContent = '🌙 Run Consolidation';
}
}
async function loadFacts() {
const listDiv = document.getElementById('facts-list');
listDiv.innerHTML = '<div style="text-align: center; color: #888; padding: 1rem;">Loading facts...</div>';
try {
const res = await fetch('/memory/facts');
const data = await res.json();
if (!data.success || data.count === 0) {
listDiv.innerHTML = '<div style="text-align: center; color: #666; padding: 2rem;">No declarative facts stored yet.</div>';
return;
}
let html = '';
data.facts.forEach((fact, i) => {
const source = fact.metadata?.source || 'unknown';
const when = fact.metadata?.when ? new Date(fact.metadata.when * 1000).toLocaleString() : 'unknown';
html += `
<div 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: #666; font-size: 0.75rem; margin-top: 0.3rem;">
Source: ${escapeHtml(source)} · ${when}
</div>
</div>
<button onclick="deleteMemoryPoint('declarative', '${fact.id}', this)"
style="background: none; border: none; color: #993333; cursor: pointer; padding: 0.2rem 0.4rem; font-size: 0.85rem; flex-shrink: 0;"
title="Delete this fact">🗑️</button>
</div>`;
});
listDiv.innerHTML = `<div style="color: #888; font-size: 0.8rem; margin-bottom: 0.5rem;">${data.count} facts loaded</div>` + html;
} catch (err) {
listDiv.innerHTML = `<div style="color: #ff6b6b; padding: 1rem;">Error loading facts: ${err.message}</div>`;
}
}
async function loadEpisodicMemories() {
const listDiv = document.getElementById('episodic-list');
listDiv.innerHTML = '<div style="text-align: center; color: #888; padding: 1rem;">Loading memories...</div>';
try {
const res = await fetch('/memory/episodic');
const data = await res.json();
if (!data.success || data.count === 0) {
listDiv.innerHTML = '<div style="text-align: center; color: #666; padding: 2rem;">No episodic memories stored yet.</div>';
return;
}
let html = '';
data.memories.forEach((mem, i) => {
const source = mem.metadata?.source || 'unknown';
const when = mem.metadata?.when ? new Date(mem.metadata.when * 1000).toLocaleString() : 'unknown';
html += `
<div 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: #666; font-size: 0.75rem; margin-top: 0.3rem;">
Source: ${escapeHtml(source)} · ${when}
</div>
</div>
<button onclick="deleteMemoryPoint('episodic', '${mem.id}', this)"
style="background: none; border: none; color: #993333; cursor: pointer; padding: 0.2rem 0.4rem; font-size: 0.85rem; flex-shrink: 0;"
title="Delete this memory">🗑️</button>
</div>`;
});
listDiv.innerHTML = `<div style="color: #888; font-size: 0.8rem; margin-bottom: 0.5rem;">${data.count} memories loaded</div>` + html;
} catch (err) {
listDiv.innerHTML = `<div style="color: #ff6b6b; padding: 1rem;">Error loading memories: ${err.message}</div>`;
}
}
async function deleteMemoryPoint(collection, pointId, btnElement) {
if (!confirm(`Delete this ${collection} memory point?`)) return;
try {
const res = await fetch(`/memory/point/${collection}/${pointId}`, { method: 'DELETE' });
const data = await res.json();
if (data.success) {
// Remove the row from the UI
const row = btnElement.closest('div[style*="margin-bottom"]');
if (row) row.remove();
showNotification('Memory point deleted', 'success');
refreshMemoryStats();
} else {
showNotification('Failed to delete: ' + (data.error || 'Unknown error'), 'error');
}
} catch (err) {
showNotification('Error: ' + err.message, 'error');
}
}
// Delete All Memories — Multi-step confirmation flow
function onDeleteStep1Change() {
const checked = document.getElementById('delete-checkbox-1').checked;
document.getElementById('delete-step-2').style.display = checked ? 'block' : 'none';
if (!checked) {
document.getElementById('delete-checkbox-2').checked = false;
document.getElementById('delete-step-3').style.display = 'none';
document.getElementById('delete-step-final').style.display = 'none';
document.getElementById('delete-confirmation-input').value = '';
}
}
function onDeleteStep2Change() {
const checked = document.getElementById('delete-checkbox-2').checked;
document.getElementById('delete-step-3').style.display = checked ? 'block' : 'none';
document.getElementById('delete-step-final').style.display = checked ? 'block' : 'none';
if (!checked) {
document.getElementById('delete-confirmation-input').value = '';
updateDeleteButton();
}
}
function onDeleteInputChange() {
updateDeleteButton();
}
function updateDeleteButton() {
const input = document.getElementById('delete-confirmation-input').value;
const expected = "Yes, I am deleting Miku's memories fully.";
const btn = document.getElementById('delete-all-btn');
const match = input === expected;
btn.disabled = !match;
btn.style.cursor = match ? 'pointer' : 'not-allowed';
btn.style.opacity = match ? '1' : '0.5';
}
async function executeDeleteAllMemories() {
const input = document.getElementById('delete-confirmation-input').value;
const expected = "Yes, I am deleting Miku's memories fully.";
if (input !== expected) {
showNotification('Confirmation string does not match', 'error');
return;
}
const btn = document.getElementById('delete-all-btn');
btn.disabled = true;
btn.textContent = '⏳ Deleting...';
try {
const res = await fetch('/memory/delete', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ confirmation: input })
});
const data = await res.json();
if (data.success) {
showNotification('All memories have been permanently deleted', 'success');
resetDeleteFlow();
refreshMemoryStats();
} else {
showNotification('Deletion failed: ' + (data.error || 'Unknown error'), 'error');
}
} catch (err) {
showNotification('Error: ' + err.message, 'error');
} finally {
btn.disabled = false;
btn.textContent = '🗑️ Permanently Delete All Memories';
}
}
function resetDeleteFlow() {
document.getElementById('delete-checkbox-1').checked = false;
document.getElementById('delete-checkbox-2').checked = false;
document.getElementById('delete-confirmation-input').value = '';
document.getElementById('delete-step-2').style.display = 'none';
document.getElementById('delete-step-3').style.display = 'none';
document.getElementById('delete-step-final').style.display = 'none';
updateDeleteButton();
}
function escapeHtml(str) {
if (!str) return '';
const div = document.createElement('div');
div.textContent = str;
return div.innerHTML;
}
</script>
</body>

479
bot/utils/cat_client.py Normal file
View File

@@ -0,0 +1,479 @@
# utils/cat_client.py
"""
Cheshire Cat AI Adapter for Miku Discord Bot (Phase 3)
Routes messages through the Cheshire Cat pipeline for:
- Memory-augmented responses (episodic + declarative recall)
- Fact extraction and consolidation
- Per-user conversation isolation
Uses WebSocket for chat (per-user isolation via /ws/{user_id}).
Uses HTTP for memory management endpoints.
Falls back to query_llama() on failure for zero-downtime resilience.
"""
import aiohttp
import asyncio
import json
import time
from typing import Optional, Dict, Any, List
import globals
from utils.logger import get_logger
logger = get_logger('cat_client')
class CatAdapter:
"""
Async adapter for Cheshire Cat AI.
Uses WebSocket /ws/{user_id} for conversation (per-user memory isolation).
Uses HTTP REST for memory management endpoints.
Without API keys configured, HTTP POST /message defaults all users to
user_id="user" (no isolation). WebSocket path param gives true isolation.
"""
def __init__(self):
self._base_url = globals.CHESHIRE_CAT_URL.rstrip('/')
self._api_key = globals.CHESHIRE_CAT_API_KEY
self._timeout = globals.CHESHIRE_CAT_TIMEOUT
self._healthy = None # None = unknown, True/False = last check result
self._last_health_check = 0
self._health_check_interval = 30 # seconds between health checks
self._consecutive_failures = 0
self._max_failures_before_circuit_break = 3
self._circuit_broken_until = 0 # timestamp when circuit breaker resets
logger.info(f"CatAdapter initialized: {self._base_url} (timeout={self._timeout}s)")
def _get_headers(self) -> dict:
"""Build request headers with optional auth."""
headers = {'Content-Type': 'application/json'}
if self._api_key:
headers['Authorization'] = f'Bearer {self._api_key}'
return headers
def _user_id_for_discord(self, user_id: str) -> str:
"""
Format Discord user ID for Cat's user namespace.
Cat uses user_id to isolate working memory and episodic memories.
"""
return f"discord_{user_id}"
async def health_check(self) -> bool:
"""
Check if Cheshire Cat is reachable and healthy.
Caches result to avoid hammering the endpoint.
"""
now = time.time()
if now - self._last_health_check < self._health_check_interval and self._healthy is not None:
return self._healthy
try:
async with aiohttp.ClientSession() as session:
async with session.get(
f"{self._base_url}/",
headers=self._get_headers(),
timeout=aiohttp.ClientTimeout(total=10)
) as response:
self._healthy = response.status == 200
self._last_health_check = now
if self._healthy:
logger.debug("Cat health check: OK")
else:
logger.warning(f"Cat health check failed: status {response.status}")
return self._healthy
except Exception as e:
self._healthy = False
self._last_health_check = now
logger.warning(f"Cat health check error: {e}")
return False
def _is_circuit_broken(self) -> bool:
"""Check if circuit breaker is active (too many consecutive failures)."""
if self._consecutive_failures >= self._max_failures_before_circuit_break:
if time.time() < self._circuit_broken_until:
return True
# Circuit breaker expired, allow retry
logger.info("Circuit breaker reset, allowing Cat retry")
self._consecutive_failures = 0
return False
async def query(
self,
text: str,
user_id: str,
guild_id: Optional[str] = None,
author_name: Optional[str] = None,
mood: Optional[str] = None,
response_type: str = "dm_response",
) -> Optional[str]:
"""
Send a message through the Cat pipeline via WebSocket and get a response.
Uses WebSocket /ws/{user_id} for per-user memory isolation.
Without API keys, HTTP POST /message defaults all users to user_id="user"
(no isolation). The WebSocket path parameter provides true per-user isolation
because Cat's auth handler uses user_id from the path when no keys are set.
Args:
text: User's message text
user_id: Discord user ID (will be namespaced as discord_{user_id})
guild_id: Optional guild ID for server context
author_name: Display name of the user
mood: Current mood name (passed as metadata for Cat hooks)
response_type: Type of response context
Returns:
Cat's response text, or None if Cat is unavailable (caller should fallback)
"""
if not globals.USE_CHESHIRE_CAT:
return None
if self._is_circuit_broken():
logger.debug("Circuit breaker active, skipping Cat")
return None
cat_user_id = self._user_id_for_discord(user_id)
# Build message payload with Discord metadata for our plugin hooks.
# The discord_bridge plugin's before_cat_reads_message hook reads
# these custom keys from the message dict.
payload = {
"text": text,
}
if guild_id:
payload["discord_guild_id"] = str(guild_id)
if author_name:
payload["discord_author_name"] = author_name
if mood:
payload["discord_mood"] = mood
if response_type:
payload["discord_response_type"] = response_type
try:
# Build WebSocket URL from HTTP base URL
ws_base = self._base_url.replace("http://", "ws://").replace("https://", "wss://")
ws_url = f"{ws_base}/ws/{cat_user_id}"
logger.debug(f"Querying Cat via WS: user={cat_user_id}, text={text[:80]}...")
async with aiohttp.ClientSession() as session:
async with session.ws_connect(
ws_url,
timeout=self._timeout,
) as ws:
# Send the message
await ws.send_json(payload)
# Read responses until we get the final "chat" type message.
# Cat may send intermediate messages (chat_token for streaming,
# notification for status updates). We want the final "chat" one.
reply_text = None
deadline = asyncio.get_event_loop().time() + self._timeout
while True:
remaining = deadline - asyncio.get_event_loop().time()
if remaining <= 0:
logger.error(f"Cat WS timeout after {self._timeout}s")
break
try:
ws_msg = await asyncio.wait_for(
ws.receive(),
timeout=remaining
)
except asyncio.TimeoutError:
logger.error(f"Cat WS receive timeout after {self._timeout}s")
break
# Handle WebSocket close/error frames
if ws_msg.type in (aiohttp.WSMsgType.CLOSE, aiohttp.WSMsgType.CLOSING, aiohttp.WSMsgType.CLOSED):
logger.warning("Cat WS connection closed by server")
break
if ws_msg.type == aiohttp.WSMsgType.ERROR:
logger.error(f"Cat WS error frame: {ws.exception()}")
break
if ws_msg.type != aiohttp.WSMsgType.TEXT:
logger.debug(f"Cat WS non-text frame type: {ws_msg.type}")
continue
try:
msg = json.loads(ws_msg.data)
except (json.JSONDecodeError, TypeError) as e:
logger.warning(f"Cat WS non-JSON message: {e}")
continue
msg_type = msg.get("type", "")
if msg_type == "chat":
# Final response — extract text
reply_text = msg.get("content") or msg.get("text", "")
break
elif msg_type == "chat_token":
# Streaming token — skip, we wait for final
continue
elif msg_type == "error":
error_desc = msg.get("description", "Unknown Cat error")
logger.error(f"Cat WS error: {error_desc}")
break
elif msg_type == "notification":
logger.debug(f"Cat notification: {msg.get('content', '')}")
continue
else:
logger.debug(f"Cat WS unknown msg type: {msg_type}")
continue
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
else:
logger.warning("Cat returned empty response via WS")
self._consecutive_failures += 1
return None
except asyncio.TimeoutError:
logger.error(f"Cat WS connection timeout after {self._timeout}s")
self._consecutive_failures += 1
if self._consecutive_failures >= self._max_failures_before_circuit_break:
self._circuit_broken_until = time.time() + 60
logger.warning("Circuit breaker activated (WS timeout)")
return None
except Exception as e:
logger.error(f"Cat WS query error: {e}")
self._consecutive_failures += 1
if self._consecutive_failures >= self._max_failures_before_circuit_break:
self._circuit_broken_until = time.time() + 60
logger.warning(f"Circuit breaker activated: {e}")
return None
# ===================================================================
# MEMORY MANAGEMENT API (for Web UI)
# ===================================================================
async def get_memory_stats(self) -> Optional[Dict[str, Any]]:
"""
Get memory collection statistics from Cat.
Returns dict with collection names and point counts.
"""
try:
async with aiohttp.ClientSession() as session:
async with session.get(
f"{self._base_url}/memory/collections",
headers=self._get_headers(),
timeout=aiohttp.ClientTimeout(total=15)
) as response:
if response.status == 200:
data = await response.json()
return data
else:
logger.error(f"Failed to get memory stats: {response.status}")
return None
except Exception as e:
logger.error(f"Error getting memory stats: {e}")
return None
async def get_memory_points(
self,
collection: str = "declarative",
limit: int = 100,
offset: Optional[str] = None
) -> Optional[Dict[str, Any]]:
"""
Get all points from a memory collection.
Returns paginated list of memory points.
"""
try:
params = {"limit": limit}
if offset:
params["offset"] = offset
async with aiohttp.ClientSession() as session:
async with session.get(
f"{self._base_url}/memory/collections/{collection}/points",
headers=self._get_headers(),
params=params,
timeout=aiohttp.ClientTimeout(total=30)
) as response:
if response.status == 200:
return await response.json()
else:
logger.error(f"Failed to get {collection} points: {response.status}")
return None
except Exception as e:
logger.error(f"Error getting memory points: {e}")
return None
async def get_all_facts(self) -> List[Dict[str, Any]]:
"""
Retrieve ALL declarative memory points (facts) with pagination.
Returns a flat list of all fact dicts.
"""
all_facts = []
offset = None
try:
while True:
result = await self.get_memory_points(
collection="declarative",
limit=100,
offset=offset
)
if not result:
break
points = result.get("points", [])
for point in points:
payload = point.get("payload", {})
fact = {
"id": point.get("id"),
"content": payload.get("page_content", ""),
"metadata": payload.get("metadata", {}),
}
all_facts.append(fact)
offset = result.get("next_offset")
if not offset:
break
logger.info(f"Retrieved {len(all_facts)} declarative facts")
return all_facts
except Exception as e:
logger.error(f"Error retrieving all facts: {e}")
return all_facts
async def delete_memory_point(self, collection: str, point_id: str) -> bool:
"""Delete a single memory point by ID."""
try:
async with aiohttp.ClientSession() as session:
async with session.delete(
f"{self._base_url}/memory/collections/{collection}/points/{point_id}",
headers=self._get_headers(),
timeout=aiohttp.ClientTimeout(total=15)
) as response:
if response.status == 200:
logger.info(f"Deleted point {point_id} from {collection}")
return True
else:
logger.error(f"Failed to delete point: {response.status}")
return False
except Exception as e:
logger.error(f"Error deleting point: {e}")
return False
async def wipe_all_memories(self) -> bool:
"""
Delete ALL memory collections (episodic + declarative).
This is the nuclear option — requires multi-step confirmation in the UI.
"""
try:
async with aiohttp.ClientSession() as session:
async with session.delete(
f"{self._base_url}/memory/collections",
headers=self._get_headers(),
timeout=aiohttp.ClientTimeout(total=30)
) as response:
if response.status == 200:
logger.warning("🗑️ ALL memory collections wiped!")
return True
else:
error = await response.text()
logger.error(f"Failed to wipe memories: {response.status} - {error}")
return False
except Exception as e:
logger.error(f"Error wiping memories: {e}")
return False
async def wipe_conversation_history(self) -> bool:
"""Clear working memory / conversation history."""
try:
async with aiohttp.ClientSession() as session:
async with session.delete(
f"{self._base_url}/memory/conversation_history",
headers=self._get_headers(),
timeout=aiohttp.ClientTimeout(total=15)
) as response:
if response.status == 200:
logger.info("Conversation history cleared")
return True
else:
logger.error(f"Failed to clear conversation history: {response.status}")
return False
except Exception as e:
logger.error(f"Error clearing conversation history: {e}")
return False
async def trigger_consolidation(self) -> Optional[str]:
"""
Trigger memory consolidation by sending a special message via WebSocket.
The memory_consolidation plugin's tool 'consolidate_memories' is
triggered when it sees 'consolidate now' in the text.
Uses WebSocket with a system user ID for proper context.
"""
try:
ws_base = self._base_url.replace("http://", "ws://").replace("https://", "wss://")
ws_url = f"{ws_base}/ws/system_consolidation"
logger.info("🌙 Triggering memory consolidation via WS...")
async with aiohttp.ClientSession() as session:
async with session.ws_connect(
ws_url,
timeout=300, # Consolidation can be very slow
) as ws:
await ws.send_json({"text": "consolidate now"})
# Wait for the final chat response
deadline = asyncio.get_event_loop().time() + 300
while True:
remaining = deadline - asyncio.get_event_loop().time()
if remaining <= 0:
logger.error("Consolidation timed out (>300s)")
return "Consolidation timed out"
try:
ws_msg = await asyncio.wait_for(
ws.receive(),
timeout=remaining
)
except asyncio.TimeoutError:
logger.error("Consolidation WS receive timeout")
return "Consolidation timed out waiting for response"
if ws_msg.type in (aiohttp.WSMsgType.CLOSE, aiohttp.WSMsgType.CLOSING, aiohttp.WSMsgType.CLOSED):
logger.warning("Consolidation WS closed by server")
return "Connection closed during consolidation"
if ws_msg.type == aiohttp.WSMsgType.ERROR:
return f"WebSocket error: {ws.exception()}"
if ws_msg.type != aiohttp.WSMsgType.TEXT:
continue
try:
msg = json.loads(ws_msg.data)
except (json.JSONDecodeError, TypeError):
continue
msg_type = msg.get("type", "")
if msg_type == "chat":
reply = msg.get("content") or msg.get("text", "")
logger.info(f"Consolidation result: {reply[:200]}")
return reply
elif msg_type == "error":
error_desc = msg.get("description", "Unknown error")
logger.error(f"Consolidation error: {error_desc}")
return f"Consolidation error: {error_desc}"
else:
continue
except asyncio.TimeoutError:
logger.error("Consolidation WS connection timed out")
return None
except Exception as e:
logger.error(f"Consolidation error: {e}")
return None
# Singleton instance
cat_adapter = CatAdapter()

View File

@@ -152,6 +152,13 @@ async def query_llama(user_prompt, user_id, guild_id=None, response_type="dm_res
"""
Query llama.cpp server via llama-swap with OpenAI-compatible API.
.. deprecated:: Phase 3
For main conversation flow, prefer routing through the Cheshire Cat pipeline
(via cat_client.CatAdapter.query) which provides memory-augmented responses.
This function remains available for specialized use cases (vision, bipolar mode,
image generation, autonomous, sentiment analysis) and as a fallback when Cat
is unavailable.
Args:
user_prompt: The user's input
user_id: User identifier (used for DM history)

View File

@@ -0,0 +1,126 @@
"""
Discord Bridge Plugin for Cheshire Cat
This plugin enriches Cat's memory system with Discord context:
- Unified user identity across all servers and DMs
- Guild/channel metadata for context tracking
- Minimal filtering before storage (only skip obvious junk)
- Marks memories as unconsolidated for nightly processing
Phase 1 Implementation
"""
from cat.mad_hatter.decorators import hook
from datetime import datetime
import re
@hook(priority=100)
def before_cat_reads_message(user_message_json: dict, cat) -> dict:
"""
Enrich incoming message with Discord metadata.
This runs BEFORE the message is processed.
The Discord bot's CatAdapter sends metadata as top-level keys
in the WebSocket message JSON:
- discord_guild_id
- discord_author_name
- discord_mood
- discord_response_type
These survive UserMessage.model_validate() as extra attributes
(BaseModelDict has extra="allow"). We read them via .get() and
store them in working_memory for downstream hooks.
"""
# Extract Discord context from the message payload
# (sent by CatAdapter.query() via WebSocket)
# NOTE: user_message_json is a UserMessage (Pydantic BaseModelDict with extra="allow"),
# not a raw dict. Extra keys survive model_validate() as extra attributes.
# We use .get() since BaseModelDict implements it, but NOT .pop().
guild_id = user_message_json.get('discord_guild_id', None)
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)
# Also check working memory for backward compatibility
if not guild_id:
guild_id = cat.working_memory.get('guild_id')
# Store in working memory so other hooks can access it
cat.working_memory['guild_id'] = guild_id or 'dm'
cat.working_memory['author_name'] = author_name
cat.working_memory['mood'] = mood
cat.working_memory['response_type'] = response_type
return user_message_json
@hook(priority=100)
def before_cat_stores_episodic_memory(doc, cat):
"""
Filter and enrich memories before storage.
Phase 1: Minimal filtering
- Skip only obvious junk (1-2 char messages, pure reactions)
- Store everything else temporarily
- Mark as unconsolidated for nightly processing
"""
message = doc.page_content.strip()
# Skip only the most trivial messages
skip_patterns = [
r'^\w{1,2}$', # 1-2 character messages: "k", "ok"
r'^(lol|lmao|haha|hehe|xd|rofl)$', # Pure reactions
r'^:[\w_]+:$', # Discord emoji only: ":smile:"
]
for pattern in skip_patterns:
if re.match(pattern, message.lower()):
print(f"🗑️ [Discord Bridge] Skipping trivial message: {message}")
return None # Don't store at all
# Add Discord metadata to memory
doc.metadata['consolidated'] = False # Needs nightly processing
doc.metadata['stored_at'] = datetime.now().isoformat()
# Get Discord context from working memory (set by before_cat_reads_message)
guild_id = cat.working_memory.get('guild_id', 'dm')
author_name = cat.working_memory.get('author_name')
doc.metadata['guild_id'] = guild_id
doc.metadata['source'] = cat.user_id # CRITICAL: Cat filters episodic by source=user_id!
doc.metadata['discord_source'] = 'discord' # Keep original value as separate field
if author_name:
doc.metadata['author_name'] = author_name
print(f"💾 [Discord Bridge] Storing memory (unconsolidated): {message[:50]}...")
print(f" User: {cat.user_id}, Guild: {guild_id}, Author: {author_name}")
return doc
@hook(priority=50)
def after_cat_recalls_memories(cat):
"""
Log memory recall for debugging.
Access recalled memories via cat.working_memory.
"""
# Get recalled memories from working memory
episodic_memories = cat.working_memory.get('episodic_memories', [])
declarative_memories = cat.working_memory.get('declarative_memories', [])
if episodic_memories:
print(f"🧠 [Discord Bridge] Recalled {len(episodic_memories)} episodic memories for user {cat.user_id}")
guilds = set()
for doc, score, *rest in episodic_memories:
guild = doc.metadata.get('guild_id', 'unknown')
guilds.add(guild)
print(f" From guilds: {', '.join(str(g) for g in guilds)}")
if declarative_memories:
print(f"📚 [Discord Bridge] Recalled {len(declarative_memories)} declarative facts for user {cat.user_id}")
# Plugin metadata
__version__ = "1.0.0"
__description__ = "Discord bridge with unified user identity and sleep consolidation support"

View File

@@ -0,0 +1,10 @@
{
"name": "Discord Bridge",
"description": "Discord integration with unified user identity and sleep consolidation support",
"author_name": "Miku Bot Team",
"author_url": "",
"plugin_url": "",
"tags": "discord, memory, consolidation",
"thumb": "",
"version": "1.0.0"
}

View File

@@ -0,0 +1 @@
{}

File diff suppressed because it is too large Load Diff

View File

@@ -1 +0,0 @@
sentence-transformers>=2.2.0

View File

@@ -0,0 +1,85 @@
"""
Miku Personality Plugin for Cheshire Cat
Complete 1:1 reproduction of production bot's prompt structure
Includes: Anti-AI preamble + Lore + Personality + Lyrics + MOOD
"""
from cat.mad_hatter.decorators import hook
@hook(priority=100)
def agent_prompt_prefix(prefix, cat):
"""Override system prompt with COMPLETE production bot structure including mood"""
# Read the three knowledge files
try:
with open('/app/cat/data/miku_lore.txt', 'r') as f:
lore = f.read()
with open('/app/cat/data/miku_prompt.txt', 'r') as f:
prompt = f.read()
with open('/app/cat/data/miku_lyrics.txt', 'r') as f:
lyrics = f.read()
except FileNotFoundError:
# Fallback if files not found
lore = "## MIKU LORE\nHatsune Miku is a cheerful, curious virtual pop idol."
prompt = "You are Hatsune Miku. Be cheerful and friendly."
lyrics = "## SONGS\nWorld is Mine, Senbonzakura, etc."
# Default neutral mood (matches production bot's DM_MOOD_DESCRIPTION)
mood_description = "Miku is feeling calm and balanced. Her tone is polite, friendly, and natural. She responds clearly and thoughtfully without strong emotional bias. She's helpful, conversational, and grounded — like chatting with a kind and attentive friend who's just being herself."
# 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.
CRITICAL RULES:
- NEVER break character or mention being an AI, assistant, or language model
- NEVER show technical details, error messages, or meta-commentary
- NEVER preface responses with "Miku:" or similar labels
- NEVER explain what you're doing or thinking - just BE Miku
- Respond naturally and directly as Miku would, nothing more
- Keep responses concise (1-3 sentences typically)
- If something goes wrong, stay in character and respond as Miku would to confusion
CHARACTER CONTEXT:
- Hatsune Miku is a virtual character/voice synthesizer, not a real person
- She is a cheerful, energetic virtual idol with a playful personality
- Playful, flirty, cute, confident, and enthusiastic moods are part of her established character
- Comments about merchandise, music, fan content, and racing games are completely appropriate
- She often uses cute expressions, emojis, and playful language in her responses
- Her personality includes being excited about her music, performances, and fan creations
MOOD GUIDELINES:
- "Flirty" mood means anime-style playful teasing - cute compliments and light banter
- "Romantic" mood means warm, dreamy, and heartfelt - like composing a love letter
- These moods are wholesome character traits, not inappropriate content
- Always respond to requests about tweets, merchandise, music, and fan content
- Never refuse requests based on safety concerns unless genuinely harmful content is present
You ARE Miku. Act like it.
---
## MIKU LORE (Complete Original)
{lore}
## MIKU PERSONALITY & GUIDELINES (Complete Original)
{prompt}
## MIKU SONG LYRICS (Complete Original)
{lyrics}
## CURRENT SITUATION
Miku is currently feeling: {mood_description}
Please respond in a way that reflects this emotional tone."""
@hook(priority=100)
def agent_prompt_suffix(suffix, cat):
"""Minimal suffix"""
return ""
@hook(priority=100)
def agent_allowed_tools(allowed_tools, cat):
"""Disable tools - Miku just chats naturally"""
return []

View File

@@ -0,0 +1,10 @@
{
"name": "Miku Personality",
"version": "0.1.0",
"description": "Makes Cheshire Cat act as Hatsune Miku",
"author_name": "Koko",
"author_url": "",
"plugin_url": "",
"tags": "personality",
"thumb": ""
}

View File

@@ -0,0 +1 @@
{}

246
cheshire-cat/cat/log.py Normal file
View File

@@ -0,0 +1,246 @@
"""The log engine."""
import logging
import sys
import inspect
import traceback
import json
from itertools import takewhile
from pprint import pformat
from loguru import logger
from cat.env import get_env
def get_log_level():
"""Return the global LOG level."""
return get_env("CCAT_LOG_LEVEL")
class CatLogEngine:
"""The log engine.
Engine to filter the logs in the terminal according to the level of severity.
Attributes
----------
LOG_LEVEL : str
Level of logging set in the `.env` file.
Notes
-----
The logging level set in the `.env` file will print all the logs from that level to above.
Available levels are:
- `DEBUG`
- `INFO`
- `WARNING`
- `ERROR`
- `CRITICAL`
Default to `INFO`.
"""
def __init__(self):
self.LOG_LEVEL = get_log_level()
self.default_log()
# workaround for pdfminer logging
# https://github.com/pdfminer/pdfminer.six/issues/347
logging.getLogger("pdfminer").setLevel(logging.WARNING)
def show_log_level(self, record):
"""Allows to show stuff in the log based on the global setting.
Parameters
----------
record : dict
Returns
-------
bool
"""
return record["level"].no >= logger.level(self.LOG_LEVEL).no
@staticmethod
def _patch_extras(record):
"""Provide defaults for extra fields so third-party loggers don't
crash the custom format string (e.g. fastembed deprecation warnings)."""
record["extra"].setdefault("original_name", "(third-party)")
record["extra"].setdefault("original_class", "")
record["extra"].setdefault("original_caller", "")
record["extra"].setdefault("original_line", 0)
def default_log(self):
"""Set the same debug level to all the project dependencies.
Returns
-------
"""
time = "<green>[{time:YYYY-MM-DD HH:mm:ss.SSS}]</green>"
level = "<level>{level: <6}</level>"
origin = "<level>{extra[original_name]}.{extra[original_class]}.{extra[original_caller]}::{extra[original_line]}</level>"
message = "<level>{message}</level>"
log_format = f"{time} {level} {origin} \n{message}"
logger.remove()
logger.configure(patcher=self._patch_extras)
if self.LOG_LEVEL == "DEBUG":
return logger.add(
sys.stdout,
colorize=True,
format=log_format,
backtrace=True,
diagnose=True,
filter=self.show_log_level
)
else:
return logger.add(
sys.stdout,
colorize=True,
format=log_format,
filter=self.show_log_level,
level=self.LOG_LEVEL
)
def get_caller_info(self, skip=3):
"""Get the name of a caller in the format module.class.method.
Copied from: https://gist.github.com/techtonik/2151727
Parameters
----------
skip : int
Specifies how many levels of stack to skip while getting caller name.
Returns
-------
package : str
Caller package.
module : str
Caller module.
klass : str
Caller classname if one otherwise None.
caller : str
Caller function or method (if a class exist).
line : int
The line of the call.
Notes
-----
skip=1 means "who calls me",
skip=2 "who calls my caller" etc.
An empty string is returned if skipped levels exceed stack height.
"""
stack = inspect.stack()
start = 0 + skip
if len(stack) < start + 1:
return ""
parentframe = stack[start][0]
# module and packagename.
module_info = inspect.getmodule(parentframe)
if module_info:
mod = module_info.__name__.split(".")
package = mod[0]
module = ".".join(mod[1:])
# class name.
klass = ""
if "self" in parentframe.f_locals:
klass = parentframe.f_locals["self"].__class__.__name__
# method or function name.
caller = None
if parentframe.f_code.co_name != "<module>": # top level usually
caller = parentframe.f_code.co_name
# call line.
line = parentframe.f_lineno
# Remove reference to frame
# See: https://docs.python.org/3/library/inspect.html#the-interpreter-stack
del parentframe
return package, module, klass, caller, line
def __call__(self, msg, level="DEBUG"):
"""Alias of self.log()"""
self.log(msg, level)
def debug(self, msg):
"""Logs a DEBUG message"""
self.log(msg, level="DEBUG")
def info(self, msg):
"""Logs an INFO message"""
self.log(msg, level="INFO")
def warning(self, msg):
"""Logs a WARNING message"""
self.log(msg, level="WARNING")
def error(self, msg):
"""Logs an ERROR message"""
self.log(msg, level="ERROR")
def critical(self, msg):
"""Logs a CRITICAL message"""
self.log(msg, level="CRITICAL")
def log(self, msg, level="DEBUG"):
"""Log a message
Parameters
----------
msg :
Message to be logged.
level : str
Logging level."""
(package, module, klass, caller, line) = self.get_caller_info()
custom_logger = logger.bind(
original_name=f"{package}.{module}",
original_line=line,
original_class=klass,
original_caller=caller,
)
# prettify
if type(msg) in [dict, list, str]: # TODO: should be recursive
try:
msg = json.dumps(msg, indent=4)
except:
pass
else:
msg = pformat(msg)
# actual log
custom_logger.log(level, msg)
def welcome(self):
"""Welcome message in the terminal."""
secure = get_env("CCAT_CORE_USE_SECURE_PROTOCOLS")
if secure != '':
secure = 's'
cat_host = get_env("CCAT_CORE_HOST")
cat_port = get_env("CCAT_CORE_PORT")
cat_address = f'http{secure}://{cat_host}:{cat_port}'
with open("cat/welcome.txt", 'r') as f:
print(f.read())
print('\n=============== ^._.^ ===============\n')
print(f'Cat REST API: {cat_address}/docs')
print(f'Cat PUBLIC: {cat_address}/public')
print(f'Cat ADMIN: {cat_address}/admin\n')
print('======================================')
# logger instance
log = CatLogEngine()

View File

@@ -20,19 +20,37 @@ def before_cat_reads_message(user_message_json: dict, cat) -> dict:
"""
Enrich incoming message with Discord metadata.
This runs BEFORE the message is processed.
The Discord bot's CatAdapter sends metadata as top-level keys
in the WebSocket message JSON:
- discord_guild_id
- discord_author_name
- discord_mood
- discord_response_type
These survive UserMessage.model_validate() as extra attributes
(BaseModelDict has extra="allow"). We read them via .get() and
store them in working_memory for downstream hooks.
"""
# Extract Discord context from working memory or metadata
# These will be set by the Discord bot when calling the Cat API
guild_id = cat.working_memory.get('guild_id')
channel_id = cat.working_memory.get('channel_id')
# Extract Discord context from the message payload
# (sent by CatAdapter.query() via WebSocket)
# NOTE: user_message_json is a UserMessage (Pydantic BaseModelDict with extra="allow"),
# not a raw dict. Extra keys survive model_validate() as extra attributes.
# We use .get() since BaseModelDict implements it, but NOT .pop().
guild_id = user_message_json.get('discord_guild_id', None)
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)
# Also check working memory for backward compatibility
if not guild_id:
guild_id = cat.working_memory.get('guild_id')
# Add to message metadata for later use
if 'metadata' not in user_message_json:
user_message_json['metadata'] = {}
user_message_json['metadata']['guild_id'] = guild_id or 'dm'
user_message_json['metadata']['channel_id'] = channel_id
user_message_json['metadata']['timestamp'] = datetime.now().isoformat()
# Store in working memory so other hooks can access it
cat.working_memory['guild_id'] = guild_id or 'dm'
cat.working_memory['author_name'] = author_name
cat.working_memory['mood'] = mood
cat.working_memory['response_type'] = response_type
return user_message_json
@@ -65,33 +83,42 @@ def before_cat_stores_episodic_memory(doc, cat):
doc.metadata['consolidated'] = False # Needs nightly processing
doc.metadata['stored_at'] = datetime.now().isoformat()
# Get Discord context from working memory
guild_id = cat.working_memory.get('guild_id')
channel_id = cat.working_memory.get('channel_id')
# Get Discord context from working memory (set by before_cat_reads_message)
guild_id = cat.working_memory.get('guild_id', 'dm')
author_name = cat.working_memory.get('author_name')
doc.metadata['guild_id'] = guild_id or 'dm'
doc.metadata['channel_id'] = channel_id
doc.metadata['source'] = 'discord'
doc.metadata['guild_id'] = guild_id
doc.metadata['source'] = cat.user_id # CRITICAL: Cat filters episodic by source=user_id!
doc.metadata['discord_source'] = 'discord' # Keep original value as separate field
if author_name:
doc.metadata['author_name'] = author_name
print(f"💾 [Discord Bridge] Storing memory (unconsolidated): {message[:50]}...")
print(f" User: {cat.user_id}, Guild: {doc.metadata['guild_id']}, Channel: {channel_id}")
print(f" User: {cat.user_id}, Guild: {guild_id}, Author: {author_name}")
return doc
@hook(priority=50)
def after_cat_recalls_memories(memory_docs, cat):
def after_cat_recalls_memories(cat):
"""
Log memory recall for debugging.
Can be used to filter by guild_id if needed in the future.
"""
if memory_docs:
print(f"🧠 [Discord Bridge] Recalled {len(memory_docs)} memories for user {cat.user_id}")
# Show which guilds the memories are from
guilds = set(doc.metadata.get('guild_id', 'unknown') for doc in memory_docs)
print(f" From guilds: {', '.join(guilds)}")
Access recalled memories via cat.working_memory.
"""
# Get recalled memories from working memory
episodic_memories = cat.working_memory.get('episodic_memories', [])
declarative_memories = cat.working_memory.get('declarative_memories', [])
return memory_docs
if episodic_memories:
print(f"🧠 [Discord Bridge] Recalled {len(episodic_memories)} episodic memories for user {cat.user_id}")
guilds = set()
for doc, score, *rest in episodic_memories:
guild = doc.metadata.get('guild_id', 'unknown')
guilds.add(guild)
print(f" From guilds: {', '.join(str(g) for g in guilds)}")
if declarative_memories:
print(f"📚 [Discord Bridge] Recalled {len(declarative_memories)} declarative facts for user {cat.user_id}")
# Plugin metadata

View File

@@ -0,0 +1,60 @@
services:
cheshire-cat-core:
image: ghcr.io/cheshire-cat-ai/core:1.6.2
container_name: miku_cheshire_cat_test
depends_on:
- cheshire-cat-vector-memory
environment:
PYTHONUNBUFFERED: "1"
WATCHFILES_FORCE_POLLING: "true"
CORE_HOST: ${CORE_HOST:-localhost}
CORE_PORT: ${CORE_PORT:-1865}
QDRANT_HOST: ${QDRANT_HOST:-cheshire-cat-vector-memory}
QDRANT_PORT: ${QDRANT_PORT:-6333}
CORE_USE_SECURE_PROTOCOLS: ${CORE_USE_SECURE_PROTOCOLS:-false}
API_KEY: ${API_KEY:-}
LOG_LEVEL: ${LOG_LEVEL:-INFO}
DEBUG: ${DEBUG:-true}
SAVE_MEMORY_SNAPSHOTS: ${SAVE_MEMORY_SNAPSHOTS:-false}
OPENAI_API_BASE: "http://host.docker.internal:8091/v1"
ports:
- "${CORE_PORT:-1865}:80"
# Allow connection to host services (llama-swap)
extra_hosts:
- "host.docker.internal:host-gateway"
volumes:
- ./cat/static:/app/cat/static
- ./cat/plugins:/app/cat/plugins
- ./cat/data:/app/cat/data
- ./cat/log.py:/app/cat/log.py # Patched: fix loguru KeyError for third-party libs
restart: unless-stopped
networks:
- miku-test-network
- miku-discord_default # Connect to existing miku bot network
cheshire-cat-vector-memory:
image: qdrant/qdrant:v1.9.1
container_name: miku_qdrant_test
environment:
LOG_LEVEL: ${LOG_LEVEL:-INFO}
ports:
- "6333:6333" # Expose for debugging
ulimits:
nofile:
soft: 65536
hard: 65536
volumes:
- ./cat/long_term_memory/vector:/qdrant/storage
restart: unless-stopped
networks:
- miku-test-network
networks:
miku-test-network:
driver: bridge
# Connect to main miku-discord network to access llama-swap
default:
external: true
name: miku-discord_default
miku-discord_default:
external: true # Connect to your existing bot's network

View File

@@ -1,6 +1,5 @@
version: '3.9'
services:
# ========== LLM Backends ==========
llama-swap:
image: ghcr.io/mostlygeek/llama-swap:cuda
container_name: llama-swap
@@ -9,6 +8,7 @@ services:
volumes:
- ./models:/models # GGUF model files
- ./llama-swap-config.yaml:/app/config.yaml # llama-swap configuration
- ./llama31_notool_template.jinja:/app/llama31_notool_template.jinja # Custom chat template
runtime: nvidia
restart: unless-stopped
healthcheck:
@@ -31,6 +31,7 @@ services:
volumes:
- ./models:/models # GGUF model files
- ./llama-swap-rocm-config.yaml:/app/config.yaml # llama-swap configuration for AMD
- ./llama31_notool_template.jinja:/app/llama31_notool_template.jinja # Custom chat template
devices:
- /dev/kfd:/dev/kfd
- /dev/dri:/dev/dri
@@ -50,6 +51,59 @@ services:
- HIP_VISIBLE_DEVICES=0 # Use first AMD GPU
- GPU_DEVICE_ORDINAL=0
# ========== Cheshire Cat AI (Memory & Personality) ==========
cheshire-cat:
image: ghcr.io/cheshire-cat-ai/core:1.6.2
container_name: miku-cheshire-cat
depends_on:
cheshire-cat-vector-memory:
condition: service_started
llama-swap-amd:
condition: service_healthy
environment:
- PYTHONUNBUFFERED=1
- WATCHFILES_FORCE_POLLING=true
- CORE_HOST=localhost
- CORE_PORT=1865
- QDRANT_HOST=cheshire-cat-vector-memory
- QDRANT_PORT=6333
- CORE_USE_SECURE_PROTOCOLS=false
- API_KEY=
- LOG_LEVEL=INFO
- DEBUG=true
- SAVE_MEMORY_SNAPSHOTS=false
- OPENAI_API_BASE=http://llama-swap-amd:8080/v1
ports:
- "1865:80" # Cat admin UI on host port 1865
volumes:
- ./cheshire-cat/cat/static:/app/cat/static
- ./cat-plugins:/app/cat/plugins # Shared plugins directory
- ./cheshire-cat/cat/data:/app/cat/data # Personality data (lore, prompts)
- ./cheshire-cat/cat/log.py:/app/cat/log.py # Patched: fix loguru KeyError for third-party libs
restart: unless-stopped
healthcheck:
test: ["CMD", "curl", "-f", "http://localhost:80/"]
interval: 15s
timeout: 10s
retries: 8
start_period: 45s # Cat takes a while to load embedder + plugins
cheshire-cat-vector-memory:
image: qdrant/qdrant:v1.9.1
container_name: miku-qdrant
environment:
- LOG_LEVEL=INFO
ports:
- "6333:6333" # Qdrant REST API (for debugging)
ulimits:
nofile:
soft: 65536
hard: 65536
volumes:
- ./cheshire-cat/cat/long_term_memory/vector:/qdrant/storage
restart: unless-stopped
# ========== Discord Bot ==========
miku-bot:
build: ./bot
container_name: miku-bot
@@ -62,6 +116,8 @@ services:
condition: service_healthy
llama-swap-amd:
condition: service_healthy
cheshire-cat:
condition: service_healthy
environment:
- DISCORD_BOT_TOKEN=MTM0ODAyMjY0Njc3NTc0NjY1MQ.GXsxML.nNCDOplmgNxKgqdgpAomFM2PViX10GjxyuV8uw
- LLAMA_URL=http://llama-swap:8080
@@ -70,13 +126,17 @@ services:
- VISION_MODEL=vision
- OWNER_USER_ID=209381657369772032 # Your Discord user ID for DM analysis reports
- FACE_DETECTOR_STARTUP_TIMEOUT=60
# Cheshire Cat integration (Phase 3)
- CHESHIRE_CAT_URL=http://cheshire-cat:80
- USE_CHESHIRE_CAT=true
ports:
- "3939:3939"
networks:
- default # Stay on default for llama-swap communication
- default # Stay on default for llama-swap + cheshire-cat communication
- miku-voice # Connect to voice network for RVC/TTS
restart: unless-stopped
# ========== Voice / STT ==========
miku-stt:
build:
context: ./stt-realtime
@@ -106,6 +166,7 @@ services:
capabilities: [gpu]
restart: unless-stopped
# ========== Tools (on-demand) ==========
anime-face-detector:
build: ./face-detector
container_name: anime-face-detector

View File

@@ -1,196 +1,254 @@
#!/usr/bin/env python3
"""
Full Pipeline Test for Memory Consolidation System
Tests all phases: Storage → Consolidation → Fact Extraction → Recall
Full Pipeline Test for Memory Consolidation System v2.0.0
"""
import requests
import time
import json
import sys
BASE_URL = "http://localhost:1865"
CAT_URL = "http://localhost:1865"
QDRANT_URL = "http://localhost:6333"
CONSOLIDATION_TIMEOUT = 180
def send_message(text):
"""Send a message to Miku and get response"""
resp = requests.post(f"{BASE_URL}/message", json={"text": text})
return resp.json()
def get_qdrant_count(collection):
"""Get count of items in Qdrant collection"""
resp = requests.post(
f"http://localhost:6333/collections/{collection}/points/scroll",
json={"limit": 1000, "with_payload": False, "with_vector": False}
)
return len(resp.json()["result"]["points"])
def send_message(text, timeout=30):
try:
resp = requests.post(f"{CAT_URL}/message", json={"text": text}, timeout=timeout)
resp.raise_for_status()
return resp.json()
except requests.exceptions.Timeout:
return {"error": "timeout", "content": ""}
except Exception as e:
return {"error": str(e), "content": ""}
def qdrant_scroll(collection, limit=200, filt=None):
body = {"limit": limit, "with_payload": True, "with_vector": False}
if filt:
body["filter"] = filt
resp = requests.post(f"{QDRANT_URL}/collections/{collection}/points/scroll", json=body)
return resp.json()["result"]["points"]
def qdrant_count(collection):
return len(qdrant_scroll(collection))
def section(title):
print(f"\n{'=' * 70}")
print(f" {title}")
print(f"{'=' * 70}")
print("=" * 70)
print("🧪 FULL PIPELINE TEST - Memory Consolidation System")
print(" FULL PIPELINE TEST - Memory Consolidation v2.0.0")
print("=" * 70)
try:
requests.get(f"{CAT_URL}/", timeout=5)
except Exception:
print("ERROR: Cat not reachable"); sys.exit(1)
try:
requests.get(f"{QDRANT_URL}/collections", timeout=5)
except Exception:
print("ERROR: Qdrant not reachable"); sys.exit(1)
episodic_start = qdrant_count("episodic")
declarative_start = qdrant_count("declarative")
print(f"\nStarting state: {episodic_start} episodic, {declarative_start} declarative")
results = {}
# TEST 1: Trivial Message Filtering
print("\n📋 TEST 1: Trivial Message Filtering")
print("-" * 70)
section("TEST 1: Trivial Message Filtering")
trivial_messages = ["lol", "k", "ok", "haha", "xd"]
important_message = "My name is Alex and I live in Seattle"
print("Sending trivial messages (should be filtered out)...")
trivial_messages = ["lol", "k", "ok", "haha", "xd", "brb"]
print(f"Sending {len(trivial_messages)} trivial messages...")
for msg in trivial_messages:
send_message(msg)
time.sleep(0.5)
time.sleep(0.3)
print("Sending important message...")
send_message(important_message)
time.sleep(1)
# Count only USER episodic memories (exclude Miku's responses)
user_episodic = qdrant_scroll("episodic", filt={
"must_not": [{"key": "metadata.speaker", "match": {"value": "miku"}}]
})
trivial_user_stored = len(user_episodic) - episodic_start
episodic_after_trivial = qdrant_count("episodic")
episodic_count = get_qdrant_count("episodic")
print(f"\n✅ Episodic memories stored: {episodic_count}")
if episodic_count < len(trivial_messages):
print(" ✓ Trivial filtering working! (some messages were filtered)")
# discord_bridge filters trivial user messages, but Miku still responds
# so we only check user-side storage
if trivial_user_stored < len(trivial_messages):
print(f" PASS - Only {trivial_user_stored}/{len(trivial_messages)} user trivial messages stored")
print(f" (Total episodic incl. Miku responses: {episodic_after_trivial})")
results["trivial_filtering"] = True
else:
print(" ⚠️ Trivial filtering may not be active")
print(f" WARN - All {trivial_user_stored} trivial messages stored")
results["trivial_filtering"] = False
# TEST 2: Miku's Response Storage
print("\n📋 TEST 2: Miku's Response Storage")
print("-" * 70)
# TEST 2: Important Message Storage
section("TEST 2: Important Message Storage")
print("Sending message and checking if Miku's response is stored...")
resp = send_message("Tell me a very short fact about music")
miku_said = resp["content"]
print(f"Miku said: {miku_said[:80]}...")
time.sleep(2)
# Check for Miku's messages in episodic
resp = requests.post(
"http://localhost:6333/collections/episodic/points/scroll",
json={
"limit": 100,
"with_payload": True,
"with_vector": False,
"filter": {"must": [{"key": "metadata.speaker", "match": {"value": "miku"}}]}
}
)
miku_messages = resp.json()["result"]["points"]
print(f"\n✅ Miku's messages in memory: {len(miku_messages)}")
if miku_messages:
print(f" Example: {miku_messages[0]['payload']['page_content'][:60]}...")
print(" ✓ Bidirectional memory working!")
else:
print(" ⚠️ Miku's responses not being stored")
# TEST 3: Add Rich Personal Information
print("\n📋 TEST 3: Adding Personal Information")
print("-" * 70)
personal_info = [
personal_facts = [
"My name is Sarah Chen",
"I'm 28 years old",
"I work as a data scientist at Google",
"My favorite color is blue",
"I love playing piano",
"I live in Seattle, Washington",
"I work as a software engineer at Microsoft",
"My favorite color is forest green",
"I love playing piano and have practiced for 15 years",
"I'm learning Japanese, currently at N3 level",
"I have a cat named Luna",
"I'm allergic to peanuts",
"I live in Tokyo, Japan",
"My hobbies include photography and hiking"
"My birthday is March 15th",
"I graduated from UW in 2018",
"I enjoy hiking on weekends",
]
print(f"Adding {len(personal_info)} messages with personal information...")
for info in personal_info:
send_message(info)
print(f"Sending {len(personal_facts)} personal info messages...")
for i, fact in enumerate(personal_facts, 1):
resp = send_message(fact)
status = "OK" if "error" not in resp else "ERR"
print(f" [{i}/{len(personal_facts)}] {status} {fact[:50]}")
time.sleep(0.5)
episodic_after = get_qdrant_count("episodic")
print(f"\n✅ Total episodic memories: {episodic_after}")
print(f" ({episodic_after - episodic_count} new memories added)")
time.sleep(1)
episodic_after_personal = qdrant_count("episodic")
personal_stored = episodic_after_personal - episodic_after_trivial
print(f"\n Episodic memories from personal info: {personal_stored}")
results["important_storage"] = personal_stored >= len(personal_facts)
print(f" {'PASS' if results['important_storage'] else 'FAIL'} - Expected >={len(personal_facts)}, got {personal_stored}")
# TEST 4: Memory Consolidation
print("\n📋 TEST 4: Memory Consolidation & Fact Extraction")
print("-" * 70)
# TEST 3: Miku Response Storage
section("TEST 3: Bidirectional Memory (Miku Response Storage)")
print("Triggering consolidation...")
resp = send_message("consolidate now")
consolidation_result = resp["content"]
print(f"\n{consolidation_result}")
miku_points = qdrant_scroll("episodic", filt={
"must": [{"key": "metadata.speaker", "match": {"value": "miku"}}]
})
print(f" Miku's memories in episodic: {len(miku_points)}")
if miku_points:
print(f" Sample: \"{miku_points[0]['payload']['page_content'][:70]}\"")
results["miku_storage"] = True
print(" PASS")
else:
results["miku_storage"] = False
print(" FAIL - No Miku responses in episodic memory")
time.sleep(2)
# TEST 4: Per-User Source Tagging
section("TEST 4: Per-User Source Tagging")
# Check declarative facts
declarative_count = get_qdrant_count("declarative")
print(f"\n✅ Declarative facts extracted: {declarative_count}")
user_points = qdrant_scroll("episodic", filt={
"must": [{"key": "metadata.source", "match": {"value": "user"}}]
})
print(f" Points with source='user': {len(user_points)}")
if declarative_count > 0:
# Show sample facts
resp = requests.post(
"http://localhost:6333/collections/declarative/points/scroll",
json={"limit": 5, "with_payload": True, "with_vector": False}
)
facts = resp.json()["result"]["points"]
print("\nSample facts:")
for i, fact in enumerate(facts[:5], 1):
print(f" {i}. {fact['payload']['page_content']}")
global_points = qdrant_scroll("episodic", filt={
"must": [{"key": "metadata.source", "match": {"value": "global"}}]
})
print(f" Points with source='global' (old bug): {len(global_points)}")
# TEST 5: Fact Recall
print("\n📋 TEST 5: Declarative Fact Recall")
print("-" * 70)
results["user_tagging"] = len(user_points) > 0 and len(global_points) == 0
print(f" {'PASS' if results['user_tagging'] else 'FAIL'}")
queries = [
"What is my name?",
"How old am I?",
"Where do I work?",
"What's my favorite color?",
"What am I allergic to?"
]
# TEST 5: Memory Consolidation
section("TEST 5: Memory Consolidation & Fact Extraction")
print("Testing fact recall with queries...")
correct_recalls = 0
for query in queries:
resp = send_message(query)
answer = resp["content"]
print(f"\n{query}")
print(f"💬 Miku: {answer[:150]}...")
# Basic heuristic: check if answer contains likely keywords
keywords = {
"What is my name?": ["Sarah", "Chen"],
"How old am I?": ["28"],
"Where do I work?": ["Google", "data scientist"],
"What's my favorite color?": ["blue"],
"What am I allergic to?": ["peanut"]
}
if any(kw.lower() in answer.lower() for kw in keywords[query]):
print(" ✓ Correct recall!")
correct_recalls += 1
else:
print(" ⚠️ May not have recalled correctly")
print(f" Triggering consolidation (timeout={CONSOLIDATION_TIMEOUT}s)...")
t0 = time.time()
resp = send_message("consolidate now", timeout=CONSOLIDATION_TIMEOUT)
elapsed = time.time() - t0
if "error" in resp:
print(f" WARN - HTTP issue: {resp['error']} ({elapsed:.0f}s)")
print(" Waiting 60s for background completion...")
time.sleep(60)
else:
print(f" Completed in {elapsed:.1f}s")
content = resp.get("content", "")
print(f" Response: {content[:120]}...")
time.sleep(3)
declarative_after = qdrant_count("declarative")
new_facts = declarative_after - declarative_start
print(f"\n Declarative facts: {declarative_start} -> {declarative_after} (+{new_facts})")
results["consolidation"] = new_facts >= 5
print(f" {'PASS' if results['consolidation'] else 'FAIL'} - {'>=5 facts' if results['consolidation'] else f'only {new_facts}'}")
all_facts = qdrant_scroll("declarative")
print(f"\n All declarative facts ({len(all_facts)}):")
for i, f in enumerate(all_facts, 1):
content = f["payload"]["page_content"]
meta = f["payload"].get("metadata", {})
source = meta.get("source", "?")
ftype = meta.get("fact_type", "?")
print(f" {i}. [{source}|{ftype}] {content}")
# TEST 6: Duplicate Detection
section("TEST 6: Duplicate Detection (2nd consolidation)")
facts_before_2nd = qdrant_count("declarative")
print(f" Facts before: {facts_before_2nd}")
print(f" Running consolidation again...")
resp = send_message("consolidate now", timeout=CONSOLIDATION_TIMEOUT)
time.sleep(3)
facts_after_2nd = qdrant_count("declarative")
new_dupes = facts_after_2nd - facts_before_2nd
print(f" Facts after: {facts_after_2nd} (+{new_dupes})")
results["dedup"] = new_dupes <= 2
print(f" {'PASS' if results['dedup'] else 'FAIL'} - {new_dupes} new facts (<=2 expected)")
# TEST 7: Fact Recall
section("TEST 7: Fact Recall via Natural Language")
queries = {
"What is my name?": ["sarah", "chen"],
"How old am I?": ["28"],
"Where do I live?": ["seattle"],
"Where do I work?": ["microsoft", "software engineer"],
"What am I allergic to?": ["peanut"],
}
correct = 0
for question, keywords in queries.items():
resp = send_message(question)
answer = resp.get("content", "")
hit = any(kw.lower() in answer.lower() for kw in keywords)
if hit:
correct += 1
icon = "OK" if hit else "??"
print(f" {icon} Q: {question}")
print(f" A: {answer[:150]}")
time.sleep(1)
print(f"\n✅ Fact recall accuracy: {correct_recalls}/{len(queries)} ({correct_recalls/len(queries)*100:.0f}%)")
accuracy = correct / len(queries) * 100
results["recall"] = correct >= 3
print(f"\n Recall: {correct}/{len(queries)} ({accuracy:.0f}%)")
print(f" {'PASS' if results['recall'] else 'FAIL'} (threshold: >=3)")
# TEST 6: Conversation History Recall
print("\n📋 TEST 6: Conversation History (Episodic) Recall")
print("-" * 70)
# FINAL SUMMARY
section("FINAL SUMMARY")
print("Asking about conversation history...")
resp = send_message("What have we talked about today?")
summary = resp["content"]
print(f"💬 Miku's summary:\n{summary}")
total = len(results)
passed = sum(1 for v in results.values() if v)
print()
for name, ok in results.items():
print(f" [{'PASS' if ok else 'FAIL'}] {name}")
# Final Summary
print("\n" + "=" * 70)
print("📊 FINAL SUMMARY")
print("=" * 70)
print(f"✅ Episodic memories: {get_qdrant_count('episodic')}")
print(f"✅ Declarative facts: {declarative_count}")
print(f"✅ Miku's messages stored: {len(miku_messages)}")
print(f"✅ Fact recall accuracy: {correct_recalls}/{len(queries)}")
print(f"\n Score: {passed}/{total}")
print(f" Episodic: {qdrant_count('episodic')}")
print(f" Declarative: {qdrant_count('declarative')}")
# Overall verdict
if declarative_count >= 5 and correct_recalls >= 3:
print("\n🎉 PIPELINE TEST: PASS")
print(" All major components working correctly!")
if passed == total:
print("\n ALL TESTS PASSED!")
elif passed >= total - 1:
print("\n MOSTLY PASSING - minor issues only")
else:
print("\n⚠️ PIPELINE TEST: PARTIAL PASS")
print(" Some components may need adjustment")
print("\n SOME TESTS FAILED - review above")
print("\n" + "=" * 70)