Skip to content
Merged
41 changes: 40 additions & 1 deletion src/memos/api/handlers/memory_handler.py
Original file line number Diff line number Diff line change
Expand Up @@ -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 = [
Expand All @@ -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)
Expand All @@ -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(
Expand Down
7 changes: 7 additions & 0 deletions src/memos/api/product_models.py
Original file line number Diff line number Diff line change
Expand Up @@ -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):
Expand Down
7 changes: 6 additions & 1 deletion src/memos/mem_reader/read_multi_modal/file_content_parser.py
Original file line number Diff line number Diff line change
Expand Up @@ -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 = []

Expand Down Expand Up @@ -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")

Expand Down Expand Up @@ -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
)
Expand Down Expand Up @@ -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,
Expand Down
25 changes: 19 additions & 6 deletions src/memos/mem_reader/read_multi_modal/image_parser.py
Original file line number Diff line number Diff line change
Expand Up @@ -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(
Expand All @@ -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),
},
}

Expand Down
7 changes: 6 additions & 1 deletion src/memos/mem_reader/read_multi_modal/multi_modal_parser.py
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,8 @@
in both fast and fine modes.
"""

import traceback

from typing import Any

from memos.embedders.base import BaseEmbedder
Expand Down Expand Up @@ -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)
Expand Down
1 change: 1 addition & 0 deletions src/memos/mem_reader/read_multi_modal/user_parser.py
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down
1 change: 1 addition & 0 deletions src/memos/memories/textual/item.py
Original file line number Diff line number Diff line change
Expand Up @@ -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")


Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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:
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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]
Expand Down