From 458985ee62e75a73a46965647d44bed15f8e518a Mon Sep 17 00:00:00 2001 From: kevin Date: Mon, 25 May 2026 22:29:23 -0700 Subject: [PATCH] Fix 1: openwebuiexpects a FastAPI Request object to be passed along with the user object, but the older code in this plugin is only passing the user:Update 1. get_all_memories, 2. Update find_similar_memories_text. Fix 2: Open WebUI has recently updated its internal database functions (like Users.get_user_by_id) to be asynchronous, which means they now return a coroutine instead of returning the user object directly. Because the code doesn't await this coroutine, it tries to access .id on the coroutine itself, causing the crash. 1.Update inlet function 2. Update inlet function --- memory.py | 645 ++++++++++++++++++++++++++++++++++++------------------ 1 file changed, 438 insertions(+), 207 deletions(-) diff --git a/memory.py b/memory.py index 0343148..c3db4cf 100644 --- a/memory.py +++ b/memory.py @@ -1,10 +1,10 @@ """ -title: Memory -author: Roni Laukkarinen +title: Memory bug fix for opwewebui v0.9.5 +original author: Roni Laukkarinen, Hotfix by Kevin W. 5/25/2026 description: Automatically identify, retrieve and store memories. repository_url: https://github.com/ronilaukkarinen/open-webui-memory -version: 3.2.5 -required_open_webui_version: >= 0.5.0 +version: 3.2.6 +required_open_webui_version: >= 0.5.0 (bug fixed for v0.9.5) """ import ast @@ -258,8 +258,6 @@ """ - - class Filter: class Valves(BaseModel): openai_api_url: str = Field( @@ -292,15 +290,15 @@ class Valves(BaseModel): ) excluded_models: str = Field( default="", - description="Comma-separated list of model names to exclude from memory processing. Use lowercase with hyphens (e.g., 'english-refiner,translator,obfuscator')" + description="Comma-separated list of model names to exclude from memory processing. Use lowercase with hyphens (e.g., 'english-refiner,translator,obfuscator')", ) model_specific_settings: str = Field( default='{"character_name": {"openai_api_url": "http://localhost:11434", "api_key": "ollama", "model": "qwen2.5:7b"}}', - description='JSON object with per-model API settings. Format: {"character_name": {"openai_api_url": "http://localhost:11434", "api_key": "ollama", "model": "qwen2.5:7b"}}.' + description='JSON object with per-model API settings. Format: {"character_name": {"openai_api_url": "http://localhost:11434", "api_key": "ollama", "model": "qwen2.5:7b"}}.', ) disable_for_image_generation: bool = Field( default=True, - description="Disable memory injection for image generation requests to prevent interference with image prompts" + description="Disable memory injection for image generation requests to prevent interference with image prompts", ) class UserValves(BaseModel): @@ -344,28 +342,40 @@ async def inlet( # Check if image generation is disabled and this is an image generation request if self.valves.disable_for_image_generation: if self._is_image_generation_request(body): - print("MEMORY FILTER: Skipping memory injection for image generation request") + print( + "MEMORY FILTER: Skipping memory injection for image generation request" + ) return body else: # Debug logging to help understand request structure print(f"MEMORY DEBUG: Request body keys: {list(body.keys())}") - if 'messages' in body and len(body['messages']) > 0: - last_message = body['messages'][-1] - print(f"MEMORY DEBUG: Last message keys: {list(last_message.keys()) if isinstance(last_message, dict) else 'Not a dict'}") - if isinstance(last_message, dict) and 'content' in last_message: - content = last_message['content'] - print(f"MEMORY DEBUG: Content type: {type(content)}, Content preview: {str(content)[:100] if isinstance(content, str) else content}") + if "messages" in body and len(body["messages"]) > 0: + last_message = body["messages"][-1] + print( + f"MEMORY DEBUG: Last message keys: {list(last_message.keys()) if isinstance(last_message, dict) else 'Not a dict'}" + ) + if isinstance(last_message, dict) and "content" in last_message: + content = last_message["content"] + print( + f"MEMORY DEBUG: Content type: {type(content)}, Content preview: {str(content)[:100] if isinstance(content, str) else content}" + ) print(f"MEMORY DEBUG: Model: {body.get('model', 'Not found')}") print(f"MEMORY DEBUG: Tools: {body.get('tools', 'Not found')}") print(f"MEMORY DEBUG: Images: {body.get('images', 'Not found')}") print(f"MEMORY DEBUG: Backend: {body.get('backend', 'Not found')}") - print(f"MEMORY DEBUG: Response format: {body.get('response_format', 'Not found')}") - print(f"MEMORY DEBUG: Image generation: {body.get('image_generation', 'Not found')}") + print( + f"MEMORY DEBUG: Response format: {body.get('response_format', 'Not found')}" + ) + print( + f"MEMORY DEBUG: Image generation: {body.get('image_generation', 'Not found')}" + ) # Always inject all memories for context if __user__: try: user = Users.get_user_by_id(__user__["id"]) + if hasattr(user, "__await__"): + user = await user memories = await self.get_all_memories(user) if memories: self.inject_memories_into_conversation(body, memories) @@ -378,19 +388,25 @@ async def get_all_memories(self, user) -> list: """Retrieve ALL memories for the user with timestamps for context.""" try: # Get all memories for the user - memories_result = await get_memories(user=user) + memories_result = await get_memories( + request=Request(scope={"type": "http", "app": webui_app}), user=user + ) memories_with_time = [] - if memories_result and hasattr(memories_result, 'data'): + if memories_result and hasattr(memories_result, "data"): for memory in memories_result.data: # Format timestamp as human-readable date - created_at = getattr(memory, 'created_at', time.time()) + created_at = getattr(memory, "created_at", time.time()) if isinstance(created_at, (int, float)): - formatted_time = datetime.fromtimestamp(created_at).strftime("%Y-%m-%d %H:%M") + formatted_time = datetime.fromtimestamp(created_at).strftime( + "%Y-%m-%d %H:%M" + ) else: # Handle string timestamps or other formats try: - formatted_time = datetime.fromisoformat(str(created_at).replace('Z', '+00:00')).strftime("%Y-%m-%d %H:%M") + formatted_time = datetime.fromisoformat( + str(created_at).replace("Z", "+00:00") + ).strftime("%Y-%m-%d %H:%M") except: formatted_time = str(created_at)[:16] # Fallback @@ -401,15 +417,19 @@ async def get_all_memories(self, user) -> list: return memories_with_time elif isinstance(memories_result, list): for memory in memories_result: - if hasattr(memory, 'content'): + if hasattr(memory, "content"): # Format timestamp as human-readable date - created_at = getattr(memory, 'created_at', time.time()) + created_at = getattr(memory, "created_at", time.time()) if isinstance(created_at, (int, float)): - formatted_time = datetime.fromtimestamp(created_at).strftime("%Y-%m-%d %H:%M") + formatted_time = datetime.fromtimestamp( + created_at + ).strftime("%Y-%m-%d %H:%M") else: # Handle string timestamps or other formats try: - formatted_time = datetime.fromisoformat(str(created_at).replace('Z', '+00:00')).strftime("%Y-%m-%d %H:%M") + formatted_time = datetime.fromisoformat( + str(created_at).replace("Z", "+00:00") + ).strftime("%Y-%m-%d %H:%M") except: formatted_time = str(created_at)[:16] # Fallback @@ -466,58 +486,83 @@ def _is_image_generation_request(self, body: dict) -> bool: """ if not body: return False - + # Method 1: Check for image generation endpoint in URL (if available) # This would be checked in the calling context if URL is available - + # Method 2: Check for image generation specific parameters - image_params = ['size', 'n', 'response_format'] + image_params = ["size", "n", "response_format"] has_image_params = any(param in body for param in image_params) - + # Method 3: Check for prompt-only structure (common in image generation) - has_prompt_only = 'prompt' in body and len([k for k in body.keys() if k not in ['prompt', 'model']]) <= 2 - + has_prompt_only = ( + "prompt" in body + and len([k for k in body.keys() if k not in ["prompt", "model"]]) <= 2 + ) + # Method 4: Check metadata flags - metadata = body.get('metadata', {}) - metadata_flags = metadata.get('image_generation') or metadata.get('generate_image') - + metadata = body.get("metadata", {}) + metadata_flags = metadata.get("image_generation") or metadata.get( + "generate_image" + ) + # Method 5: Check features and options - features = body.get('features', {}) - options = body.get('options', {}) - feature_flags = features.get('image_generation') or options.get('generate_image') - + features = body.get("features", {}) + options = body.get("options", {}) + feature_flags = features.get("image_generation") or options.get( + "generate_image" + ) + # Method 6: Check for direct image generation flags - direct_flags = body.get('image_generation', False) or body.get('generate_image', False) - + direct_flags = body.get("image_generation", False) or body.get( + "generate_image", False + ) + # Method 7: Check for image generation models - model_check = any(model in body.get("model", "").lower() for model in ["dall-e", "stable-diffusion", "imagen", "midjourney"]) - + model_check = any( + model in body.get("model", "").lower() + for model in ["dall-e", "stable-diffusion", "imagen", "midjourney"] + ) + # Method 8: Check for ComfyUI or other image generation backends - backend_check = body.get("backend") in ["comfyui", "automatic1111", "stable-diffusion"] - + backend_check = body.get("backend") in [ + "comfyui", + "automatic1111", + "stable-diffusion", + ] + # Combine all detection methods is_image_request = ( - has_image_params or - (has_prompt_only and 'prompt' in body) or - metadata_flags or - feature_flags or - direct_flags or - model_check or - backend_check + has_image_params + or (has_prompt_only and "prompt" in body) + or metadata_flags + or feature_flags + or direct_flags + or model_check + or backend_check ) - + if is_image_request: detection_methods = [] - if has_image_params: detection_methods.append("image_params") - if has_prompt_only and 'prompt' in body: detection_methods.append("prompt_only") - if metadata_flags: detection_methods.append("metadata_flags") - if feature_flags: detection_methods.append("feature_flags") - if direct_flags: detection_methods.append("direct_flags") - if model_check: detection_methods.append("model_check") - if backend_check: detection_methods.append("backend_check") - - print(f"MEMORY FILTER: Detected image generation request via: {', '.join(detection_methods)}") - + if has_image_params: + detection_methods.append("image_params") + if has_prompt_only and "prompt" in body: + detection_methods.append("prompt_only") + if metadata_flags: + detection_methods.append("metadata_flags") + if feature_flags: + detection_methods.append("feature_flags") + if direct_flags: + detection_methods.append("direct_flags") + if model_check: + detection_methods.append("model_check") + if backend_check: + detection_methods.append("backend_check") + + print( + f"MEMORY FILTER: Detected image generation request via: {', '.join(detection_methods)}" + ) + return is_image_request def _should_exclude_model(self, body: dict, excluded_models: str) -> bool: @@ -529,7 +574,9 @@ def _should_exclude_model(self, body: dict, excluded_models: str) -> bool: return False # Parse excluded models list - excluded_models_list = [model.strip().strip('"\'') for model in excluded_models.split(",")] + excluded_models_list = [ + model.strip().strip("\"'") for model in excluded_models.split(",") + ] # Check multiple possible model identifier fields current_model = body.get("model", "") @@ -570,7 +617,9 @@ def _should_exclude_model(self, body: dict, excluded_models: str) -> bool: print(f"MEMORY FILTER: Excluding model: {model_identifier}") return True - print(f"MEMORY FILTER DEBUG: No exclusion match found, proceeding with memory processing") + print( + f"MEMORY FILTER DEBUG: No exclusion match found, proceeding with memory processing" + ) return False def _get_current_model_name(self, body: dict) -> str: @@ -648,9 +697,13 @@ def _get_effective_settings(self, body: dict) -> tuple[str, str, str]: api_url = model_settings.get("openai_api_url", api_url) model = model_settings.get("model", model) api_key = model_settings.get("api_key", api_key) - print(f"MEMORY DEBUG: Using model-specific settings for '{current_model_name}': url={api_url}, LLM model={model}") + print( + f"MEMORY DEBUG: Using model-specific settings for '{current_model_name}': url={api_url}, LLM model={model}" + ) else: - print(f"MEMORY DEBUG: Using global settings for model '{current_model_name}' (no specific settings found): LLM model={model}") + print( + f"MEMORY DEBUG: Using global settings for model '{current_model_name}' (no specific settings found): LLM model={model}" + ) return api_url, model, api_key @@ -661,6 +714,8 @@ async def outlet( __user__: Optional[dict] = None, ) -> dict: user = Users.get_user_by_id(__user__["id"]) + if hasattr(user, "__await__"): + user = await user self.user_valves: Filter.UserValves = __user__.get("valves", self.UserValves()) # Check if current model should be excluded from memory processing @@ -672,7 +727,9 @@ async def outlet( # Check if image generation is disabled and this is an image generation request if self.valves.disable_for_image_generation: if self._is_image_generation_request(body): - print("MEMORY FILTER: Skipping memory processing for image generation request") + print( + "MEMORY FILTER: Skipping memory processing for image generation request" + ) return body # Process user message for memories @@ -684,11 +741,14 @@ async def outlet( if i <= len(body["messages"]): message = body["messages"][-i] content = message["content"] - + # Skip assistant messages unless the valve is enabled - if message["role"] == "assistant" and not self.valves.save_assistant_response: + if ( + message["role"] == "assistant" + and not self.valves.save_assistant_response + ): continue - + # Remove memory context if it was injected from any user message if message["role"] == "user" and "" in content: # More robust removal - handle both start and embedded contexts @@ -697,7 +757,13 @@ async def outlet( else: # Handle cases where context might be embedded import re - content = re.sub(r'.*?\n\n', '', content, flags=re.DOTALL) + + content = re.sub( + r".*?\n\n", + "", + content, + flags=re.DOTALL, + ) stringified_message = STRINGIFIED_MESSAGE_TEMPLATE.format( index=i, role=message["role"], @@ -710,7 +776,9 @@ async def outlet( print(f"Error stringifying messages: {e}") prompt_string = "\n".join(stringified_messages) try: - print(f"MEMORY DEBUG: About to call identify_memories with prompt length: {len(prompt_string)}") + print( + f"MEMORY DEBUG: About to call identify_memories with prompt length: {len(prompt_string)}" + ) memories = await self.identify_memories(prompt_string, body) print(f"MEMORY DEBUG: identify_memories returned: '{memories}'") except Exception as e: @@ -755,11 +823,11 @@ async def outlet( "**Result**", "Output:", "Response:", - "Result:" + "Result:", ] for prefix in prefixes_to_remove: if memories.startswith(prefix): - memories = memories[len(prefix):].strip() + memories = memories[len(prefix) :].strip() print(f"MEMORY DEBUG: Removed prefix '{prefix}' from response") if ( @@ -798,13 +866,23 @@ async def outlet( try: # Safely parse memories for notification counting memories_cleaned = memories.strip() - + # Remove common AI response prefixes - prefixes_to_remove = ["**Correct Output**", "**Output**", "**Response**", "**Result**", "Output:", "Response:", "Result:"] + prefixes_to_remove = [ + "**Correct Output**", + "**Output**", + "**Response**", + "**Result**", + "Output:", + "Response:", + "Result:", + ] for prefix in prefixes_to_remove: if memories_cleaned.startswith(prefix): - memories_cleaned = memories_cleaned[len(prefix):].strip() - + memories_cleaned = memories_cleaned[ + len(prefix) : + ].strip() + memory_list = ast.literal_eval(memories_cleaned) memory_count = len(memory_list) await __event_emitter__( @@ -844,15 +922,21 @@ async def outlet( print("Auto Memory: no new memories identified") # Process assistant response if auto-save is enabled - print(f"ASSISTANT DEBUG: save_assistant_response valve = {self.valves.save_assistant_response}") + print( + f"ASSISTANT DEBUG: save_assistant_response valve = {self.valves.save_assistant_response}" + ) if self.valves.save_assistant_response and len(body["messages"]) > 0: last_message = body["messages"][-1] - print(f"ASSISTANT DEBUG: Last message role = {last_message.get('role', 'unknown')}") + print( + f"ASSISTANT DEBUG: Last message role = {last_message.get('role', 'unknown')}" + ) # Only save if the last message is actually from assistant if last_message.get("role") == "assistant": print(f"ASSISTANT DEBUG: Proceeding to save assistant response") try: - print(f"ASSISTANT DEBUG: Adding assistant memory: {last_message['content'][:100]}...") + print( + f"ASSISTANT DEBUG: Adding assistant memory: {last_message['content'][:100]}..." + ) memory_obj = await add_memory( request=Request(scope={"type": "http", "app": webui_app}), form_data=AddMemoryForm(content=last_message["content"]), @@ -899,7 +983,9 @@ async def identify_memories(self, input_text: str, body: dict = None) -> str: print(f"MEMORY DEBUG: Input text was: '{input_text}'") return memories - async def query_openai_api(self, system_prompt: str, prompt: str, body: dict = None) -> str: + async def query_openai_api( + self, system_prompt: str, prompt: str, body: dict = None + ) -> str: # Use model-specific settings if available, otherwise use global/user values if body: @@ -933,16 +1019,16 @@ async def query_openai_api(self, system_prompt: str, prompt: str, body: dict = N response = await session.post(url, headers=headers, json=payload) print(f"MEMORY API DEBUG: Response status: {response.status}") response.raise_for_status() - + # Get response text first for validation response_text = await response.text() print(f"MEMORY API DEBUG: Response text length: {len(response_text)}") - + # Validate response is not empty if not response_text or response_text.strip() == "": print(f"MEMORY API ERROR: Empty response received") raise Exception("Empty response from API") - + # Try to parse JSON with proper error handling try: json_content = json.loads(response_text) @@ -954,24 +1040,32 @@ async def query_openai_api(self, system_prompt: str, prompt: str, body: dict = N try: # Remove BOM, control characters, and try again import re - cleaned = response_text.strip().lstrip('\ufeff') - cleaned = re.sub(r'[\x00-\x1f\x7f]', '', cleaned) + + cleaned = response_text.strip().lstrip("\ufeff") + cleaned = re.sub(r"[\x00-\x1f\x7f]", "", cleaned) json_content = json.loads(cleaned) print(f"MEMORY API DEBUG: JSON parsed after cleaning") except: raise Exception(f"Invalid JSON response: {str(e)}") - + # Validate expected structure if "choices" not in json_content or not json_content["choices"]: - print(f"MEMORY API ERROR: Missing choices in response: {json_content}") + print( + f"MEMORY API ERROR: Missing choices in response: {json_content}" + ) raise Exception("Invalid API response structure - missing choices") - - if "message" not in json_content["choices"][0] or "content" not in json_content["choices"][0]["message"]: + + if ( + "message" not in json_content["choices"][0] + or "content" not in json_content["choices"][0]["message"] + ): print(f"MEMORY API ERROR: Missing message content in response") - raise Exception("Invalid API response structure - missing message content") - + raise Exception( + "Invalid API response structure - missing message content" + ) + return json_content["choices"][0]["message"]["content"] - + except aiohttp.ClientError as e: # Fixed error handling error_msg = str(e) @@ -993,36 +1087,53 @@ async def process_memories( try: # Safely parse the memories string with better error handling memories_cleaned = memories.strip() - + # Remove common AI response prefixes - prefixes_to_remove = ["**Correct Output**", "**Output**", "**Response**", "**Result**", "Output:", "Response:", "Result:"] + prefixes_to_remove = [ + "**Correct Output**", + "**Output**", + "**Response**", + "**Result**", + "Output:", + "Response:", + "Result:", + ] for prefix in prefixes_to_remove: if memories_cleaned.startswith(prefix): - memories_cleaned = memories_cleaned[len(prefix):].strip() - + memories_cleaned = memories_cleaned[len(prefix) :].strip() + # Validate the cleaned response looks like a list - if not memories_cleaned.startswith("[") or not memories_cleaned.endswith("]"): - print(f"Auto Memory: Response doesn't look like a list: {repr(memories_cleaned[:100])}") + if not memories_cleaned.startswith("[") or not memories_cleaned.endswith( + "]" + ): + print( + f"Auto Memory: Response doesn't look like a list: {repr(memories_cleaned[:100])}" + ) # Try to extract list from malformed response import re - list_match = re.search(r'\[.*?\]', memories_cleaned, re.DOTALL) + + list_match = re.search(r"\[.*?\]", memories_cleaned, re.DOTALL) if list_match: memories_cleaned = list_match.group(0) print(f"Auto Memory: Extracted list from response") else: - print(f"Auto Memory: Could not extract valid list, skipping memory storage") + print( + f"Auto Memory: Could not extract valid list, skipping memory storage" + ) return True # Return success to avoid crashing # Try parsing with comprehensive error handling try: memory_list = ast.literal_eval(memories_cleaned) if not isinstance(memory_list, list): - print(f"Auto Memory: Parsed result is not a list: {type(memory_list)}") + print( + f"Auto Memory: Parsed result is not a list: {type(memory_list)}" + ) raise ValueError(f"Expected list, got {type(memory_list)}") except (ValueError, SyntaxError) as e: print(f"Auto Memory: ast.literal_eval failed: {e}") print(f"Auto Memory: Failed to parse: {repr(memories_cleaned[:200])}") - + # Try JSON parsing as fallback try: memory_list = json.loads(memories_cleaned) @@ -1033,24 +1144,31 @@ async def process_memories( print(f"Auto Memory: JSON fallback also failed: {json_e}") print(f"Auto Memory: Skipping memory storage due to parse error") return True # Return success to avoid crashing - + print(f"Auto Memory: identified {len(memory_list)} new memories") # Validate all items are strings for i, memory in enumerate(memory_list): if not isinstance(memory, str): - print(f"Auto Memory: Memory item {i} is not a string: {type(memory)}") + print( + f"Auto Memory: Memory item {i} is not a string: {type(memory)}" + ) memory_list[i] = str(memory) # Pre-process to remove exact duplicates within the same batch unique_memories = [] for memory in memory_list: memory_lower = memory.lower().strip() - if not any(existing.lower().strip() == memory_lower for existing in unique_memories): + if not any( + existing.lower().strip() == memory_lower + for existing in unique_memories + ): unique_memories.append(memory) if len(unique_memories) < len(memory_list): - print(f"Auto Memory: removed {len(memory_list) - len(unique_memories)} exact duplicates from batch") + print( + f"Auto Memory: removed {len(memory_list) - len(unique_memories)} exact duplicates from batch" + ) # Instead of processing each memory individually, consolidate the entire batch # This handles cases where multiple memories in the same conversation contradict each other @@ -1066,57 +1184,77 @@ async def process_memories( Return the final list as a Python list of strings - just the memory text, no extra formatting. """ - + if __user__: self.user_valves = __user__.get("valves", self.UserValves()) - + batch_result = await self.query_openai_api( system_prompt="You are consolidating memories from a single conversation. Keep the most recent when there are contradictions.", prompt=batch_consolidation_prompt, body=body, ) - + # Safely parse batch consolidation result using the same safe parsing batch_result_cleaned = batch_result.strip() - + # Remove common AI response prefixes - prefixes_to_remove = ["**Correct Output**", "**Output**", "**Response**", "**Result**", "Output:", "Response:", "Result:"] + prefixes_to_remove = [ + "**Correct Output**", + "**Output**", + "**Response**", + "**Result**", + "Output:", + "Response:", + "Result:", + ] for prefix in prefixes_to_remove: if batch_result_cleaned.startswith(prefix): - batch_result_cleaned = batch_result_cleaned[len(prefix):].strip() - + batch_result_cleaned = batch_result_cleaned[ + len(prefix) : + ].strip() + # Parse batch consolidation result safely try: batch_memories = ast.literal_eval(batch_result_cleaned) if not isinstance(batch_memories, list): - raise ValueError(f"Expected list, got {type(batch_memories)}") + raise ValueError( + f"Expected list, got {type(batch_memories)}" + ) except (ValueError, SyntaxError) as e: # Try JSON fallback try: batch_memories = json.loads(batch_result_cleaned) if not isinstance(batch_memories, list): - raise ValueError(f"Expected list, got {type(batch_memories)}") + raise ValueError( + f"Expected list, got {type(batch_memories)}" + ) except json.JSONDecodeError: - print(f"Auto Memory: Batch consolidation parsing failed: {e}") + print( + f"Auto Memory: Batch consolidation parsing failed: {e}" + ) # Fall back to individual processing batch_memories = unique_memories - print(f"Auto Memory: Batch consolidation reduced {len(unique_memories)} memories to {len(batch_memories)}") - + print( + f"Auto Memory: Batch consolidation reduced {len(unique_memories)} memories to {len(batch_memories)}" + ) + # Now process the consolidated batch for memory in batch_memories: await self.store_memory(memory, user, body, __user__) return True - + except (ValueError, SyntaxError) as e: if "string did not match the expected pattern" in str(e): - print(f"Auto Memory: Batch consolidation AI response parsing failed - malformed list format. Response was: {repr(batch_result[:200])}") + print( + f"Auto Memory: Batch consolidation AI response parsing failed - malformed list format. Response was: {repr(batch_result[:200])}" + ) else: print(f"Auto Memory: Batch consolidation parsing failed: {e}") # Fall back to individual processing except Exception as e: print(f"Auto Memory: Batch consolidation failed: {e}") # Fall back to individual processing - + # Process individually if batch consolidation failed or only one memory for memory in unique_memories: await self.store_memory(memory, user, body, __user__) @@ -1124,6 +1262,7 @@ async def process_memories( except Exception as e: print(f"Auto Memory: Unexpected error in process_memories: {e}") import traceback + print(f"Auto Memory: Traceback: {traceback.format_exc()}") # Return True to prevent crash - just skip this memory processing return True @@ -1133,55 +1272,76 @@ async def find_similar_memories_text(self, memory: str, user) -> list: try: print(f"Auto Memory: Starting text-based similarity search for '{memory}'") # Get all existing memories - memories_result = await get_memories(user=user) + memories_result = await get_memories( + request=Request(scope={"type": "http", "app": webui_app}), user=user + ) print(f"Auto Memory: get_memories returned type: {type(memories_result)}") print(f"Auto Memory: get_memories result: {memories_result}") - + if not memories_result: print(f"Auto Memory: No existing memories found") return [] - + # Handle different return formats - if hasattr(memories_result, 'data'): + if hasattr(memories_result, "data"): existing_memories = memories_result.data elif isinstance(memories_result, list): existing_memories = memories_result else: print(f"Auto Memory: Invalid result format from get_memories") return [] - - print(f"Auto Memory: Found {len(existing_memories)} existing memories to compare against") + + print( + f"Auto Memory: Found {len(existing_memories)} existing memories to compare against" + ) similar_memories = [] memory_lower = memory.lower().strip() - + for existing_memory in existing_memories: - existing_content = getattr(existing_memory, 'content', '').lower().strip() - + existing_content = ( + getattr(existing_memory, "content", "").lower().strip() + ) + # Calculate simple text similarity - similarity = self.calculate_text_similarity(memory_lower, existing_content) - + similarity = self.calculate_text_similarity( + memory_lower, existing_content + ) + # Use the same distance threshold (convert similarity to distance) distance = 1.0 - similarity - print(f"Auto Memory: Comparing '{memory}' vs '{existing_content[:50]}...' - similarity: {similarity:.3f}, distance: {distance:.3f}") - + print( + f"Auto Memory: Comparing '{memory}' vs '{existing_content[:50]}...' - similarity: {similarity:.3f}, distance: {distance:.3f}" + ) + if distance < self.valves.related_memories_dist: - similar_memories.append({ - 'id': getattr(existing_memory, 'id', ''), - 'fact': getattr(existing_memory, 'content', ''), - 'metadata': {'created_at': getattr(existing_memory, 'created_at', time.time())}, - 'distance': distance - }) - print(f"Auto Memory: Added similar memory (distance: {distance:.3f}): '{existing_content[:50]}...'") - + similar_memories.append( + { + "id": getattr(existing_memory, "id", ""), + "fact": getattr(existing_memory, "content", ""), + "metadata": { + "created_at": getattr( + existing_memory, "created_at", time.time() + ) + }, + "distance": distance, + } + ) + print( + f"Auto Memory: Added similar memory (distance: {distance:.3f}): '{existing_content[:50]}...'" + ) + # Sort by distance (most similar first) and limit results - similar_memories.sort(key=lambda x: x['distance']) - final_results = similar_memories[:self.valves.related_memories_n] - print(f"Auto Memory: Text-based search returning {len(final_results)} similar memories") + similar_memories.sort(key=lambda x: x["distance"]) + final_results = similar_memories[: self.valves.related_memories_n] + print( + f"Auto Memory: Text-based search returning {len(final_results)} similar memories" + ) return final_results - + except Exception as e: print(f"Auto Memory: Error in text-based similarity search: {e}") import traceback + print(f"Auto Memory: Traceback: {traceback.format_exc()}") return None @@ -1189,30 +1349,30 @@ def calculate_text_similarity(self, text1: str, text2: str) -> float: """Simple text similarity focusing on word overlap.""" if not text1 or not text2: return 0.0 - + # Exact match if text1 == text2: return 1.0 - + # Check if one text contains the other if text1 in text2 or text2 in text1: return 0.8 - + # Simple word overlap (case-insensitive) words1 = set(text1.lower().split()) words2 = set(text2.lower().split()) - + if not words1 or not words2: return 0.0 - + intersection = words1.intersection(words2) union = words1.union(words2) - + # For "User loves strawberries" vs "User hates strawberries": # intersection = {"user", "strawberries"} = 2 words - # union = {"user", "loves", "strawberries", "hates"} = 4 words + # union = {"user", "loves", "strawberries", "hates"} = 4 words # similarity = 2/4 = 0.5 (which converts to distance 0.5, well under 0.6 threshold) - + return len(intersection) / len(union) if union else 0.0 async def store_memory( @@ -1226,14 +1386,18 @@ async def store_memory( # Initialize user_valves if __user__ is provided if __user__: self.user_valves = __user__.get("valves", self.UserValves()) - + try: # Use improved text-based similarity search instead of vector search - print(f"Auto Memory: Using improved text-based similarity search for '{memory}'") + print( + f"Auto Memory: Using improved text-based similarity search for '{memory}'" + ) related_memories = await self.find_similar_memories_text(memory, user) - + if related_memories is None: - print(f"Auto Memory: Text-based search failed for '{memory}'. Storing without duplicate detection.") + print( + f"Auto Memory: Text-based search failed for '{memory}'. Storing without duplicate detection." + ) await add_memory( request=Request(scope={"type": "http", "app": webui_app}), form_data=AddMemoryForm(content=memory), @@ -1249,18 +1413,30 @@ async def store_memory( if len(related_memories) > 0 and isinstance(related_memories[0], dict): # Text-based search results (already in structured format) structured_data = related_memories - print(f"Auto Memory: Found {len(related_memories)} related memories using text-based similarity") + print( + f"Auto Memory: Found {len(related_memories)} related memories using text-based similarity" + ) else: # Empty list from text-based search - no similar memories found structured_data = [] - print(f"Auto Memory: No similar memories found using text-based similarity for '{memory}'") - elif hasattr(related_memories, 'ids') and hasattr(related_memories, 'documents'): + print( + f"Auto Memory: No similar memories found using text-based similarity for '{memory}'" + ) + elif hasattr(related_memories, "ids") and hasattr( + related_memories, "documents" + ): # Vector search SearchResult format ids = related_memories.ids[0] if related_memories.ids else [] - documents = related_memories.documents[0] if related_memories.documents else [] - metadatas = related_memories.metadatas[0] if related_memories.metadatas else [] - distances = related_memories.distances[0] if related_memories.distances else [] - + documents = ( + related_memories.documents[0] if related_memories.documents else [] + ) + metadatas = ( + related_memories.metadatas[0] if related_memories.metadatas else [] + ) + distances = ( + related_memories.distances[0] if related_memories.distances else [] + ) + # Combine each document and its associated data into a list of dictionaries structured_data = [ { @@ -1278,7 +1454,7 @@ async def store_memory( documents = related_list[1][1][0] metadatas = related_list[2][1][0] distances = related_list[3][1][0] - + # Combine each document and its associated data into a list of dictionaries structured_data = [ { @@ -1298,11 +1474,17 @@ async def store_memory( # Debug logging to understand why duplicates aren't being caught if filtered_data: - print(f"Auto Memory: Found {len(filtered_data)} related memories for '{memory}'") + print( + f"Auto Memory: Found {len(filtered_data)} related memories for '{memory}'" + ) for item in filtered_data: - print(f" - Distance: {item['distance']:.4f}, Memory: '{item['fact']}'") + print( + f" - Distance: {item['distance']:.4f}, Memory: '{item['fact']}'" + ) else: - print(f"Auto Memory: No related memories found for '{memory}' (threshold: {self.valves.related_memories_dist})") + print( + f"Auto Memory: No related memories found for '{memory}' (threshold: {self.valves.related_memories_dist})" + ) fact_list = [ {"fact": item["fact"], "created_at": item["metadata"]["created_at"]} for item in filtered_data @@ -1313,36 +1495,57 @@ async def store_memory( return f"Unable to restructure and filter related memories: {e}" # Consolidate conflicts or overlaps try: - print(f"Auto Memory: Starting consolidation API call for memory: '{memory}'") + print( + f"Auto Memory: Starting consolidation API call for memory: '{memory}'" + ) print(f"Auto Memory: Consolidation input fact_list: {fact_list}") - print(f"Auto Memory: Found {len(filtered_data)} similar memories for consolidation") + print( + f"Auto Memory: Found {len(filtered_data)} similar memories for consolidation" + ) if filtered_data: for item in filtered_data: - print(f" - Similar memory (distance {item['distance']:.4f}): '{item['fact']}'") - print(f"Auto Memory: API URL: {self.user_valves.openai_api_url if hasattr(self, 'user_valves') and self.user_valves.openai_api_url else self.valves.openai_api_url}") - print(f"Auto Memory: Model: {self.user_valves.model if hasattr(self, 'user_valves') and self.user_valves.model else self.valves.model}") - print(f"Auto Memory: API Key configured: {bool(self.user_valves.api_key if hasattr(self, 'user_valves') and self.user_valves.api_key else self.valves.api_key)}") - + print( + f" - Similar memory (distance {item['distance']:.4f}): '{item['fact']}'" + ) + print( + f"Auto Memory: API URL: {self.user_valves.openai_api_url if hasattr(self, 'user_valves') and self.user_valves.openai_api_url else self.valves.openai_api_url}" + ) + print( + f"Auto Memory: Model: {self.user_valves.model if hasattr(self, 'user_valves') and self.user_valves.model else self.valves.model}" + ) + print( + f"Auto Memory: API Key configured: {bool(self.user_valves.api_key if hasattr(self, 'user_valves') and self.user_valves.api_key else self.valves.api_key)}" + ) + consolidated_memories = await self.query_openai_api( system_prompt=CONSOLIDATE_MEMORIES_PROMPT, prompt=json.dumps(fact_list), body=body, ) - print(f"Auto Memory: Consolidation API call successful. Response: {consolidated_memories}") - print(f"Auto Memory: Raw consolidation response: {repr(consolidated_memories)}") + print( + f"Auto Memory: Consolidation API call successful. Response: {consolidated_memories}" + ) + print( + f"Auto Memory: Raw consolidation response: {repr(consolidated_memories)}" + ) except Exception as e: print(f"Auto Memory: Consolidation API call failed with error: {e}") import traceback + print(f"Auto Memory: Consolidation traceback: {traceback.format_exc()}") # If consolidation fails, try basic duplicate handling if len(filtered_data) > 0: - print(f"Auto Memory: Consolidation failed but found {len(filtered_data)} similar memories. Performing basic duplicate handling.") + print( + f"Auto Memory: Consolidation failed but found {len(filtered_data)} similar memories. Performing basic duplicate handling." + ) # Check if this is a simple duplicate (very high similarity) for item in filtered_data: if item["distance"] < 0.1: # Very similar - likely duplicate - print(f"Auto Memory: Found very similar memory (distance: {item['distance']:.3f}). Skipping storage to avoid duplicate.") + print( + f"Auto Memory: Found very similar memory (distance: {item['distance']:.3f}). Skipping storage to avoid duplicate." + ) return "Memory already exists" - + # Check if this contradicts existing memories about the same topic # Use AI to detect contradictions when consolidation API fails if len(filtered_data) > 0: @@ -1365,34 +1568,42 @@ async def store_memory( - "User likes apples" vs "User likes oranges" = NOT a contradiction (can like both) - "User is 25 years old" vs "User is 30 years old" = contradiction """ - + contradiction_result = await self.query_openai_api( system_prompt="You are a precise contradiction detector. Return only valid JSON.", prompt=contradiction_prompt, body=body, ) - + result = json.loads(contradiction_result.strip()) - - if result.get("has_contradiction") and result.get("contradicting_memory"): + + if result.get("has_contradiction") and result.get( + "contradicting_memory" + ): # Find and delete the contradicting memory for item in filtered_data: if item["fact"] == result["contradicting_memory"]: - print(f"Auto Memory: AI detected contradiction. Deleting old memory: '{item['fact']}'") + print( + f"Auto Memory: AI detected contradiction. Deleting old memory: '{item['fact']}'" + ) await delete_memory_by_id(item["id"], user) break elif result.get("has_contradiction"): - # If contradiction detected but no specific memory identified, + # If contradiction detected but no specific memory identified, # delete all similar memories (they're likely all conflicting) - print(f"Auto Memory: AI detected general contradiction. Deleting all similar memories.") + print( + f"Auto Memory: AI detected general contradiction. Deleting all similar memories." + ) for item in filtered_data: - print(f"Auto Memory: Deleting conflicting memory: '{item['fact']}'") + print( + f"Auto Memory: Deleting conflicting memory: '{item['fact']}'" + ) await delete_memory_by_id(item["id"], user) - + except Exception as e: print(f"Auto Memory: Contradiction detection failed: {e}") # Fall back to storing without contradiction detection - + # Store the new memory print(f"Auto Memory: Storing memory without consolidation: '{memory}'") await add_memory( @@ -1405,27 +1616,39 @@ async def store_memory( try: # Parse the consolidated memories first with better error handling consolidated_cleaned = consolidated_memories.strip() - + # Remove common AI response prefixes - prefixes_to_remove = ["**Correct Output**", "**Output**", "**Response**", "**Result**", "Output:", "Response:", "Result:"] + prefixes_to_remove = [ + "**Correct Output**", + "**Output**", + "**Response**", + "**Result**", + "Output:", + "Response:", + "Result:", + ] for prefix in prefixes_to_remove: if consolidated_cleaned.startswith(prefix): - consolidated_cleaned = consolidated_cleaned[len(prefix):].strip() - + consolidated_cleaned = consolidated_cleaned[len(prefix) :].strip() + memory_list = ast.literal_eval(consolidated_cleaned) # Only proceed with deletion/addition if consolidation actually changed something original_facts = [item["fact"] for item in fact_list] original_facts_set = set(original_facts) memory_list_set = set(memory_list) - + # Check if consolidation changed anything meaningfully memories_were_consolidated = len(memory_list) < len(original_facts) - content_was_changed = original_facts_set != memory_list_set and len(filtered_data) > 0 - + content_was_changed = ( + original_facts_set != memory_list_set and len(filtered_data) > 0 + ) + if memories_were_consolidated or content_was_changed: # Real consolidation happened - duplicates/conflicts resolved - print(f"Consolidation detected: {len(original_facts)} -> {len(memory_list)} memories") + print( + f"Consolidation detected: {len(original_facts)} -> {len(memory_list)} memories" + ) print(f"Original facts: {original_facts}") print(f"Consolidated facts: {memory_list}") @@ -1445,7 +1668,9 @@ async def store_memory( print(f"Added consolidated memory: {item}") else: # No real consolidation - just add the new memory without touching existing ones - print("No consolidation needed - just adding new memory without affecting existing ones") + print( + "No consolidation needed - just adding new memory without affecting existing ones" + ) await add_memory( request=Request(scope={"type": "http", "app": webui_app}), form_data=AddMemoryForm(content=memory), @@ -1454,15 +1679,21 @@ async def store_memory( except (ValueError, SyntaxError) as e: # Handle specific parsing errors with more helpful messages if "string did not match the expected pattern" in str(e): - print(f"Auto Memory: Consolidation AI response parsing failed - malformed list format. Response was: {repr(consolidated_memories[:200])}") + print( + f"Auto Memory: Consolidation AI response parsing failed - malformed list format. Response was: {repr(consolidated_memories[:200])}" + ) # Fall back to storing without consolidation - print(f"Auto Memory: Storing memory without consolidation due to parsing error: '{memory}'") + print( + f"Auto Memory: Storing memory without consolidation due to parsing error: '{memory}'" + ) await add_memory( request=Request(scope={"type": "http", "app": webui_app}), form_data=AddMemoryForm(content=memory), user=user, ) - print(f"Auto Memory: Successfully stored memory without consolidation: '{memory}'") + print( + f"Auto Memory: Successfully stored memory without consolidation: '{memory}'" + ) return True else: return f"Memory consolidation parsing error: {str(e)}"