diff --git a/src/memos/api/handlers/memory_handler.py b/src/memos/api/handlers/memory_handler.py index 2ab8f50c7..72c6a3da8 100644 --- a/src/memos/api/handlers/memory_handler.py +++ b/src/memos/api/handlers/memory_handler.py @@ -320,7 +320,10 @@ def handle_delete_memories(delete_mem_req: DeleteMemoryRequest, naive_mem_cube: Now unified to delete from text_mem only (includes preferences). """ logger.info( - f"[Delete memory request] writable_cube_ids: {delete_mem_req.writable_cube_ids}, memory_ids: {delete_mem_req.memory_ids}" + "[Delete memory request] writable_cube_ids: %s, memory_ids: %s, auto_cleanup_working: %s", + delete_mem_req.writable_cube_ids, + delete_mem_req.memory_ids, + getattr(delete_mem_req, "auto_cleanup_working", False), ) # Validate that only one of memory_ids, file_ids, or filter is provided provided_params = [ @@ -335,6 +338,31 @@ def handle_delete_memories(delete_mem_req: DeleteMemoryRequest, naive_mem_cube: ) try: + working_ids_to_delete: set[str] = set() + # When deleting by explicit memory_ids and auto_cleanup_working is enabled, + # collect related WorkingMemory ids from working_binding + if delete_mem_req.memory_ids is not None and getattr( + delete_mem_req, "auto_cleanup_working", False + ): + try: + memories = naive_mem_cube.text_mem.get_by_ids(memory_ids=delete_mem_req.memory_ids) + except Exception as e: + logger.warning("Failed to fetch memories before delete for working cleanup: %s", e) + memories = [] + + if memories: + import re + + pattern = re.compile(r"\[working_binding:([0-9a-fA-F-]{36})\]") + for mem in memories: + metadata = mem.get("metadata") or {} + bg = metadata.get("background") or "" + if not isinstance(bg, str): + continue + match = pattern.search(bg) + if match: + working_ids_to_delete.add(match.group(1)) + if delete_mem_req.memory_ids is not None: # Unified deletion from text_mem (includes preferences) naive_mem_cube.text_mem.delete_by_memory_ids(delete_mem_req.memory_ids) @@ -344,6 +372,17 @@ def handle_delete_memories(delete_mem_req: DeleteMemoryRequest, naive_mem_cube: ) elif delete_mem_req.filter is not None: naive_mem_cube.text_mem.delete_by_filter(filter=delete_mem_req.filter) + + # After main deletion, optionally clean up related WorkingMemory nodes. + if working_ids_to_delete: + try: + logger.info( + "Auto-cleanup WorkingMemory nodes after delete, count=%d", + len(working_ids_to_delete), + ) + naive_mem_cube.text_mem.delete_by_memory_ids(list(working_ids_to_delete)) + except Exception as e: + logger.warning("Failed to auto-cleanup WorkingMemory nodes: %s, Pass", e) except Exception as e: logger.error(f"Failed to delete memories: {e}", exc_info=True) return DeleteMemoryResponse( diff --git a/src/memos/api/product_models.py b/src/memos/api/product_models.py index 6f112b9a7..59f7f74c8 100644 --- a/src/memos/api/product_models.py +++ b/src/memos/api/product_models.py @@ -858,6 +858,13 @@ class DeleteMemoryRequest(BaseRequest): memory_ids: list[str] | None = Field(None, description="Memory IDs") file_ids: list[str] | None = Field(None, description="File IDs") filter: dict[str, Any] | None = Field(None, description="Filter for the memory") + auto_cleanup_working: bool | None = Field( + False, + description=( + "(Internal) Whether to automatically delete related WorkingMemory nodes " + "based on working_binding metadata when deleting by memory_ids." + ), + ) class SuggestionRequest(BaseRequest): diff --git a/src/memos/mem_reader/read_multi_modal/file_content_parser.py b/src/memos/mem_reader/read_multi_modal/file_content_parser.py index 00e02abda..c8f944e41 100644 --- a/src/memos/mem_reader/read_multi_modal/file_content_parser.py +++ b/src/memos/mem_reader/read_multi_modal/file_content_parser.py @@ -470,7 +470,7 @@ def parse_fast( file_data = file_info.get("file_data", "") file_id = file_info.get("file_id", "") filename = file_info.get("filename", "") - file_url_flag = False + file_url_flag = bool(file_info) # Build content string based on available information content_parts = [] @@ -651,6 +651,9 @@ def parse_fine( file_id = file_info.get("file_id", "") filename = file_info.get("filename", "") + # Whether to keep full file_info in sources + file_url_flag = bool(file_info) + # Extract custom_tags from kwargs (for LLM extraction) custom_tags = kwargs.get("custom_tags") @@ -683,6 +686,7 @@ def parse_fine( url_str = file_data[1:] if file_data.startswith("@") else file_data if url_str.startswith(("http://", "https://")): + file_url_flag = True parsed_text, temp_file_path, is_markdown = self._handle_url( url_str, filename ) @@ -793,6 +797,7 @@ def _make_memory_item( chunk_index=chunk_idx, chunk_total=total_chunks, chunk_content=chunk_content, + file_url_flag=file_url_flag, ) return TextualMemoryItem( memory=value, diff --git a/src/memos/mem_reader/read_multi_modal/image_parser.py b/src/memos/mem_reader/read_multi_modal/image_parser.py index 0d5e8bcc2..43674a9cc 100644 --- a/src/memos/mem_reader/read_multi_modal/image_parser.py +++ b/src/memos/mem_reader/read_multi_modal/image_parser.py @@ -51,15 +51,23 @@ def create_source( if isinstance(image_url, dict): url = image_url.get("url", "") detail = image_url.get("detail", "auto") + image_info = image_url + return SourceMessage( + type="image", + content=url, + url=url, + detail=detail, + image_info=image_info, + ) else: url = str(image_url) detail = "auto" - return SourceMessage( - type="image", - content=url, - url=url, - detail=detail, - ) + return SourceMessage( + type="image", + content=url, + url=url, + detail=detail, + ) return SourceMessage(type="image", content=str(message)) def rebuild_from_source( @@ -74,11 +82,16 @@ def rebuild_from_source( or (source.content or "").replace("[image_url]: ", "") ) detail = getattr(source, "detail", "auto") + image_id = "" + image_info = source.image_info + if image_info and isinstance(image_info, dict): + image_id = image_info.get("image_id") return { "type": "image_url", "image_url": { "url": url, "detail": detail, + "image_id": str(image_id), }, } diff --git a/src/memos/mem_reader/read_multi_modal/multi_modal_parser.py b/src/memos/mem_reader/read_multi_modal/multi_modal_parser.py index 81bd25902..4750d181b 100644 --- a/src/memos/mem_reader/read_multi_modal/multi_modal_parser.py +++ b/src/memos/mem_reader/read_multi_modal/multi_modal_parser.py @@ -4,6 +4,8 @@ in both fast and fine modes. """ +import traceback + from typing import Any from memos.embedders.base import BaseEmbedder @@ -242,7 +244,10 @@ def process_transfer( try: message = parser.rebuild_from_source(source) except Exception as e: - logger.error(f"[MultiModalParser] Error rebuilding message from source: {e}") + logger.error( + f"[MultiModalParser] Error rebuilding message from " + f"source: {e} {traceback.format_exc()}" + ) return [] # Parse in fine mode (pass context_items and custom_tags to parse_fine) diff --git a/src/memos/mem_reader/read_multi_modal/user_parser.py b/src/memos/mem_reader/read_multi_modal/user_parser.py index 2e5ea6eae..a5a248e9d 100644 --- a/src/memos/mem_reader/read_multi_modal/user_parser.py +++ b/src/memos/mem_reader/read_multi_modal/user_parser.py @@ -114,6 +114,7 @@ def create_source( chat_time=chat_time, message_id=message_id, image_path=image_info.get("url"), + image_info=image_info, ) source.lang = overall_lang sources.append(source) diff --git a/src/memos/memories/textual/item.py b/src/memos/memories/textual/item.py index 60af67830..a9b2c43a4 100644 --- a/src/memos/memories/textual/item.py +++ b/src/memos/memories/textual/item.py @@ -42,6 +42,7 @@ class SourceMessage(BaseModel): content: str | None = None doc_path: str | None = None file_info: dict | None = None + image_info: dict | None = None model_config = ConfigDict(extra="allow") diff --git a/src/memos/memories/textual/tree_text_memory/organize/manager.py b/src/memos/memories/textual/tree_text_memory/organize/manager.py index df419f0c1..96453f5a0 100644 --- a/src/memos/memories/textual/tree_text_memory/organize/manager.py +++ b/src/memos/memories/textual/tree_text_memory/organize/manager.py @@ -235,7 +235,6 @@ def _submit_batches(nodes: list[dict], node_kind: str) -> None: exc_info=e, ) - _submit_batches(working_nodes, "WorkingMemory") _submit_batches(graph_nodes, "graph memory") if graph_node_ids and self.is_reorganize: diff --git a/src/memos/types/openai_chat_completion_types/chat_completion_content_part_image_param.py b/src/memos/types/openai_chat_completion_types/chat_completion_content_part_image_param.py index 6718bd91e..a5469cdc3 100644 --- a/src/memos/types/openai_chat_completion_types/chat_completion_content_part_image_param.py +++ b/src/memos/types/openai_chat_completion_types/chat_completion_content_part_image_param.py @@ -19,6 +19,9 @@ class ImageURL(TypedDict, total=False): [Vision guide](https://platform.openai.com/docs/guides/vision#low-or-high-fidelity-image-understanding). """ + image_id: str + """Optional custom image id for tracking image sources.""" + class ChatCompletionContentPartImageParam(TypedDict, total=False): image_url: Required[ImageURL]