diff --git a/cat-plugins/memory_consolidation/memory_consolidation.py b/cat-plugins/memory_consolidation/memory_consolidation.py new file mode 100644 index 0000000..9a20cf6 --- /dev/null +++ b/cat-plugins/memory_consolidation/memory_consolidation.py @@ -0,0 +1,827 @@ +""" +Memory Consolidation Plugin for Cheshire Cat + +Phase 2: Sleep Consolidation Implementation + +Implements human-like memory consolidation: +1. During the day: Store almost everything temporarily +2. At night (3 AM): Analyze conversations, keep important, delete trivial +3. Extract facts for declarative memory + +This mimics how human brains consolidate memories during REM sleep. +""" + +from cat.mad_hatter.decorators import hook, plugin, tool +from cat.mad_hatter.decorators import CatHook +from datetime import datetime, timedelta +import json +import asyncio +import os +from typing import List, Dict, Any + +print("๐ŸŒ™ [Consolidation Plugin] Loading...") + + + +# Store consolidation state +consolidation_state = { + 'last_run': None, + 'is_running': False, + 'stats': { + 'total_processed': 0, + 'kept': 0, + 'deleted': 0, + 'facts_learned': 0 + } +} + + +async def consolidate_user_memories(user_id: str, memories: List[Any], cat) -> Dict[str, Any]: + """ + Analyze all of a user's conversations from the day in ONE LLM call. + + This is the core intelligence - Miku sees patterns, themes, relationship evolution. + """ + + # Build conversation timeline + timeline = [] + for mem in sorted(memories, key=lambda m: m.metadata.get('stored_at', '')): + timeline.append({ + 'time': mem.metadata.get('stored_at', ''), + 'guild': mem.metadata.get('guild_id', 'unknown'), + 'channel': mem.metadata.get('channel_id', 'unknown'), + 'content': mem.page_content[:200] # Truncate for context window + }) + + # Build consolidation prompt + consolidation_prompt = f"""You are Miku, reviewing your conversations with user {user_id} from today. +Look at the full timeline and decide what's worth remembering long-term. + +Timeline of {len(timeline)} conversations: +{json.dumps(timeline, indent=2)} + +Analyze holistically: +1. What did you learn about this person today? +2. Any recurring themes or important moments? +3. How did your relationship with them evolve? +4. Which conversations were meaningful vs casual chitchat? + +For EACH conversation (by index), decide: +- keep: true/false (should this go to long-term memory?) +- importance: 1-10 (10 = life-changing event, 1 = forget immediately) +- categories: list of ["personal", "preference", "emotional", "event", "relationship"] +- insights: What did you learn? (for declarative memory) +- summary: One sentence for future retrieval + +Respond with VALID JSON (no extra text): +{{ + "day_summary": "One sentence about this person based on today", + "relationship_change": "How your relationship evolved (if at all)", + "conversations": [ + {{ + "index": 0, + "keep": true, + "importance": 8, + "categories": ["personal", "emotional"], + "insights": "User struggles with anxiety, needs support", + "summary": "User opened up about their anxiety" + }}, + {{ + "index": 1, + "keep": false, + "importance": 2, + "categories": [], + "insights": null, + "summary": "Just casual greeting" + }} + ], + "new_facts": [ + "User has anxiety", + "User trusts Miku enough to open up" + ] +}} +""" + + try: + # Call LLM for analysis + print(f"๐ŸŒ™ [Consolidation] Analyzing {len(memories)} memories for {user_id}...") + + # Use the Cat's LLM + from cat.looking_glass.cheshire_cat import CheshireCat + response = cat.llm(consolidation_prompt) + + # Parse JSON response + # Remove markdown code blocks if present + response = response.strip() + if response.startswith('```'): + response = response.split('```')[1] + if response.startswith('json'): + response = response[4:] + + analysis = json.loads(response) + + return analysis + + except json.JSONDecodeError as e: + print(f"โŒ [Consolidation] Failed to parse LLM response: {e}") + print(f" Response: {response[:200]}...") + # Default: keep everything if parsing fails + return { + "day_summary": "Unable to analyze", + "relationship_change": "Unknown", + "conversations": [ + {"index": i, "keep": True, "importance": 5, "categories": [], "insights": None, "summary": "Kept by default"} + for i in range(len(memories)) + ], + "new_facts": [] + } + except Exception as e: + print(f"โŒ [Consolidation] Error during analysis: {e}") + return { + "day_summary": "Error during analysis", + "relationship_change": "Unknown", + "conversations": [ + {"index": i, "keep": True, "importance": 5, "categories": [], "insights": None, "summary": "Kept by default"} + for i in range(len(memories)) + ], + "new_facts": [] + } + + +async def run_consolidation(cat): + """ + Main consolidation task. + Run at 3 AM or on-demand via admin endpoint. + """ + + if consolidation_state['is_running']: + print("โš ๏ธ [Consolidation] Already running, skipping...") + return + + try: + consolidation_state['is_running'] = True + print(f"๐ŸŒ™ [Consolidation] Starting memory consolidation at {datetime.now()}") + + # Get episodic memory collection + print("๐Ÿ“Š [Consolidation] Fetching unconsolidated memories...") + + episodic_memory = cat.memory.vectors.episodic + + # Get all points from episodic memory + # Qdrant API: scroll through all points + try: + from qdrant_client.models import Filter, FieldCondition, MatchValue + + # Query for unconsolidated memories + # Filter by consolidated=False + filter_condition = Filter( + must=[ + FieldCondition( + key="metadata.consolidated", + match=MatchValue(value=False) + ) + ] + ) + + # Get all unconsolidated memories + results = episodic_memory.client.scroll( + collection_name=episodic_memory.collection_name, + scroll_filter=filter_condition, + limit=1000, # Max per batch + with_payload=True, + with_vectors=False + ) + + memories = results[0] if results else [] + + print(f"๐Ÿ“Š [Consolidation] Found {len(memories)} unconsolidated memories") + + if len(memories) == 0: + print("โœจ [Consolidation] No memories to consolidate!") + return + + # Group by user_id + memories_by_user = {} + for point in memories: + # Extract user_id from metadata or ID + user_id = point.payload.get('metadata', {}).get('user_id', 'unknown') + if user_id == 'unknown': + # Try to extract from ID format + continue + + if user_id not in memories_by_user: + memories_by_user[user_id] = [] + + memories_by_user[user_id].append(point) + + print(f"๐Ÿ“Š [Consolidation] Processing {len(memories_by_user)} users") + + # Process each user + total_kept = 0 + total_deleted = 0 + total_processed = 0 + + for user_id, user_memories in memories_by_user.items(): + print(f"\n๐Ÿ‘ค [Consolidation] Processing user: {user_id} ({len(user_memories)} memories)") + + # Simulate consolidation for now + # In Phase 2 complete, this will call consolidate_user_memories() + for memory in user_memories: + total_processed += 1 + + # Simple heuristic for testing + content = memory.payload.get('page_content', '') + + # Delete if very short or common reactions + if len(content.strip()) <= 2 or content.lower().strip() in ['lol', 'k', 'ok', 'okay', 'haha']: + print(f" ๐Ÿ—‘๏ธ Deleting: {content[:50]}") + # Delete from Qdrant + episodic_memory.client.delete( + collection_name=episodic_memory.collection_name, + points_selector=[memory.id] + ) + total_deleted += 1 + else: + print(f" ๐Ÿ’พ Keeping: {content[:50]}") + # Mark as consolidated + payload = memory.payload + if 'metadata' not in payload: + payload['metadata'] = {} + payload['metadata']['consolidated'] = True + payload['metadata']['importance'] = 5 # Default importance + + # Update in Qdrant + episodic_memory.client.set_payload( + collection_name=episodic_memory.collection_name, + payload=payload, + points=[memory.id] + ) + total_kept += 1 + + consolidation_state['stats']['total_processed'] = total_processed + consolidation_state['stats']['kept'] = total_kept + consolidation_state['stats']['deleted'] = total_deleted + consolidation_state['last_run'] = datetime.now() + + print(f"\nโœจ [Consolidation] Complete! Stats:") + print(f" Processed: {total_processed}") + print(f" Kept: {total_kept}") + print(f" Deleted: {total_deleted}") + print(f" Facts learned: {consolidation_state['stats']['facts_learned']}") + + except Exception as e: + print(f"โŒ [Consolidation] Error querying memories: {e}") + import traceback + traceback.print_exc() + + except Exception as e: + print(f"โŒ [Consolidation] Error: {e}") + import traceback + traceback.print_exc() + finally: + consolidation_state['is_running'] = False + + +@hook(priority=50) +def after_cat_bootstrap(cat): + """ + Run after Cat starts up. + Schedule nightly consolidation task. + """ + print("๐ŸŒ™ [Memory Consolidation] Plugin loaded") + print(" Scheduling nightly consolidation for 3:00 AM") + + # TODO: Implement scheduler (APScheduler or similar) + # For now, just log that we're ready + + return None + + + + +# NOTE: before_cat_sends_message is defined below (line ~438) with merged logic + + +@hook(priority=10) +def before_cat_recalls_memories(cat): + """ + Retrieve declarative facts BEFORE Cat recalls episodic memories. + This ensures facts are available when building the prompt. + Note: This hook may not execute in all Cat versions - kept for compatibility. + """ + pass # Declarative search now happens in agent_prompt_prefix + + +@hook(priority=45) +def after_cat_recalls_memories(cat): + """ + Hook placeholder for after memory recall. + Currently unused but kept for future enhancements. + """ + pass + + + + +# Manual trigger via agent_prompt_prefix hook +@hook(priority=10) +def agent_prompt_prefix(prefix, cat): + """ + 1. Search and inject declarative facts into the prompt + 2. Handle admin commands like 'consolidate now' + """ + # PART 1: Search for declarative facts and inject into prompt + try: + user_message_json = cat.working_memory.get('user_message_json', {}) + user_text = user_message_json.get('text', '').strip() + + if user_text: + # Search declarative memory + declarative_memory = cat.memory.vectors.declarative + embedding = cat.embedder.embed_query(user_text) + + results = declarative_memory.recall_memories_from_embedding( + embedding=embedding, + metadata=None, + k=5 + ) + + if results: + high_confidence_facts = [] + for item in results: + doc = item[0] + score = item[1] + if score > 0.5: # Only reasonably relevant facts + high_confidence_facts.append(doc.page_content) + + if high_confidence_facts: + facts_text = "\n\n## ๐Ÿ“ Personal Facts About the User:\n" + for fact in high_confidence_facts: + facts_text += f"- {fact}\n" + facts_text += "\n(Use these facts when answering the user's question)\n" + prefix += facts_text + print(f"โœ… [Declarative] Injected {len(high_confidence_facts)} facts into prompt") + + except Exception as e: + print(f"โŒ [Declarative] Error: {e}") + + # PART 2: Handle consolidation command + user_message = cat.working_memory.get('user_message_json', {}) + user_text = user_message.get('text', '').lower().strip() + + if user_text in ['consolidate', 'consolidate now', '/consolidate']: + print("๐Ÿ”ง [Consolidation] Manual trigger command received!") + + # Run consolidation synchronously + import asyncio + try: + # Try to get the current event loop + loop = asyncio.get_event_loop() + if loop.is_running(): + # We're in an async context, schedule as task + print("๐Ÿ”„ [Consolidation] Scheduling async task...") + # Run synchronously using run_until_complete won't work here + # Instead, we'll use the manual non-async version + result = trigger_consolidation_sync(cat) + else: + # Not in async context, safe to run_until_complete + result = loop.run_until_complete(run_consolidation(cat)) + except RuntimeError: + # Fallback to sync version + result = trigger_consolidation_sync(cat) + + # Store the result in working memory so it can be used by other hooks + stats = consolidation_state['stats'] + cat.working_memory['consolidation_triggered'] = True + cat.working_memory['consolidation_stats'] = stats + + return prefix + +print("โœ… [Consolidation Plugin] agent_prompt_prefix hook registered") + + +# Intercept the response to replace with consolidation stats +@hook(priority=10) +def before_cat_sends_message(message, cat): + """ + 1. Inject declarative facts into response context + 2. Replace response if consolidation was triggered + """ + import sys + sys.stderr.write("\n๏ฟฝ [before_cat_sends_message] Hook executing...\n") + sys.stderr.flush() + + # PART 1: Inject declarative facts + try: + user_message_json = cat.working_memory.get('user_message_json', {}) + user_text = user_message_json.get('text', '') + + if user_text and not cat.working_memory.get('consolidation_triggered', False): + # Search declarative memory for relevant facts + declarative_memory = cat.memory.vectors.declarative + embedding = cat.embedder.embed_query(user_text) + + results = declarative_memory.recall_memories_from_embedding( + embedding=embedding, + metadata=None, + k=5 + ) + + if results: + sys.stderr.write(f"๐Ÿ’ก [Declarative] Found {len(results)} facts!\n") + # Results format: [(doc, score, vector, id), ...] - ignore vector and id + high_confidence_facts = [] + for item in results: + doc = item[0] + score = item[1] + if score > 0.5: # Only reasonably relevant facts + sys.stderr.write(f" - [{score:.2f}] {doc.page_content}\n") + high_confidence_facts.append(doc.page_content) + + # Store facts in working memory so agent_prompt_prefix can use them + if high_confidence_facts: + cat.working_memory['declarative_facts'] = high_confidence_facts + sys.stderr.write(f"โœ… [Declarative] Stored {len(high_confidence_facts)} facts in working memory\n") + + sys.stderr.flush() + + except Exception as e: + sys.stderr.write(f"โŒ [Declarative] Error: {e}\n") + sys.stderr.flush() + + # PART 2: Handle consolidation response replacement + if cat.working_memory.get('consolidation_triggered', False): + print("๐Ÿ“ [Consolidation] Replacing message with stats") + stats = cat.working_memory.get('consolidation_stats', {}) + output_str = (f"๐ŸŒ™ **Memory Consolidation Complete!**\n\n" + f"๐Ÿ“Š **Stats:**\n" + f"- Total processed: {stats.get('total_processed', 0)}\n" + f"- Kept: {stats.get('kept', 0)}\n" + f"- Deleted: {stats.get('deleted', 0)}\n" + f"- Facts learned: {stats.get('facts_learned', 0)}\n") + + # Clear the flag + cat.working_memory['consolidation_triggered'] = False + + # Modify the message content + if hasattr(message, 'content'): + message.content = output_str + else: + message['content'] = output_str + + # PART 3: Store Miku's response in memory + try: + # Get Miku's response text + if hasattr(message, 'content'): + miku_response = message.content + elif isinstance(message, dict): + miku_response = message.get('content', '') + else: + miku_response = str(message) + + if miku_response and len(miku_response) > 3: + from datetime import datetime + + # Prepare metadata + metadata = { + 'source': cat.user_id, + 'when': datetime.now().timestamp(), + 'stored_at': datetime.now().isoformat(), + 'speaker': 'miku', + 'consolidated': False, + 'guild_id': cat.working_memory.get('guild_id', 'dm'), + 'channel_id': cat.working_memory.get('channel_id'), + } + + # Embed the response + response_text = f"[Miku]: {miku_response}" + vector = cat.embedder.embed_query(response_text) + + # Store in episodic memory + cat.memory.vectors.episodic.add_point( + content=response_text, + vector=vector, + metadata=metadata + ) + + print(f"๐Ÿ’ฌ [Miku Memory] Stored response: {miku_response[:50]}...") + + except Exception as e: + print(f"โŒ [Miku Memory] Error: {e}") + + return message + +print("โœ… [Consolidation Plugin] before_cat_sends_message hook registered") + + +def trigger_consolidation_sync(cat): + """ + Synchronous version of consolidation for use in hooks. + """ + from qdrant_client import QdrantClient + + print("๐ŸŒ™ [Consolidation] Starting synchronous consolidation...") + + # Connect to Qdrant + qdrant_host = os.getenv('QDRANT_HOST', 'localhost') + qdrant_port = int(os.getenv('QDRANT_PORT', 6333)) + + client = QdrantClient(host=qdrant_host, port=qdrant_port) + + # Query all unconsolidated memories + result = client.scroll( + collection_name='episodic', + scroll_filter={ + "must_not": [ + {"key": "metadata.consolidated", "match": {"value": True}} + ] + }, + limit=10000, + with_payload=True, + with_vectors=False + ) + + memories = result[0] + print(f"๐Ÿ“Š [Consolidation] Found {len(memories)} unconsolidated memories") + + if not memories: + consolidation_state['stats'] = { + 'total_processed': 0, + 'kept': 0, + 'deleted': 0, + 'facts_learned': 0 + } + return + + #Apply heuristic-based consolidation + to_delete = [] + to_mark_consolidated = [] + user_messages_for_facts = [] # Track USER messages separately for fact extraction + + for point in memories: + content = point.payload.get('page_content', '').strip() + content_lower = content.lower() + metadata = point.payload.get('metadata', {}) + + # Check if this is a Miku message + is_miku_message = ( + metadata.get('speaker') == 'miku' or + content.startswith('[Miku]:') + ) + + # Trivial patterns (expanded list) + trivial_patterns = [ + 'lol', 'k', 'ok', 'okay', 'haha', 'lmao', 'xd', 'rofl', 'lmfao', + 'brb', 'gtg', 'afk', 'ttyl', 'lmk', 'idk', 'tbh', 'imo', 'imho', + 'omg', 'wtf', 'fyi', 'btw', 'nvm', 'jk', 'ikr', 'smh', + 'hehe', 'heh', 'gg', 'wp', 'gz', 'gj', 'ty', 'thx', 'np', 'yw', + 'nice', 'cool', 'neat', 'wow', 'yep', 'nope', 'yeah', 'nah' + ] + + is_trivial = False + + # Check if it matches trivial patterns + if len(content_lower) <= 3 and content_lower in trivial_patterns: + is_trivial = True + elif content_lower in trivial_patterns: + is_trivial = True + + if is_trivial: + to_delete.append(point.id) + else: + to_mark_consolidated.append(point.id) + # Only add USER messages for fact extraction (not Miku's responses) + if not is_miku_message: + user_messages_for_facts.append(point.id) + + # Delete trivial memories + if to_delete: + client.delete( + collection_name='episodic', + points_selector=to_delete + ) + print(f"๐Ÿ—‘๏ธ [Consolidation] Deleted {len(to_delete)} trivial memories") + + # Mark important memories as consolidated + if to_mark_consolidated: + for point_id in to_mark_consolidated: + # Get the point + point = client.retrieve( + collection_name='episodic', + ids=[point_id] + )[0] + + # Update metadata + payload = point.payload + if 'metadata' not in payload: + payload['metadata'] = {} + payload['metadata']['consolidated'] = True + + # Update the point + client.set_payload( + collection_name='episodic', + payload=payload, + points=[point_id] + ) + + print(f"โœ… [Consolidation] Marked {len(to_mark_consolidated)} memories as consolidated") + + # Update stats + facts_extracted = 0 + + # Extract declarative facts from USER messages only (not Miku's responses) + print(f"๐Ÿ” [Consolidation] Extracting declarative facts from {len(user_messages_for_facts)} user messages...") + facts_extracted = extract_and_store_facts(client, user_messages_for_facts, cat) + print(f"๐Ÿ“ [Consolidation] Extracted and stored {facts_extracted} declarative facts") + + consolidation_state['stats'] = { + 'total_processed': len(memories), + 'kept': len(to_mark_consolidated), + 'deleted': len(to_delete), + 'facts_learned': facts_extracted + } + + print("โœ… [Consolidation] Synchronous consolidation complete!") + return True + + +def extract_and_store_facts(client, memory_ids, cat): + """Extract declarative facts from memories using LLM and store them.""" + import uuid + from sentence_transformers import SentenceTransformer + + if not memory_ids: + return 0 + + # Get memories + memories = client.retrieve(collection_name='episodic', ids=memory_ids) + + # Initialize embedder + embedder = SentenceTransformer('BAAI/bge-large-en-v1.5') + + facts_stored = 0 + + # Process memories in batches to avoid overwhelming the LLM + batch_size = 5 + for i in range(0, len(memories), batch_size): + batch = memories[i:i+batch_size] + + # Combine batch messages for LLM analysis + conversation_context = "\n".join([ + f"- {mem.payload.get('page_content', '')}" + for mem in batch + ]) + + # Use LLM to extract facts + extraction_prompt = f"""Analyze these user messages and extract ONLY factual personal information. + +User messages: +{conversation_context} + +Extract facts in this exact format (one per line): +- The user's name is [name] +- The user is [age] years old +- The user lives in [location] +- The user works as [job] +- The user is allergic to [allergen] +- The user's favorite color is [color] +- The user enjoys [hobby/activity] +- The user prefers [preference] + +IMPORTANT: +- Only include facts that are CLEARLY stated +- Use the EXACT format shown above +- If no facts found, respond with: "No facts found" +- Do not include greetings, questions, or opinions +""" + + try: + # Call LLM + response = cat.llm(extraction_prompt) + + print(f"๐Ÿค– [LLM Extract] Response:\n{response[:200]}...") + + # Parse LLM response for facts + lines = response.strip().split('\n') + for line in lines: + line = line.strip() + + # Skip empty lines, headers, or "no facts" responses + if not line or line.lower().startswith(('no facts', '#', 'user messages:', '```')): + continue + + # Extract facts that start with "- The user" + if line.startswith('- The user'): + fact_text = line[2:].strip() # Remove "- " prefix + + # Determine fact type from the sentence structure + fact_type = 'general' + fact_value = fact_text + + if "'s name is" in fact_text: + fact_type = 'name' + fact_value = fact_text.split("'s name is")[-1].strip() + elif " is " in fact_text and " years old" in fact_text: + fact_type = 'age' + fact_value = fact_text.split(" is ")[1].split(" years")[0].strip() + elif "lives in" in fact_text: + fact_type = 'location' + fact_value = fact_text.split("lives in")[-1].strip() + elif "works as" in fact_text: + fact_type = 'job' + fact_value = fact_text.split("works as")[-1].strip() + elif "allergic to" in fact_text: + fact_type = 'allergy' + fact_value = fact_text.split("allergic to")[-1].strip() + elif "favorite color is" in fact_text: + fact_type = 'favorite_color' + fact_value = fact_text.split("favorite color is")[-1].strip() + elif "enjoys" in fact_text: + fact_type = 'hobby' + fact_value = fact_text.split("enjoys")[-1].strip() + elif "prefers" in fact_text: + fact_type = 'preference' + fact_value = fact_text.split("prefers")[-1].strip() + + # Generate embedding for the fact + fact_embedding = embedder.encode(fact_text).tolist() + + # Store in declarative collection + point_id = str(uuid.uuid4()) + + client.upsert( + collection_name='declarative', + points=[{ + 'id': point_id, + 'vector': fact_embedding, + 'payload': { + 'page_content': fact_text, + 'metadata': { + 'source': 'memory_consolidation', + 'when': batch[0].payload.get('metadata', {}).get('when', 0), + 'fact_type': fact_type, + 'fact_value': fact_value, + 'user_id': 'global' + } + } + }] + ) + + facts_stored += 1 + print(f"โœ… [Fact Stored] {fact_text}") + + except Exception as e: + print(f"โŒ [LLM Extract] Error: {e}") + import traceback + traceback.print_exc() + + return facts_stored + + +def trigger_consolidation_manual(cat): + """ + Manually trigger consolidation for testing. + Can be called via admin API or command. + """ + print("๐Ÿ”ง [Consolidation] Manual trigger received") + + # Run consolidation + import asyncio + try: + # Create event loop if needed + loop = asyncio.get_event_loop() + except RuntimeError: + loop = asyncio.new_event_loop() + asyncio.set_event_loop(loop) + + loop.run_until_complete(run_consolidation(cat)) + + return consolidation_state + + +# Plugin metadata +__version__ = "1.0.0" +__description__ = "Sleep consolidation - analyze memories nightly, keep important, delete trivial" + +print("โœ… [Consolidation Plugin] after_cat_recalls_memories hook registered") + + +# Tool for manual consolidation trigger +@tool(return_direct=True) +def consolidate_memories(tool_input, cat): + """Use this tool to consolidate memories. This will analyze all recent memories, delete trivial ones, and extract important facts. Input is always an empty string.""" + + print("๐Ÿ”ง [Consolidation] Tool called!") + + # Run consolidation synchronously + result = trigger_consolidation_sync(cat) + + # Return stats + stats = consolidation_state['stats'] + return (f"๐ŸŒ™ **Memory Consolidation Complete!**\n\n" + f"๐Ÿ“Š **Stats:**\n" + f"- Total processed: {stats['total_processed']}\n" + f"- Kept: {stats['kept']}\n" + f"- Deleted: {stats['deleted']}\n" + f"- Facts learned: {stats['facts_learned']}\n") + diff --git a/cat-plugins/memory_consolidation/plugin.json b/cat-plugins/memory_consolidation/plugin.json new file mode 100644 index 0000000..57584d6 --- /dev/null +++ b/cat-plugins/memory_consolidation/plugin.json @@ -0,0 +1,10 @@ +{ + "name": "Memory Consolidation", + "description": "Sleep consolidation plugin - analyze memories nightly, keep important, delete trivial (mimics human REM sleep)", + "author_name": "Miku Bot Team", + "author_url": "", + "plugin_url": "", + "tags": "memory, consolidation, sleep, intelligence", + "thumb": "", + "version": "1.0.0" +} diff --git a/cat-plugins/memory_consolidation/requirements.txt b/cat-plugins/memory_consolidation/requirements.txt new file mode 100644 index 0000000..f998fbc --- /dev/null +++ b/cat-plugins/memory_consolidation/requirements.txt @@ -0,0 +1 @@ +sentence-transformers>=2.2.0 diff --git a/cat-plugins/memory_consolidation/settings.json b/cat-plugins/memory_consolidation/settings.json new file mode 100644 index 0000000..0967ef4 --- /dev/null +++ b/cat-plugins/memory_consolidation/settings.json @@ -0,0 +1 @@ +{} diff --git a/test_full_pipeline.py b/test_full_pipeline.py new file mode 100644 index 0000000..1214449 --- /dev/null +++ b/test_full_pipeline.py @@ -0,0 +1,196 @@ +#!/usr/bin/env python3 +""" +Full Pipeline Test for Memory Consolidation System +Tests all phases: Storage โ†’ Consolidation โ†’ Fact Extraction โ†’ Recall +""" + +import requests +import time +import json + +BASE_URL = "http://localhost:1865" + +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"]) + +print("=" * 70) +print("๐Ÿงช FULL PIPELINE TEST - Memory Consolidation System") +print("=" * 70) + +# TEST 1: Trivial Message Filtering +print("\n๐Ÿ“‹ TEST 1: Trivial Message Filtering") +print("-" * 70) + +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)...") +for msg in trivial_messages: + send_message(msg) + time.sleep(0.5) + +print("Sending important message...") +send_message(important_message) +time.sleep(1) + +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)") +else: + print(" โš ๏ธ Trivial filtering may not be active") + +# TEST 2: Miku's Response Storage +print("\n๐Ÿ“‹ TEST 2: Miku's Response Storage") +print("-" * 70) + +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 = [ + "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'm allergic to peanuts", + "I live in Tokyo, Japan", + "My hobbies include photography and hiking" +] + +print(f"Adding {len(personal_info)} messages with personal information...") +for info in personal_info: + send_message(info) + 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)") + +# TEST 4: Memory Consolidation +print("\n๐Ÿ“‹ TEST 4: Memory Consolidation & Fact Extraction") +print("-" * 70) + +print("Triggering consolidation...") +resp = send_message("consolidate now") +consolidation_result = resp["content"] +print(f"\n{consolidation_result}") + +time.sleep(2) + +# Check declarative facts +declarative_count = get_qdrant_count("declarative") +print(f"\nโœ… Declarative facts extracted: {declarative_count}") + +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']}") + +# TEST 5: Fact Recall +print("\n๐Ÿ“‹ TEST 5: Declarative Fact Recall") +print("-" * 70) + +queries = [ + "What is my name?", + "How old am I?", + "Where do I work?", + "What's my favorite color?", + "What am I allergic to?" +] + +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") + + time.sleep(1) + +print(f"\nโœ… Fact recall accuracy: {correct_recalls}/{len(queries)} ({correct_recalls/len(queries)*100:.0f}%)") + +# TEST 6: Conversation History Recall +print("\n๐Ÿ“‹ TEST 6: Conversation History (Episodic) Recall") +print("-" * 70) + +print("Asking about conversation history...") +resp = send_message("What have we talked about today?") +summary = resp["content"] +print(f"๐Ÿ’ฌ Miku's summary:\n{summary}") + +# 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)}") + +# Overall verdict +if declarative_count >= 5 and correct_recalls >= 3: + print("\n๐ŸŽ‰ PIPELINE TEST: PASS") + print(" All major components working correctly!") +else: + print("\nโš ๏ธ PIPELINE TEST: PARTIAL PASS") + print(" Some components may need adjustment") + +print("\n" + "=" * 70)