diff --git a/hy_memory/client.py b/hy_memory/client.py
index e9319e9..51249b0 100644
--- a/hy_memory/client.py
+++ b/hy_memory/client.py
@@ -1864,6 +1864,7 @@ class HyMemoryClient:
branch: Optional[str] = None,
request_id: Optional[str] = None,
layers: Optional[List[Any]] = None,
+ statuses: Optional[List[Any]] = None,
embed_override: Optional[Dict[str, Any]] = None,
query_embedding: Optional[List[float]] = None,
rerank: bool = True,
@@ -1956,6 +1957,8 @@ class HyMemoryClient:
layers: 可选。pro/ultra 下只召回指定层级(字符串或
MemoryLayer),在召回阶段生效。lite 下忽略该参数并
强制只召回 L1_RAW。
+ statuses: 仅用于 L1_RAW 定向搜索,可显式包含 active / shadow。
+ 非 raw 搜索或 scene="all" 传入时拒绝,避免静默忽略。
rerank: 默认 True(原行为)。False 时本次调用跳过全部重排阶段:
chat 路的本地 cross-encoder 重排(recall.local_rerank_enabled)
与记忆关系图重排(graph_rerank / episode 扩展),working/
@@ -1999,7 +2002,7 @@ class HyMemoryClient:
intention_limit=intention_limit, created_after=created_after,
reader=reader,
workspace_id=workspace_id, branch=branch,
- request_id=request_id, layers=layers,
+ request_id=request_id, layers=layers, statuses=statuses,
embed_override=embed_override,
query_embedding=query_embedding,
rerank=rerank,
@@ -3387,12 +3390,14 @@ class HyMemoryClient:
request_id: str,
t0: float,
query_embedding: Optional[List[float]],
+ statuses: Optional[List[Any]],
) -> Dict[str, Any]:
"""Implement `search(..., layers=['l1_raw'])` without a second API."""
from .models.memory import MemoryLayer, MemoryStatus
embedding = query_embedding or await self._embed_service.embed(query)
fetch = min(max(limit * (10 if session_ids else 2), limit), 10_000)
+ status_filter = self._normalize_statuses(statuses) or [MemoryStatus.ACTIVE]
hits = await self._vector_store.search(
query_embedding=embedding,
user_ids=user_ids,
@@ -3402,7 +3407,7 @@ class HyMemoryClient:
layers=[MemoryLayer.L1_RAW],
limit=fetch,
score_threshold=min_score,
- status_filter=[MemoryStatus.ACTIVE],
+ status_filter=status_filter,
only_latest=False,
created_after=created_after,
)
@@ -3420,7 +3425,7 @@ class HyMemoryClient:
"memory_id": row.pop("raw_id"),
**row,
"layer": MemoryLayer.L1_RAW.value,
- "status": MemoryStatus.ACTIVE.value,
+ "status": node.status.value,
"score": round(float(hit.get("score", 0.0)), 4),
}
)
@@ -3864,6 +3869,7 @@ class HyMemoryClient:
branch: Optional[str] = None,
request_id: Optional[str] = None,
layers: Optional[List[Any]] = None,
+ statuses: Optional[List[Any]] = None,
embed_override: Optional[Dict[str, Any]] = None,
query_embedding: Optional[List[float]] = None,
rerank: bool = True,
@@ -3893,7 +3899,7 @@ class HyMemoryClient:
schema_min_score=schema_min_score, schema_limit=schema_limit,
intention_limit=intention_limit, created_after=created_after,
reader=reader, workspace_id=workspace_id, branch=branch,
- request_id=request_id, layers=layers,
+ request_id=request_id, layers=layers, statuses=statuses,
query_embedding=query_embedding,
rerank=rerank,
)
@@ -3908,7 +3914,7 @@ class HyMemoryClient:
schema_min_score=schema_min_score, schema_limit=schema_limit,
intention_limit=intention_limit, created_after=created_after,
reader=reader, workspace_id=workspace_id, branch=branch,
- request_id=request_id, layers=layers,
+ request_id=request_id, layers=layers, statuses=statuses,
query_embedding=query_embedding,
rerank=rerank,
)
@@ -3938,6 +3944,7 @@ class HyMemoryClient:
branch: Optional[str] = None,
request_id: Optional[str] = None,
layers: Optional[List[Any]] = None,
+ statuses: Optional[List[Any]] = None,
query_embedding: Optional[List[float]] = None,
rerank: bool = True,
) -> Dict[str, Any]:
@@ -4023,6 +4030,17 @@ class HyMemoryClient:
"Use single agent_id with multiple session_ids, or multiple agent_ids without session_ids."
)
+ if statuses is not None:
+ from .models.memory import MemoryLayer
+
+ requested_layers = self._normalize_layers(layers)
+ if scene_all or (
+ not lite_mode and requested_layers != [MemoryLayer.L1_RAW]
+ ):
+ raise ValueError(
+ "statuses is supported only for explicit L1_RAW search"
+ )
+
if scene_all:
return await self._search_all_scenes(
query, queries=queries, search_mode=search_mode,
@@ -4358,6 +4376,7 @@ class HyMemoryClient:
request_id=request_id,
t0=t0,
query_embedding=query_embedding,
+ statuses=statuses,
)
read_req = ReadRequest(
diff --git a/memhub/apps/core/src/one_memory/api.py b/memhub/apps/core/src/one_memory/api.py
index c2fe6fe..c6d7f21 100644
--- a/memhub/apps/core/src/one_memory/api.py
+++ b/memhub/apps/core/src/one_memory/api.py
@@ -270,6 +270,7 @@ from one_memory.onboarding import (
OnboardingState,
OnboardingStore,
)
+from one_memory.raw_turns import RawDisplayTurn, raw_display_turns
from one_memory.recall import (
GovernedRecallTarget,
RecallReceipt,
@@ -604,24 +605,37 @@ class SdkRawItem(HySdkRawRecord):
role: str | None = None
content_hash: str
created: datetime | None = None
+ turn_id: str | None = Field(default=None, min_length=1, max_length=96)
+ turn_index: int | None = Field(default=None, ge=0)
+ turn_count: int | None = Field(default=None, ge=1)
@classmethod
- def of(cls, record: HySdkRawRecord) -> "SdkRawItem":
+ def of(
+ cls, record: HySdkRawRecord, turn: RawDisplayTurn | None = None
+ ) -> "SdkRawItem":
+ content = turn.content if turn is not None else record.content
+ raw_messages = (
+ turn.messages if turn is not None else raw_turn_messages(record.content)
+ )
messages = tuple(
SdkRawMessage(role=role, content=text)
- for role, text in raw_turn_messages(record.content)
+ for role, text in raw_messages
)
roles = {message.role for message in messages}
return cls(
- **record.model_dump(),
+ **record.model_dump(exclude={"content"}),
+ content=content,
messages=messages,
role=next(iter(roles)) if len(roles) == 1 and "" not in roles else None,
- content_hash="sha256:" + hashlib.sha256(record.content.encode("utf-8")).hexdigest(),
+ content_hash="sha256:" + hashlib.sha256(content.encode("utf-8")).hexdigest(),
created=(
datetime.fromtimestamp(record.gmt_created, tz=UTC)
if record.gmt_created is not None
else None
),
+ turn_id=turn.turn_id if turn is not None else None,
+ turn_index=turn.turn_index if turn is not None else None,
+ turn_count=turn.turn_count if turn is not None else None,
)
@@ -634,9 +648,9 @@ class SdkRawPage(BaseModel):
total: int = Field(ge=0)
offset: int = Field(ge=0)
limit: int = Field(ge=1, le=500)
- #: Set when a time filter was applied to this page in Core (the SDK list
- #: has none): `items` is the page's rows inside the range, `total` stays
- #: the SDK's count before that filter.
+ unit: Literal["raw", "turn"] = "raw"
+ #: Set when a time filter was applied in Core. For storage `raw`, `total`
+ #: remains the SDK count; for product `turn`, it is the filtered turn count.
time_filtered: bool = False
@@ -4441,6 +4455,16 @@ def create_app(
raise _sdk_http_error(error, "hy_memory_delete_failed") from None
return Response(status_code=204)
+ raw_turn_cache: dict[
+ tuple[object, ...],
+ tuple[
+ int,
+ tuple[tuple[str, str | None], ...],
+ tuple[tuple[HySdkRawRecord, RawDisplayTurn], ...],
+ ],
+ ] = {}
+ raw_turn_cache_locks: dict[tuple[object, ...], asyncio.Lock] = {}
+
@app.get(
"/api/v2/raw",
response_model=SdkRawPage,
@@ -4450,6 +4474,7 @@ def create_app(
source: list[str] | None = Query(default=None), # noqa: B008
offset: int = Query(default=0, ge=0),
limit: int = Query(default=100, ge=1, le=500),
+ unit: Literal["raw", "turn"] = Query(default="raw"),
user_id: str = Query(default=DEFAULT_USER_ID, pattern=USER_ID_PATTERN),
exclude_sources: list[str] | None = Query(default=None), # noqa: B008
session_id: list[str] | None = Query(default=None), # noqa: B008
@@ -4463,7 +4488,10 @@ def create_app(
The SDK lists raw only when asked for `layers=["l1_raw"]`, and only
active rows unless told otherwise. Pro shadows a raw once extraction
succeeds, so a raw page reads active and shadow (`statuses` narrows
- it). `source` and `session_id` (a conversation) may repeat or be comma
+ it). `unit=turn` projects storage batches into stable product turns;
+ the first read builds an append-only in-memory index and later polls
+ only probe the latest SDK page unless it changed. `source` and
+ `session_id` (a conversation) may repeat or be comma
separated. `after`/`before` (unix seconds, against the conversation
time, else the stored time) have no SDK list filter: they cut this
page in Core and mark it `time_filtered`.
@@ -4477,24 +4505,145 @@ def create_app(
sessions = split_csv(session_id)
try:
client = await _sdk_client_handle(app).client()
- value = await asyncio.to_thread(
- client.list_memories,
- user_id=user_id,
+ query = {
+ "user_id": user_id,
**raw_list_filters(chosen_statuses),
- include_sources=sources or None,
- exclude_sources=excluded or None,
- session_ids=sessions or None,
- offset=offset,
- limit=limit,
- order=order,
- )
- page = HySdkRawPage.from_memory_list(value, limit=limit, offset=offset)
+ "include_sources": sources or None,
+ "exclude_sources": excluded or None,
+ "session_ids": sessions or None,
+ "order": order if unit == "raw" else "desc",
+ }
+ if unit == "raw":
+ value = await asyncio.to_thread(
+ client.list_memories,
+ **query,
+ offset=offset,
+ limit=limit,
+ )
+ page = HySdkRawPage.from_memory_list(value, limit=limit, offset=offset)
+ records = page.raw
+ else:
+ cache_key = (
+ user_id,
+ tuple(sorted(chosen_statuses)),
+ tuple(sorted(sources)),
+ tuple(sorted(excluded)),
+ tuple(sorted(sessions)),
+ )
+ if len(raw_turn_cache_locks) > 64:
+ for stale_key, stale_lock in tuple(raw_turn_cache_locks.items()):
+ if (
+ stale_key not in raw_turn_cache
+ and not stale_lock.locked()
+ ):
+ raw_turn_cache_locks.pop(stale_key, None)
+ lock = raw_turn_cache_locks.setdefault(cache_key, asyncio.Lock())
+ async with lock:
+ for _attempt in range(3):
+ value = await asyncio.to_thread(
+ client.list_memories,
+ **query,
+ offset=0,
+ limit=500,
+ )
+ first_page = HySdkRawPage.from_memory_list(
+ value, limit=500, offset=0
+ )
+ signature = tuple(
+ (record.raw_id, record.status) for record in first_page.raw
+ )
+ cached = raw_turn_cache.get(cache_key)
+ if (
+ cached is not None
+ and cached[0] == first_page.total
+ and cached[1] == signature
+ ):
+ projected_turns = cached[2]
+ break
+
+ records_list = list(first_page.raw)
+ raw_offset = len(first_page.raw)
+ raw_total = first_page.total
+ while first_page.raw and raw_offset < raw_total:
+ value = await asyncio.to_thread(
+ client.list_memories,
+ **query,
+ offset=raw_offset,
+ limit=500,
+ )
+ raw_page = HySdkRawPage.from_memory_list(
+ value, limit=500, offset=raw_offset
+ )
+ records_list.extend(raw_page.raw)
+ raw_offset += len(raw_page.raw)
+ if not raw_page.raw:
+ break
+
+ verify_value = await asyncio.to_thread(
+ client.list_memories,
+ **query,
+ offset=0,
+ limit=500,
+ )
+ verify = HySdkRawPage.from_memory_list(
+ verify_value, limit=500, offset=0
+ )
+ verify_signature = tuple(
+ (record.raw_id, record.status) for record in verify.raw
+ )
+ if (
+ verify.total != first_page.total
+ or verify_signature != signature
+ ):
+ continue
+ projected_turns = tuple(
+ (record, turn)
+ for record in records_list
+ for turn in reversed(raw_display_turns(record))
+ )
+ raw_turn_cache[cache_key] = (
+ first_page.total,
+ signature,
+ projected_turns,
+ )
+ while len(raw_turn_cache) > 32:
+ oldest = next(iter(raw_turn_cache))
+ raw_turn_cache.pop(oldest)
+ old_lock = raw_turn_cache_locks.get(oldest)
+ if old_lock is not None and not old_lock.locked():
+ raw_turn_cache_locks.pop(oldest, None)
+ break
+ else:
+ raise RuntimeError("hy_memory_raw_snapshot_busy")
+ if cache_key not in raw_turn_cache and not lock.locked():
+ raw_turn_cache_locks.pop(cache_key, None)
except HTTPException:
raise
except Exception as error:
raise _sdk_http_error(error, "hy_memory_raw_list_failed") from None
- records = page.raw
time_filtered = after is not None or before is not None
+ if unit == "turn":
+ turns = projected_turns
+ if time_filtered:
+ turns = tuple(
+ (record, turn)
+ for record, turn in turns
+ if (at := record.memory_at or record.gmt_created) is not None
+ and (after is None or at >= after)
+ and (before is None or at < before)
+ )
+ if order == "asc":
+ turns = tuple(reversed(turns))
+ selected = turns[offset : offset + limit]
+ return SdkRawPage(
+ user_id=user_id,
+ items=tuple(SdkRawItem.of(record, turn) for record, turn in selected),
+ total=len(turns),
+ offset=offset,
+ limit=limit,
+ unit="turn",
+ time_filtered=time_filtered,
+ )
if time_filtered:
records = tuple(
record
@@ -4509,6 +4658,7 @@ def create_app(
total=page.total,
offset=offset,
limit=limit,
+ unit="raw",
time_filtered=time_filtered,
)
diff --git a/memhub/apps/core/src/one_memory/context_models.py b/memhub/apps/core/src/one_memory/context_models.py
index afef2bc..3d0b5a4 100644
--- a/memhub/apps/core/src/one_memory/context_models.py
+++ b/memhub/apps/core/src/one_memory/context_models.py
@@ -66,6 +66,8 @@ class SdkContextSearchRequest(ContextSearchRequest, SdkSearchOptions):
limit: int = Field(default=10, ge=1, le=20, strict=True)
schema_limit: int = Field(default=3, ge=0, le=20, strict=True)
intention_limit: int = Field(default=1, ge=0, le=20, strict=True)
+ #: Product callers can request one item per turn instead of SDK raw batches.
+ raw_unit: Literal["raw", "turn"] = "raw"
#: Whose memories to search. The App never sends it and reads `local`.
user_id: UserId = DEFAULT_USER_ID
@@ -123,6 +125,9 @@ class SdkRawContextItem(BaseModel):
memory_at: datetime | None = None
score: float = Field(ge=0, le=1)
truncated: bool = False
+ turn_id: str | None = Field(default=None, min_length=1, max_length=96)
+ turn_index: int | None = Field(default=None, ge=0)
+ turn_count: int | None = Field(default=None, ge=1)
class SdkMemoryContextItem(BaseModel):
diff --git a/memhub/apps/core/src/one_memory/context_routes.py b/memhub/apps/core/src/one_memory/context_routes.py
index da27e6d..8518858 100644
--- a/memhub/apps/core/src/one_memory/context_routes.py
+++ b/memhub/apps/core/src/one_memory/context_routes.py
@@ -30,6 +30,7 @@ from one_memory.hy_sdk_types import (
parse_sdk_search,
)
from one_memory.imports.storage import EvidenceStore
+from one_memory.raw_turns import raw_display_turns
from one_memory.sdk_search_options import SdkSearchFilters, SearchLane
from one_memory.semantic_deadline import SemanticMemoryHit
from one_memory.user_scope import owns
@@ -120,6 +121,9 @@ def sdk_context_router(dependencies: Sequence[Depends]) -> APIRouter:
sources = raw_lane_sources(authority.raw_sources, value.include_sources)
if sources is None:
return ()
+ candidate_limit = (
+ min(value.limit * 10, 200) if value.raw_unit == "turn" else value.limit
+ )
raw = await asyncio.to_thread(
client.search,
value.query,
@@ -127,7 +131,7 @@ def sdk_context_router(dependencies: Sequence[Depends]) -> APIRouter:
"user_ids": [value.user_id],
"agent_ids": [],
"include_sources": sources,
- "limit": value.limit,
+ "limit": candidate_limit,
"min_score": 0.0,
"schema_min_score": 0.0,
"schema_limit": 0,
@@ -135,27 +139,59 @@ def sdk_context_router(dependencies: Sequence[Depends]) -> APIRouter:
"scene": "chat",
"reader": HY_MEMORY_READER,
"layers": ["l1_raw"],
+ "statuses": ["active", "shadow"],
**raw_lane_overrides(value),
},
)
hits = tuple(
hit for hit in parse_sdk_raw_search(raw) if value.passes_floor("raw", hit.score)
)
- return tuple(
- SdkRawContextItem(
- raw_id=hit.raw_id,
- content=hit.content,
- source=hit.source,
- session_id=hit.session_id,
- memory_at=(
- datetime.fromtimestamp(hit.memory_at, tz=UTC)
- if hit.memory_at is not None
- else None
- ),
- score=hit.score,
- )
- for hit in hits
- )
+ items: list[SdkRawContextItem] = []
+ for hit in hits:
+ turns = raw_display_turns(hit) if value.raw_unit == "turn" else ()
+ if turns:
+ query = value.query.casefold()
+ lexical = tuple(turn for turn in turns if query in turn.content.casefold())
+ # A batch-level embedding cannot identify which contained
+ # turn matched semantically. Return only turns grounded by
+ # the query text instead of assigning every sibling the
+ # batch score and showing unrelated conversations.
+ for turn in lexical:
+ items.append(
+ SdkRawContextItem(
+ raw_id=hit.raw_id,
+ turn_id=turn.turn_id,
+ turn_index=turn.turn_index,
+ turn_count=turn.turn_count,
+ content=turn.content,
+ source=hit.source,
+ session_id=hit.session_id,
+ memory_at=(
+ datetime.fromtimestamp(hit.memory_at, tz=UTC)
+ if hit.memory_at is not None
+ else None
+ ),
+ score=hit.score,
+ )
+ )
+ else:
+ items.append(
+ SdkRawContextItem(
+ raw_id=hit.raw_id,
+ content=hit.content,
+ source=hit.source,
+ session_id=hit.session_id,
+ memory_at=(
+ datetime.fromtimestamp(hit.memory_at, tz=UTC)
+ if hit.memory_at is not None
+ else None
+ ),
+ score=hit.score,
+ )
+ )
+ if len(items) >= value.limit:
+ break
+ return tuple(items[: value.limit])
async def memory_lane() -> tuple[SdkMemoryContextItem, ...]:
# lite extracts nothing: its search answers raw turns, which the
diff --git a/memhub/apps/core/src/one_memory/raw_turns.py b/memhub/apps/core/src/one_memory/raw_turns.py
new file mode 100644
index 0000000..990b1d9
--- /dev/null
+++ b/memhub/apps/core/src/one_memory/raw_turns.py
@@ -0,0 +1,62 @@
+"""Turn-level read projection for SDK raw batches.
+
+HY keeps several complete conversation turns in one L1 raw node so model
+writes stay batched. Product readers must not expose that storage batch as
+one conversation item: this module gives every contained turn a stable
+identity while retaining the parent raw id for traceability.
+"""
+
+from __future__ import annotations
+
+import hashlib
+from dataclasses import dataclass
+
+from one_memory.hy_sdk_types import HySdkRawRecord, raw_turn_messages
+
+
+@dataclass(frozen=True)
+class RawDisplayTurn:
+ turn_id: str
+ turn_index: int
+ turn_count: int
+ content: str
+ messages: tuple[tuple[str, str], ...]
+
+
+def raw_display_turns(record: HySdkRawRecord) -> tuple[RawDisplayTurn, ...]:
+ """Split one stored raw batch into user-opened turns.
+
+ A user message opens a new turn; assistant/tool/system messages stay with
+ that user message until the next user message. Legacy content with no user
+ marker remains one turn so it never disappears from the product.
+ """
+ messages = raw_turn_messages(record.content)
+ grouped: list[list[tuple[str, str]]] = []
+ for message in messages:
+ role, _ = message
+ has_user = bool(grouped) and any(item[0] == "user" for item in grouped[-1])
+ if not grouped or (role == "user" and has_user):
+ grouped.append([message])
+ else:
+ grouped[-1].append(message)
+ if not grouped:
+ grouped = [[("", record.content)]]
+
+ digest = hashlib.sha256(record.raw_id.encode("utf-8")).hexdigest()
+ count = len(grouped)
+ return tuple(
+ RawDisplayTurn(
+ turn_id=f"raw-turn:{digest}:{index}",
+ turn_index=index,
+ turn_count=count,
+ content=_turn_content(turn),
+ messages=tuple(turn),
+ )
+ for index, turn in enumerate(grouped)
+ )
+
+
+def _turn_content(messages: list[tuple[str, str]]) -> str:
+ return "\n".join(
+ f"[{role}]: {content}" if role else content for role, content in messages
+ )
diff --git a/memhub/apps/core/tests/test_sdk_api_surface.py b/memhub/apps/core/tests/test_sdk_api_surface.py
index a907328..4b14e07 100644
--- a/memhub/apps/core/tests/test_sdk_api_surface.py
+++ b/memhub/apps/core/tests/test_sdk_api_surface.py
@@ -42,7 +42,10 @@ RAW_ROWS = {
"local": [
{
"memory_id": "raw-1",
- "content": "[user]: 我叫费天翔\n[assistant]: 好的,记住了。",
+ "content": (
+ "[user]: 我叫费天翔\n[assistant]: 好的,记住了。"
+ "\n[user]: 我喜欢绿茶\n[assistant]: 也记住了。"
+ ),
"status": "shadow",
"session_id": "s-1",
"source": "hermes",
@@ -347,6 +350,33 @@ def test_every_context_search_option_reaches_the_sdk_and_absent_ones_keep_the_ol
run_with_sdk(tmp_path, monkeypatch, scenario)
+@pytest.mark.fresh_sdk
+def test_raw_context_search_can_return_turns_instead_of_storage_batches(
+ tmp_path, monkeypatch
+) -> None:
+ async def scenario(app, sdk, client):
+ response = await client.post(
+ "/api/v2/context/search",
+ json={
+ "query": "绿茶",
+ "kinds": ["raw"],
+ "raw_unit": "turn",
+ "limit": 20,
+ },
+ )
+ assert response.status_code == 200, response.text
+ raw_one = [
+ item for item in response.json()["items"] if item["raw_id"] == "raw-1"
+ ]
+ assert len(raw_one) == 1
+ assert raw_one[0]["turn_index"] == 1
+ assert "我喜欢绿茶" in raw_one[0]["content"]
+ assert "我叫费天翔" not in raw_one[0]["content"]
+ assert sdk.last("search")["statuses"] == ["active", "shadow"]
+
+ run_with_sdk(tmp_path, monkeypatch, scenario)
+
+
@pytest.mark.fresh_sdk
def test_every_recall_and_ask_option_reaches_the_sdk(tmp_path, monkeypatch) -> None:
async def scenario(app, sdk, client):
@@ -574,7 +604,12 @@ def test_raw_lists_active_and_shadow_turns_with_roles_and_hashes(tmp_path, monke
assert sent["layers"] == ["l1_raw"] and sent["statuses"] == ["active", "shadow"]
first = next(item for item in body["items"] if item["raw_id"] == "raw-1")
assert first["status"] == "shadow"
- assert [message["role"] for message in first["messages"]] == ["user", "assistant"]
+ assert [message["role"] for message in first["messages"]] == [
+ "user",
+ "assistant",
+ "user",
+ "assistant",
+ ]
assert first["messages"][0]["content"] == "我叫费天翔"
assert first["role"] is None
expected = hashlib.sha256(first["content"].encode()).hexdigest()
@@ -584,6 +619,27 @@ def test_raw_lists_active_and_shadow_turns_with_roles_and_hashes(tmp_path, monke
second = next(item for item in body["items"] if item["raw_id"] == "raw-2")
assert second["role"] == "user"
+ turns = (await client.get("/api/v2/raw", params={"unit": "turn"})).json()
+ assert turns["unit"] == "turn" and turns["total"] == 3
+ first_turns = [item for item in turns["items"] if item["raw_id"] == "raw-1"]
+ assert [item["turn_index"] for item in first_turns] == [1, 0]
+ assert len({item["turn_id"] for item in first_turns}) == 2
+ assert [[message["content"] for message in item["messages"]] for item in first_turns] == [
+ ["我喜欢绿茶", "也记住了。"],
+ ["我叫费天翔", "好的,记住了。"],
+ ]
+ calls_before_refresh = len(sdk.all("list_memories"))
+ await client.get("/api/v2/raw", params={"unit": "turn"})
+ assert len(sdk.all("list_memories")) == calls_before_refresh + 1
+
+ ascending = (
+ await client.get("/api/v2/raw", params={"unit": "turn", "order": "asc"})
+ ).json()
+ ascending_first = [
+ item for item in ascending["items"] if item["raw_id"] == "raw-1"
+ ]
+ assert [item["turn_index"] for item in ascending_first] == [0, 1]
+
await client.get(
"/api/v2/raw",
params=[("source", "hermes,codex"), ("session_id", "s-1"), ("order", "asc")],
diff --git a/memhub/apps/web/src/MemorySpace.r6.test.tsx b/memhub/apps/web/src/MemorySpace.r6.test.tsx
index 15a14fc..19b4131 100644
--- a/memhub/apps/web/src/MemorySpace.r6.test.tsx
+++ b/memhub/apps/web/src/MemorySpace.r6.test.tsx
@@ -387,6 +387,67 @@ it("pages and filters the raw conversation data plane in lite", async () => {
);
});
+it("selects and renders one turn when two turns share a raw storage batch", async () => {
+ setup();
+ reads.processing.mockResolvedValue(modeProcessing("lite"));
+ reads.raw.mockResolvedValue({
+ items: [
+ {
+ raw_id: "raw-batch",
+ turn_id: "raw-turn:batch:0",
+ turn_index: 0,
+ turn_count: 2,
+ content: "[user]: 第一问\n[assistant]: 第一答",
+ user_id: "local",
+ agent_id: "all-apps",
+ source: "cursor",
+ session_id: "session-batch",
+ memory_at: null,
+ gmt_created: 1_761_000_000,
+ custom: {},
+ messages: [
+ { role: "user", content: "第一问" },
+ { role: "assistant", content: "第一答" },
+ ],
+ },
+ {
+ raw_id: "raw-batch",
+ turn_id: "raw-turn:batch:1",
+ turn_index: 1,
+ turn_count: 2,
+ content: "[user]: 第二问\n[assistant]: 第二答",
+ user_id: "local",
+ agent_id: "all-apps",
+ source: "cursor",
+ session_id: "session-batch",
+ memory_at: null,
+ gmt_created: 1_761_000_000,
+ custom: {},
+ messages: [
+ { role: "user", content: "第二问" },
+ { role: "assistant", content: "第二答" },
+ ],
+ },
+ ],
+ total: 2,
+ limit: 20,
+ offset: 0,
+ unit: "turn",
+ });
+ const user = userEvent.setup();
+ render();
+
+ await user.click(await screen.findByRole("button", { name: /第二问/ }));
+
+ await waitFor(() => {
+ const detail = document.querySelector(".map-dialogue-reading");
+ expect(detail?.textContent).toContain("第二问");
+ expect(detail?.textContent).toContain("第二答");
+ expect(detail?.textContent).not.toContain("第一问");
+ expect(detail?.textContent).not.toContain("第一答");
+ });
+});
+
it("switches data planes without keeping rows or details from the prior mode", async () => {
setup();
vi.useFakeTimers();
@@ -482,6 +543,27 @@ it("opens a search hit with the complete role transcript", async () => {
items: [
{
raw_id: "raw-search",
+ turn_id: "raw-turn:search:0",
+ turn_index: 0,
+ turn_count: 2,
+ content: "[user]: 第一轮问题\n[assistant]: 第一轮回答",
+ user_id: "local",
+ agent_id: "all-apps",
+ source: "cursor",
+ session_id: "session-search",
+ memory_at: null,
+ gmt_created: 1_761_000_000,
+ custom: {},
+ messages: [
+ { role: "user", content: "第一轮问题" },
+ { role: "assistant", content: "第一轮回答" },
+ ],
+ },
+ {
+ raw_id: "raw-search",
+ turn_id: "raw-turn:search:1",
+ turn_index: 1,
+ turn_count: 2,
content: "[user]: 完整问题\n[assistant]: 完整回答",
user_id: "local",
agent_id: "all-apps",
@@ -496,7 +578,7 @@ it("opens a search hit with the complete role transcript", async () => {
],
},
],
- total: 1,
+ total: 2,
limit: 500,
offset: 0,
}
@@ -506,6 +588,9 @@ it("opens a search hit with the complete role transcript", async () => {
{
kind: "raw",
raw_id: "raw-search",
+ turn_id: "raw-turn:search:1",
+ turn_index: 1,
+ turn_count: 2,
content: "被截断的搜索摘要",
source: "cursor",
session_id: "session-search",
@@ -525,6 +610,9 @@ it("opens a search hit with the complete role transcript", async () => {
expect(await screen.findByText("完整问题")).toBeTruthy();
expect(screen.getByText("完整回答")).toBeTruthy();
+ expect(
+ document.querySelector(".map-dialogue-reading")?.textContent,
+ ).not.toContain("第一轮问题");
expect(reads.raw).toHaveBeenCalledWith(
expect.objectContaining({
source: "cursor",
diff --git a/memhub/apps/web/src/MemorySpace.tsx b/memhub/apps/web/src/MemorySpace.tsx
index f0a7595..4be8d00 100644
--- a/memhub/apps/web/src/MemorySpace.tsx
+++ b/memhub/apps/web/src/MemorySpace.tsx
@@ -61,6 +61,7 @@ import {
type RawMapRecord,
rawDetail,
rawLibraryPage,
+ rawRecordId,
rawSearchPage,
} from "./sdk-raw-map";
import { sourceNameOr, UNATTRIBUTED_SOURCE_ID } from "./source-label";
@@ -277,8 +278,11 @@ export function MemorySpace({
const rememberRaw = (items: RawMapRecord[]) => {
const next = new Map();
if (selectedRawRef.current)
- next.set(selectedRawRef.current.raw_id, selectedRawRef.current);
- for (const item of items) next.set(item.raw_id, item);
+ next.set(
+ rawRecordId(selectedRawRef.current),
+ selectedRawRef.current,
+ );
+ for (const item of items) next.set(rawRecordId(item), item);
rawRecords.current = next;
};
const [page, graph] = lite
@@ -717,7 +721,9 @@ export function MemorySpace({
offset,
limit: 500,
});
- const full = page.items.find((item) => item.raw_id === memoryId);
+ const full = page.items.find(
+ (item) => rawRecordId(item) === memoryId,
+ );
if (full) {
record = full;
rawRecords.current.set(memoryId, full);
diff --git a/memhub/apps/web/src/SdkRawHistory.tsx b/memhub/apps/web/src/SdkRawHistory.tsx
index c59af4b..b0aac72 100644
--- a/memhub/apps/web/src/SdkRawHistory.tsx
+++ b/memhub/apps/web/src/SdkRawHistory.tsx
@@ -3,6 +3,7 @@ import { type FormEvent, useEffect, useRef, useState } from "react";
import { getSdkRaw, searchSdkRaw } from "./api";
import { useGuiLanguage } from "./gui-language";
import { subscribePoll } from "./poll-scheduler";
+import { rawRecordId } from "./sdk-raw-map";
export function SdkRawHistory({ active = true }: { active?: boolean }) {
const { locale, t, tn } = useGuiLanguage();
@@ -121,7 +122,7 @@ export function SdkRawHistory({ active = true }: { active?: boolean }) {
) : null}
{items.map((item) => (
- -
+
-
{item.content}
{item.source} · {item.session_id}
@@ -140,6 +141,9 @@ function asContextItem(item: SdkRawRecord): SdkRawContextItem {
return {
kind: "raw",
raw_id: item.raw_id,
+ turn_id: item.turn_id,
+ turn_index: item.turn_index,
+ turn_count: item.turn_count,
content: item.content,
source: item.source,
session_id: item.session_id,
diff --git a/memhub/apps/web/src/SelectedRawDetail.tsx b/memhub/apps/web/src/SelectedRawDetail.tsx
index b24d357..fc55a37 100644
--- a/memhub/apps/web/src/SelectedRawDetail.tsx
+++ b/memhub/apps/web/src/SelectedRawDetail.tsx
@@ -5,7 +5,7 @@ import type {
} from "@one-memory/contracts";
import { clockStamp } from "./clock-stamp";
import { useGuiLanguage } from "./gui-language";
-import { rawRecordTime } from "./sdk-raw-map";
+import { rawRecordId, rawRecordTime } from "./sdk-raw-map";
import { sourceNameOr, UNATTRIBUTED_SOURCE_ID } from "./source-label";
type RawRecord = SdkRawRecord | SdkRawContextItem;
@@ -50,7 +50,7 @@ export function SelectedRawDetail({
{t("memory:原始对话")}
{messages.map((message) => (
- -
+
-
{authorLabel(message.role, t)}
{message.content}
diff --git a/memhub/apps/web/src/api.raw.test.ts b/memhub/apps/web/src/api.raw.test.ts
index f6d9257..0bd9db9 100644
--- a/memhub/apps/web/src/api.raw.test.ts
+++ b/memhub/apps/web/src/api.raw.test.ts
@@ -32,6 +32,7 @@ it("lists raw conversations for the selected application", async () => {
session_id: undefined,
offset: 20,
limit: 20,
+ unit: "turn",
},
signal,
});
@@ -49,6 +50,7 @@ it("keeps the application filter when searching raw conversations", async () =>
query: "release plan",
kinds: ["raw"],
include_sources: ["claude-code"],
+ raw_unit: "turn",
limit: 20,
character_budget: 12_000,
},
diff --git a/memhub/apps/web/src/api.ts b/memhub/apps/web/src/api.ts
index d866095..74d2538 100644
--- a/memhub/apps/web/src/api.ts
+++ b/memhub/apps/web/src/api.ts
@@ -986,16 +986,18 @@ export async function getSdkRaw({
sessionId,
offset = 0,
limit = 100,
+ unit = "turn",
signal,
}: {
source?: string;
sessionId?: string;
offset?: number;
limit?: number;
+ unit?: "raw" | "turn";
signal?: AbortSignal;
} = {}): Promise {
const response = await client.get("/api/v2/raw", {
- params: { source, session_id: sessionId, offset, limit },
+ params: { source, session_id: sessionId, offset, limit, unit },
signal,
});
return response.data;
@@ -1011,6 +1013,7 @@ export async function searchSdkRaw(
query,
kinds: ["raw"],
...(options.source ? { include_sources: [options.source] } : {}),
+ raw_unit: "turn",
limit: 20,
character_budget: 12_000,
},
diff --git a/memhub/apps/web/src/sdk-raw-map.test.ts b/memhub/apps/web/src/sdk-raw-map.test.ts
index a912b27..b9860df 100644
--- a/memhub/apps/web/src/sdk-raw-map.test.ts
+++ b/memhub/apps/web/src/sdk-raw-map.test.ts
@@ -76,4 +76,28 @@ describe("raw map projection", () => {
history: [],
});
});
+
+ it("uses a turn identity while retaining the parent raw id", () => {
+ const turn = {
+ ...record,
+ raw_id: "raw-batch",
+ turn_id: "raw-turn:batch:1",
+ turn_index: 1,
+ turn_count: 2,
+ content: "[user]: second question\n[assistant]: second answer",
+ messages: [
+ { role: "user", content: "second question" },
+ { role: "assistant", content: "second answer" },
+ ],
+ };
+
+ expect(
+ rawLibraryPage({ items: [turn], total: 2, offset: 0, limit: 20 }),
+ ).toMatchObject({
+ items: [{ memory_id: "raw-turn:batch:1", content: turn.content }],
+ source_ids_by_memory: { "raw-turn:batch:1": ["cursor"] },
+ });
+ expect(rawDetail(turn).memory.memory_id).toBe("raw-turn:batch:1");
+ expect(turn.raw_id).toBe("raw-batch");
+ });
});
diff --git a/memhub/apps/web/src/sdk-raw-map.ts b/memhub/apps/web/src/sdk-raw-map.ts
index 4cdc81f..bb70418 100644
--- a/memhub/apps/web/src/sdk-raw-map.ts
+++ b/memhub/apps/web/src/sdk-raw-map.ts
@@ -9,6 +9,10 @@ import type {
export type RawMapRecord = SdkRawRecord | SdkRawContextItem;
+export function rawRecordId(record: RawMapRecord): string {
+ return record.turn_id || record.raw_id;
+}
+
const EARLIEST_PLAUSIBLE = Date.UTC(2000, 0, 1);
const LATEST_PLAUSIBLE = () => Date.now() + 86_400_000;
@@ -43,7 +47,7 @@ function sourceOf(record: RawMapRecord): string {
export function rawMapMemory(record: RawMapRecord): GovernedMemory {
const created = rawRecordTime(record) ?? "";
return {
- memory_id: record.raw_id,
+ memory_id: rawRecordId(record),
user_id: "user_id" in record ? record.user_id : "local",
kind: "chat",
status: "confirmed",
@@ -75,17 +79,18 @@ function pageFor(
{ at: string; basis: "conversation" | "import" }
> = {};
for (const record of records) {
- sourceIds[record.raw_id] = [sourceOf(record)];
+ const recordId = rawRecordId(record);
+ sourceIds[recordId] = [sourceOf(record)];
const conversationAt = isoTime(record.memory_at);
const importedAt =
"gmt_created" in record ? isoTime(record.gmt_created) : "";
if (conversationAt)
- sourceTime[record.raw_id] = {
+ sourceTime[recordId] = {
at: conversationAt,
basis: "conversation",
};
else if (importedAt)
- sourceTime[record.raw_id] = { at: importedAt, basis: "import" };
+ sourceTime[recordId] = { at: importedAt, basis: "import" };
}
return {
items: records.map(rawMapMemory),
diff --git a/memhub/docs/contracts/memory-mode-evolve-v1.md b/memhub/docs/contracts/memory-mode-evolve-v1.md
index ec2a698..138d30b 100644
--- a/memhub/docs/contracts/memory-mode-evolve-v1.md
+++ b/memhub/docs/contracts/memory-mode-evolve-v1.md
@@ -12,7 +12,7 @@ memory route. `user_id` is optional (default `local`) and must match
| Mode | SDK client | What happens to a conversation | What search reads |
|---|---|---|---|
-| `lite` | `mode="lite"` | Stored as one raw turn with one embedding. No model call; no generation model needs to be configured. | Raw turns only (the SDK forces `L1_RAW`). |
+| `lite` | `mode="lite"` | Stored in embedded L1 raw batches. No model call; no generation model needs to be configured. The product page projects each batch back into individual conversation turns. | Raw turns only (the SDK forces `L1_RAW`). |
| `pro` (default) | `mode="pro"` | Stored as raw, then a model extracts memories (System 1). The raw becomes SHADOW once extraction succeeds. | Extracted memories (facts, profile, intentions). |
| `ultra` | `mode="ultra"` | Same as pro, and new facts are also projected into the knowledge graph. Every night, evolve runs System 2 for every user. | Same as pro, plus the graph-backed lanes below. |
diff --git a/memhub/packages/contracts/src/context.ts b/memhub/packages/contracts/src/context.ts
index 32abbea..4386893 100644
--- a/memhub/packages/contracts/src/context.ts
+++ b/memhub/packages/contracts/src/context.ts
@@ -87,7 +87,12 @@ export interface ContextSearchResponse {
export interface SdkRawContextItem {
kind: "raw";
+ /** Parent SDK L1 raw storage batch. */
raw_id: string;
+ /** Stable product identity of one turn inside `raw_id`, when requested. */
+ turn_id?: string | null;
+ turn_index?: number | null;
+ turn_count?: number | null;
content: string;
source: string;
session_id: string;
diff --git a/memhub/packages/contracts/src/index.ts b/memhub/packages/contracts/src/index.ts
index 4011d7e..dd86494 100644
--- a/memhub/packages/contracts/src/index.ts
+++ b/memhub/packages/contracts/src/index.ts
@@ -675,7 +675,12 @@ export interface SdkUserProfileResponse {
}
export interface SdkRawRecord {
+ /** Parent SDK L1 raw storage batch. */
raw_id: string;
+ /** Stable product identity of one turn inside `raw_id`, when unit=turn. */
+ turn_id?: string | null;
+ turn_index?: number | null;
+ turn_count?: number | null;
content: string;
user_id: string;
agent_id: string;
@@ -703,6 +708,7 @@ export interface SdkRawPage {
total: number;
limit: number;
offset: number;
+ unit?: "raw" | "turn";
/** A time range was applied to this page in Core (the SDK list has none). */
time_filtered?: boolean;
}
diff --git a/tests/test_raw_public_api.py b/tests/test_raw_public_api.py
index 3619bfb..4fd3db9 100644
--- a/tests/test_raw_public_api.py
+++ b/tests/test_raw_public_api.py
@@ -27,6 +27,7 @@ class RawStore:
exclude_sources,
layers,
limit,
+ status_filter,
**_kwargs,
):
rows = []
@@ -41,11 +42,18 @@ class RawStore:
continue
if node.layer not in layers:
continue
+ if node.status not in status_filter:
+ continue
rows.append({"node": node, "score": 0.9})
return rows[:limit]
-def raw_node(raw_id: str, session_id: str, source: str) -> MemoryNode:
+def raw_node(
+ raw_id: str,
+ session_id: str,
+ source: str,
+ status: MemoryStatus = MemoryStatus.ACTIVE,
+) -> MemoryNode:
return MemoryNode(
node_id=raw_id,
user_id="u1",
@@ -55,7 +63,7 @@ def raw_node(raw_id: str, session_id: str, source: str) -> MemoryNode:
layer=MemoryLayer.L1_RAW,
content=f"content {raw_id}",
source_type=SourceType.EXPLICIT,
- status=MemoryStatus.ACTIVE,
+ status=status,
is_latest=True,
embedding=[0.1, 0.2],
)
@@ -102,6 +110,7 @@ async def test_existing_search_contract_can_explicitly_search_raw():
request_id="search-1",
t0=0.0,
query_embedding=[0.1, 0.2],
+ statuses=None,
)
assert [item["memory_id"] for item in result["memories"]["normal"]] == [
@@ -109,3 +118,33 @@ async def test_existing_search_contract_can_explicitly_search_raw():
]
assert result["memories"]["normal"][0]["layer"] == "l1_raw"
assert result["memories"]["normal"][0]["source"] == "claude"
+
+
+@pytest.mark.asyncio
+async def test_explicit_raw_search_can_include_shadow_rows():
+ client = HyMemoryClient.__new__(HyMemoryClient)
+ client._vector_store = RawStore(
+ [raw_node("raw-shadow", "s1", "cursor", MemoryStatus.SHADOW)]
+ )
+ client._embed_service = AsyncMock()
+ client._embed_service.embed.return_value = [0.1, 0.2]
+
+ result = await HyMemoryClient._search_explicit_raw_layer.__wrapped__(
+ client,
+ "content",
+ user_ids=["u1"],
+ agent_ids=["a1"],
+ session_ids=[],
+ include_sources=[],
+ exclude_sources=[],
+ limit=5,
+ min_score=0.0,
+ created_after=None,
+ request_id="search-shadow",
+ t0=0.0,
+ query_embedding=[0.1, 0.2],
+ statuses=["active", "shadow"],
+ )
+
+ assert result["memories"]["normal"][0]["memory_id"] == "raw-shadow"
+ assert result["memories"]["normal"][0]["status"] == "shadow"