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
This commit is contained in:
2026-02-07 20:22:03 +02:00
parent edb88e9ede
commit 14e1a8df51
14 changed files with 1382 additions and 70 deletions

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,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

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 @@
{}