Skip to content

Commit 721568a

Browse files
authored
merge v2.0.17 into main (#1768)
## Description dream - AddPhaseEnricher & context & search implementations ## Type of change - [x] New feature (non-breaking change which adds functionality) - [x] Bug fix (non-breaking change which fixes an issue) ## How Has This Been Tested? - [x] Unit Test - tests/dream/ ## Checklist - [x] I have performed a self-review of my own code | 我已自行检查了自己的代码 - [x] I have commented my code in hard-to-understand areas | 我已在难以理解的地方对代码进行了注释 - [x] I have added tests that prove my fix is effective or that my feature works | 我已添加测试以证明我的修复有效或功能正常 - [ ] I have created related documentation issue/PR in [MemOS-Docs](https://github.com/MemTensor/MemOS-Docs) (if applicable) | 我已在 [MemOS-Docs](https://github.com/MemTensor/MemOS-Docs) 中创建了相关的文档 issue/PR(如果适用) - [x] I have linked the issue to this PR (if applicable) | 我已将 issue 链接到此 PR(如果适用) - [x] I have mentioned the person who will review this PR | 我已提及将审查此 PR 的人 ## Reviewer Checklist - [ ] closes #xxxx (Replace xxxx with the GitHub issue number) - [ ] Made sure Checks passed - [ ] Tests have been provided
2 parents 6bfe97c + 23da71c commit 721568a

26 files changed

Lines changed: 443 additions & 112 deletions

File tree

pyproject.toml

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -4,7 +4,7 @@
44
##############################################################################
55

66
name = "MemoryOS"
7-
version = "2.0.16"
7+
version = "2.0.17"
88
description = "Intelligence Begins with Memory"
99
license = {text = "Apache-2.0"}
1010
readme = "README.md"

src/memos/__init__.py

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,4 @@
1-
__version__ = "2.0.16"
1+
__version__ = "2.0.17"
22

33
from memos.configs.mem_cube import GeneralMemCubeConfig
44
from memos.configs.mem_os import MOSConfig

src/memos/api/handlers/chat_handler.py

Lines changed: 6 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -60,6 +60,7 @@ def __init__(
6060
self,
6161
dependencies: HandlerDependencies,
6262
chat_llms: dict[str, Any],
63+
playground_chat_llms: dict[str, Any] | None = None,
6364
search_handler=None,
6465
add_handler=None,
6566
online_bot=None,
@@ -70,6 +71,7 @@ def __init__(
7071
Args:
7172
dependencies: HandlerDependencies instance
7273
chat_llms: Dictionary mapping model names to LLM instances
74+
playground_chat_llms: Optional model map for /chat/stream/playground
7375
search_handler: Optional SearchHandler instance (created if not provided)
7476
add_handler: Optional AddHandler instance (created if not provided)
7577
online_bot: Optional DingDing bot function for notifications
@@ -89,6 +91,7 @@ def __init__(
8991
add_handler = AddHandler(dependencies)
9092

9193
self.chat_llms = chat_llms
94+
self.playground_chat_llms = playground_chat_llms or chat_llms
9295
self.search_handler = search_handler
9396
self.add_handler = add_handler
9497
self.online_bot = online_bot
@@ -630,10 +633,11 @@ def generate_chat_response() -> Generator[str, None, None]:
630633

631634
# Step 3: Generate streaming response from LLM
632635
try:
633-
model = next(iter(self.chat_llms.keys()))
636+
chat_llms = self.playground_chat_llms
637+
model = next(iter(chat_llms.keys()))
634638
self.logger.info(f"[PLAYGROUND CHAT] Chat Playground Stream Model: {model}")
635639
start = time.time()
636-
response_stream = self.chat_llms[model].generate_stream(
640+
response_stream = chat_llms[model].generate_stream(
637641
current_messages, model_name_or_path=model
638642
)
639643

src/memos/api/handlers/component_init.py

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -157,6 +157,7 @@ def init_server() -> dict[str, Any]:
157157
graph_db_config = build_graph_db_config()
158158
llm_config = build_llm_config()
159159
chat_llm_config = build_chat_llm_config()
160+
playground_chat_llm_config = build_chat_llm_config("PLAYGROUND_CHAT_MODEL_LIST")
160161
embedder_config = build_embedder_config()
161162
nli_client_config = build_nli_client_config()
162163
mem_reader_config = build_mem_reader_config()
@@ -174,6 +175,11 @@ def init_server() -> dict[str, Any]:
174175
if os.getenv("ENABLE_CHAT_API", "false") == "true"
175176
else None
176177
)
178+
playground_chat_llms = (
179+
_init_chat_llms(playground_chat_llm_config)
180+
if os.getenv("ENABLE_CHAT_API", "false") == "true" and playground_chat_llm_config
181+
else chat_llms
182+
)
177183
embedder = EmbedderFactory.from_config(embedder_config)
178184

179185
plugin_context = build_plugin_context(
@@ -317,6 +323,7 @@ def init_server() -> dict[str, Any]:
317323
"mem_reader": mem_reader,
318324
"llm": llm,
319325
"chat_llms": chat_llms,
326+
"playground_chat_llms": playground_chat_llms,
320327
"embedder": embedder,
321328
"reranker": reranker,
322329
"internet_retriever": internet_retriever,

src/memos/api/handlers/config_builders.py

Lines changed: 5 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -85,14 +85,17 @@ def build_llm_config() -> dict[str, Any]:
8585
)
8686

8787

88-
def build_chat_llm_config() -> list[dict[str, Any]]:
88+
def build_chat_llm_config(env_name: str = "CHAT_MODEL_LIST") -> list[dict[str, Any]]:
8989
"""
9090
Build chat LLM configuration.
9191
9292
Returns:
9393
Validated chat LLM configuration dictionary
94+
Args:
95+
env_name: Environment variable that contains the JSON chat model list.
96+
9497
"""
95-
configs = json.loads(os.getenv("CHAT_MODEL_LIST", "[]"))
98+
configs = json.loads(os.getenv(env_name, "[]"))
9699
return [
97100
{
98101
"config_class": LLMConfigFactory.model_validate(

src/memos/api/handlers/search_handler.py

Lines changed: 10 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -23,7 +23,8 @@
2323
from memos.multi_mem_cube.composite_cube import CompositeCubeView
2424
from memos.multi_mem_cube.single_cube import SingleCubeView
2525
from memos.multi_mem_cube.views import MemCubeView
26-
from memos.plugins.hooks import hookable
26+
from memos.plugins.hook_defs import H
27+
from memos.plugins.hooks import hookable, trigger_hook
2728

2829

2930
logger = get_logger(__name__)
@@ -88,7 +89,14 @@ def handle_search_memories(self, search_req: APISearchRequest) -> SearchResponse
8889
# Search and deduplicate
8990
cube_view = self._build_cube_view(search_req_local)
9091
results = cube_view.search_memories(search_req_local)
91-
self._merge_context_recall(results=results, search_req=search_req_local)
92+
hooked_results = trigger_hook(
93+
H.SEARCH_MEMORY_RESULTS,
94+
handler=self,
95+
search_req=search_req_local,
96+
results=results,
97+
)
98+
if hooked_results is not None:
99+
results = hooked_results
92100
if not search_req_local.relativity:
93101
search_req_local.relativity = 0
94102
self.logger.info(f"[SearchHandler] Relativity filter: {search_req_local.relativity}")

src/memos/api/routers/server_router.py

Lines changed: 5 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -76,10 +76,11 @@
7676
add_handler = AddHandler(dependencies)
7777
chat_handler = (
7878
ChatHandler(
79-
dependencies,
80-
components["chat_llms"],
81-
search_handler,
82-
add_handler,
79+
dependencies=dependencies,
80+
chat_llms=components["chat_llms"],
81+
playground_chat_llms=components.get("playground_chat_llms"),
82+
search_handler=search_handler,
83+
add_handler=add_handler,
8384
online_bot=components.get("online_bot"),
8485
)
8586
if os.getenv("ENABLE_CHAT_API", "false") == "true"

src/memos/dream/plugin.py

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -22,6 +22,7 @@
2222
)
2323
from memos.dream.routers.diary_router import create_diary_router
2424
from memos.dream.routers.trigger_router import create_trigger_router
25+
from memos.dream.search import DreamContextSearchExtension
2526
from memos.dream.signal_store import DreamSignalStore
2627
from memos.mem_scheduler.schemas.message_schemas import ScheduleMessageItem
2728
from memos.mem_scheduler.schemas.task_schemas import MEM_DREAM_TASK_LABEL
@@ -50,6 +51,7 @@ def on_load(self) -> None:
5051
self.context: dict[str, Any] = {"shared": {}, "configs": {}}
5152
self.signal_store = DreamSignalStore()
5253
self.heuristic_enricher = DreamHeuristicEnricher()
54+
self.search_extension = DreamContextSearchExtension()
5355
self.pipeline = AbstractDreamPipeline(
5456
context_strategy=DreamContextualizer(),
5557
motive_strategy=MotiveFormation(),
@@ -62,6 +64,7 @@ def on_load(self) -> None:
6264
# Hook registration happens at load time because scheduler-triggered Dream
6365
# execution does not depend on FastAPI route binding.
6466
self.register_hook(H.DREAM_EXECUTE, partial(on_dream_execute, self))
67+
self.register_hook(H.SEARCH_MEMORY_RESULTS, self.search_extension.merge_context_recall)
6568
self.register_hook(H.ADD_AFTER, partial(on_add_signal, self))
6669
self.register_hook(
6770
H.MEMORY_ITEMS_AFTER_FINE_EXTRACT,

src/memos/dream/search.py

Lines changed: 139 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,139 @@
1+
from __future__ import annotations
2+
3+
import logging
4+
5+
from dataclasses import dataclass
6+
from typing import TYPE_CHECKING, Any
7+
8+
from memos.dream.contextualization import CONTEXT_MEMORY_TYPE
9+
10+
11+
if TYPE_CHECKING:
12+
from memos.api.product_models import APISearchRequest
13+
14+
15+
logger = logging.getLogger(__name__)
16+
17+
_DEFAULT_CONTEXT_RECALL_TOP_K = 2
18+
_CONTEXT_RETURN_FIELDS = [
19+
"memory",
20+
"key",
21+
"created_at",
22+
"updated_at",
23+
"source",
24+
"internal_info",
25+
]
26+
27+
28+
@dataclass
29+
class DreamContextSearchExtension:
30+
"""Dream-owned search extension for recalling Context nodes.
31+
32+
The core SearchHandler only exposes a generic plugin hook. This extension
33+
owns Dream-specific retrieval details such as the Context memory type,
34+
graph scope, metadata formatting, and fallback behavior.
35+
"""
36+
37+
top_k: int = _DEFAULT_CONTEXT_RECALL_TOP_K
38+
39+
def merge_context_recall(
40+
self,
41+
*,
42+
handler,
43+
search_req: APISearchRequest,
44+
results: dict[str, Any],
45+
) -> dict[str, Any]:
46+
top_k = max(0, int(self.top_k or 0))
47+
if top_k <= 0:
48+
return results
49+
50+
context_buckets = self._recall_context_buckets(
51+
handler=handler,
52+
search_req=search_req,
53+
top_k=top_k,
54+
)
55+
if context_buckets:
56+
results.setdefault("text_mem", []).extend(context_buckets)
57+
return results
58+
59+
def _recall_context_buckets(
60+
self, *, handler, search_req: APISearchRequest, top_k: int
61+
) -> list[dict[str, Any]]:
62+
graph_db = getattr(handler, "graph_db", None) or getattr(
63+
handler.searcher, "graph_store", None
64+
)
65+
embedder = getattr(handler, "embedder", None) or getattr(handler.searcher, "embedder", None)
66+
if graph_db is None or embedder is None:
67+
logger.info("[Dream Search] Context recall skipped: graph_db or embedder unavailable.")
68+
return []
69+
70+
try:
71+
query_embedding = embedder.embed([search_req.query])[0]
72+
except Exception:
73+
logger.warning("[Dream Search] Context recall embedding failed.", exc_info=True)
74+
return []
75+
76+
buckets: list[dict[str, Any]] = []
77+
for cube_id in _resolve_cube_ids(search_req):
78+
try:
79+
hits = graph_db.search_by_embedding(
80+
query_embedding,
81+
top_k=top_k,
82+
scope=CONTEXT_MEMORY_TYPE,
83+
status="activated",
84+
user_name=cube_id,
85+
return_fields=_CONTEXT_RETURN_FIELDS,
86+
)
87+
except Exception:
88+
logger.warning(
89+
"[Dream Search] Context recall search failed for cube=%s.",
90+
cube_id,
91+
exc_info=True,
92+
)
93+
continue
94+
95+
memories = [_format_context_hit(hit) for hit in hits or [] if hit.get("memory")]
96+
if not memories:
97+
continue
98+
buckets.append(
99+
{
100+
"cube_id": cube_id,
101+
"memories": memories,
102+
"total_nodes": len(memories),
103+
}
104+
)
105+
return buckets
106+
107+
108+
def _resolve_cube_ids(search_req: APISearchRequest) -> list[str]:
109+
if search_req.readable_cube_ids:
110+
return list(dict.fromkeys(search_req.readable_cube_ids))
111+
return [search_req.user_id]
112+
113+
114+
def _format_context_hit(hit: dict[str, Any]) -> dict[str, Any]:
115+
context_id = str(hit.get("id", ""))
116+
score = float(hit.get("score", 0.0) or 0.0)
117+
metadata = {
118+
"id": context_id,
119+
"memory": hit.get("memory", ""),
120+
"memory_type": CONTEXT_MEMORY_TYPE,
121+
"source": hit.get("source") or "dream",
122+
"key": hit.get("key", ""),
123+
"relativity": score,
124+
"score": score,
125+
"embedding": [],
126+
"sources": [],
127+
"usage": [],
128+
"ref_id": f"[{context_id.split('-')[0]}]" if context_id else "[context]",
129+
}
130+
for field in ("created_at", "updated_at", "internal_info"):
131+
if hit.get(field) is not None:
132+
metadata[field] = hit[field]
133+
134+
return {
135+
"id": context_id,
136+
"memory": hit.get("memory", ""),
137+
"metadata": metadata,
138+
"ref_id": metadata["ref_id"],
139+
}

src/memos/mem_reader/read_multi_modal/image_parser.py

Lines changed: 37 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -101,10 +101,43 @@ def parse_fast(
101101
info: dict[str, Any],
102102
**kwargs,
103103
) -> list[TextualMemoryItem]:
104-
"""Parse image_url in fast mode - returns empty list as images need fine mode processing."""
105-
# In fast mode, images are not processed (they need vision models)
106-
# They will be processed in fine mode via process_transfer
107-
return []
104+
"""Parse image_url in fast mode by preserving the source for fine mode."""
105+
if not isinstance(message, dict):
106+
logger.warning(f"[ImageParser] Expected dict, got {type(message)}")
107+
return []
108+
109+
source = self.create_source(message, info)
110+
url = getattr(source, "url", None) or getattr(source, "content", "")
111+
if not url:
112+
logger.warning("[ImageParser] No image URL found in fast mode message")
113+
return []
114+
115+
info_ = info.copy()
116+
user_id = info_.pop("user_id", "")
117+
session_id = info_.pop("session_id", "")
118+
content = f"[image_url]: {url}"
119+
need_emb = kwargs.get("need_emb", True)
120+
121+
return [
122+
TextualMemoryItem(
123+
memory=content,
124+
metadata=TreeNodeTextualMemoryMetadata(
125+
user_id=user_id,
126+
session_id=session_id,
127+
memory_type="UserMemory",
128+
status="activated",
129+
tags=["mode:fast", "multimodal:image"],
130+
key=_derive_key(content),
131+
embedding=self.embedder.embed([content])[0] if need_emb else None,
132+
usage=[],
133+
sources=[source],
134+
background="",
135+
confidence=0.99,
136+
type="fact",
137+
info=info_,
138+
),
139+
)
140+
]
108141

109142
def parse_fine(
110143
self,

0 commit comments

Comments
 (0)