From 14e1a8df5161ed1b8fbeb1e304b0eb4ee4525bc1 Mon Sep 17 00:00:00 2001 From: koko210Serve Date: Sat, 7 Feb 2026 20:22:03 +0200 Subject: [PATCH] Phase 3: Unified Cheshire Cat integration with WebSocket-based per-user isolation MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 --- bot/api.py | 128 +++++ bot/bot.py | 83 ++- bot/globals.py | 6 + bot/static/index.html | 427 ++++++++++++++++ bot/utils/cat_client.py | 479 ++++++++++++++++++ bot/utils/llm.py | 7 + cat-plugins/discord_bridge/discord_bridge.py | 67 ++- cat-plugins/discord_bridge/plugin.json | 10 + cat-plugins/discord_bridge/settings.json | 1 + .../miku_personality/miku_personality.py | 85 ++++ cat-plugins/miku_personality/plugin.json | 10 + cat-plugins/miku_personality/settings.json | 1 + .../plugins/discord_bridge/discord_bridge.py | 81 ++- docker-compose.yml | 67 ++- 14 files changed, 1382 insertions(+), 70 deletions(-) create mode 100644 bot/utils/cat_client.py create mode 100644 cat-plugins/discord_bridge/plugin.json create mode 100644 cat-plugins/discord_bridge/settings.json create mode 100644 cat-plugins/miku_personality/miku_personality.py create mode 100644 cat-plugins/miku_personality/plugin.json create mode 100644 cat-plugins/miku_personality/settings.json diff --git a/bot/api.py b/bot/api.py index c2b5bda..ff697c3 100644 --- a/bot/api.py +++ b/bot/api.py @@ -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) diff --git a/bot/bot.py b/bot/bot.py index e853e61..ad2b29b 100644 --- a/bot/bot.py +++ b/bot/bot.py @@ -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})") diff --git a/bot/globals.py b/bot/globals.py index 64036bf..2d1cb0e 100644 --- a/bot/globals.py +++ b/bot/globals.py @@ -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" diff --git a/bot/static/index.html b/bot/static/index.html index 91fb852..f916edd 100644 --- a/bot/static/index.html +++ b/bot/static/index.html @@ -665,6 +665,7 @@ + @@ -1547,6 +1548,142 @@ + +
+
+

๐Ÿง  Cheshire Cat Memory Management

+

+ 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. +

+ + +
+
+
+

๐Ÿฑ Cheshire Cat Status

+ Checking... +
+
+ + +
+
+
+ + +
+
+
โ€”
+
๐Ÿ“ Episodic Memories
+
Conversation snippets
+
+
+
โ€”
+
๐Ÿ“š Declarative Facts
+
Learned knowledge
+
+
+
โ€”
+
โš™๏ธ Procedural
+
Tools & procedures
+
+
+ + +
+

๐ŸŒ™ Memory Consolidation

+

+ Trigger the sleep consolidation process: analyzes episodic memories, extracts important facts, and removes trivial entries. +

+
+ + +
+ +
+ + +
+
+

๐Ÿ“š Declarative Facts

+ +
+
+
Click "Load Facts" to view stored knowledge
+
+
+ + +
+
+

๐Ÿ“ Episodic Memories

+ +
+
+
Click "Load Memories" to view conversation snippets
+
+
+ + +
+

โš ๏ธ Danger Zone โ€” Delete All Memories

+

+ This will permanently erase ALL of Miku's memories โ€” episodic conversations, learned facts, everything. + This action is irreversible. Miku will forget everything she has ever learned. +

+ + +
+ +
+ + + + + + + + + +
+ +
+
+

Logs

@@ -1611,6 +1748,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 +5161,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 = `โ— Connected โ€” ${statusData.url}`; + } else { + indicator.innerHTML = `โ— Disconnected โ€” ${statusData.url}`; + } + + if (statusData.circuit_breaker_active) { + indicator.innerHTML += ` (circuit breaker active)`; + } + + 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 = 'โ— Error checking status'; + } +} + +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 = '
Loading facts...
'; + + try { + const res = await fetch('/memory/facts'); + const data = await res.json(); + + if (!data.success || data.count === 0) { + listDiv.innerHTML = '
No declarative facts stored yet.
'; + 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 += ` +
+
+
${escapeHtml(fact.content)}
+
+ Source: ${escapeHtml(source)} ยท ${when} +
+
+ +
`; + }); + + listDiv.innerHTML = `
${data.count} facts loaded
` + html; + } catch (err) { + listDiv.innerHTML = `
Error loading facts: ${err.message}
`; + } +} + +async function loadEpisodicMemories() { + const listDiv = document.getElementById('episodic-list'); + listDiv.innerHTML = '
Loading memories...
'; + + try { + const res = await fetch('/memory/episodic'); + const data = await res.json(); + + if (!data.success || data.count === 0) { + listDiv.innerHTML = '
No episodic memories stored yet.
'; + 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 += ` +
+
+
${escapeHtml(mem.content)}
+
+ Source: ${escapeHtml(source)} ยท ${when} +
+
+ +
`; + }); + + listDiv.innerHTML = `
${data.count} memories loaded
` + html; + } catch (err) { + listDiv.innerHTML = `
Error loading memories: ${err.message}
`; + } +} + +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; +} + diff --git a/bot/utils/cat_client.py b/bot/utils/cat_client.py new file mode 100644 index 0000000..2c05433 --- /dev/null +++ b/bot/utils/cat_client.py @@ -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() diff --git a/bot/utils/llm.py b/bot/utils/llm.py index acc641f..f39cb00 100644 --- a/bot/utils/llm.py +++ b/bot/utils/llm.py @@ -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) diff --git a/cat-plugins/discord_bridge/discord_bridge.py b/cat-plugins/discord_bridge/discord_bridge.py index dcd8a41..f6b665b 100644 --- a/cat-plugins/discord_bridge/discord_bridge.py +++ b/cat-plugins/discord_bridge/discord_bridge.py @@ -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,17 +83,18 @@ 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['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 @@ -85,23 +104,21 @@ def after_cat_recalls_memories(cat): """ Log memory recall for debugging. Access recalled memories via cat.working_memory. - """ - import sys - sys.stderr.write("๐Ÿง  [Discord Bridge] after_cat_recalls_memories HOOK CALLED!\n") - sys.stderr.flush() - + """ # 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}") - # Show which guilds the memories are from guilds = set() - for doc, score in episodic_memories: + for doc, score, *rest in episodic_memories: guild = doc.metadata.get('guild_id', 'unknown') guilds.add(guild) - print(f" From guilds: {', '.join(guilds)}") + 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 diff --git a/cat-plugins/discord_bridge/plugin.json b/cat-plugins/discord_bridge/plugin.json new file mode 100644 index 0000000..7898b98 --- /dev/null +++ b/cat-plugins/discord_bridge/plugin.json @@ -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" +} diff --git a/cat-plugins/discord_bridge/settings.json b/cat-plugins/discord_bridge/settings.json new file mode 100644 index 0000000..9e26dfe --- /dev/null +++ b/cat-plugins/discord_bridge/settings.json @@ -0,0 +1 @@ +{} \ No newline at end of file diff --git a/cat-plugins/miku_personality/miku_personality.py b/cat-plugins/miku_personality/miku_personality.py new file mode 100644 index 0000000..97b6737 --- /dev/null +++ b/cat-plugins/miku_personality/miku_personality.py @@ -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 [] diff --git a/cat-plugins/miku_personality/plugin.json b/cat-plugins/miku_personality/plugin.json new file mode 100644 index 0000000..3f9237b --- /dev/null +++ b/cat-plugins/miku_personality/plugin.json @@ -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": "" +} diff --git a/cat-plugins/miku_personality/settings.json b/cat-plugins/miku_personality/settings.json new file mode 100644 index 0000000..9e26dfe --- /dev/null +++ b/cat-plugins/miku_personality/settings.json @@ -0,0 +1 @@ +{} \ No newline at end of file diff --git a/cheshire-cat/cat/plugins/discord_bridge/discord_bridge.py b/cheshire-cat/cat/plugins/discord_bridge/discord_bridge.py index 3379bfb..f6b665b 100644 --- a/cheshire-cat/cat/plugins/discord_bridge/discord_bridge.py +++ b/cheshire-cat/cat/plugins/discord_bridge/discord_bridge.py @@ -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 diff --git a/docker-compose.yml b/docker-compose.yml index 3119eeb..212ed16 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -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