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)}"