Compare commits
2
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
c4da617d59 | ||
|
|
729e699eab |
No files matched your search
+15
-15
@@ -41,13 +41,13 @@ Hy Memory and MemHub are versioned and released independently.
|
||||
## Unreleased
|
||||
|
||||
- Removed MemHub's private `hy_memory_bridge` implementation. MemHub now calls
|
||||
public `HyMemoryClient` methods through explicit runtime, write, migration,
|
||||
and read modules; prompt/reconcile/response monkey-patches and private
|
||||
VDB/Coding access were deleted. Kuzu Graph projection/admin remains the one
|
||||
documented product exception.
|
||||
public `HyMemoryClient` methods at runtime; prompt/reconcile/response
|
||||
monkey-patches and private VDB/Coding runtime access were deleted. Kuzu Graph
|
||||
projection/admin and stopped-writer App migration are the documented product
|
||||
exceptions.
|
||||
- Extended public SDK `get()` to resolve Chat, Coding Experience, and Working
|
||||
Fact IDs, including owner/session fields and explicit ambiguity handling;
|
||||
added constructor-time public embedding-service injection.
|
||||
Fact IDs, including raw records, owner/session fields, and explicit ambiguity
|
||||
handling; added constructor-time public embedding-service injection.
|
||||
- Added a MemHub-managed native Hermes MemoryProvider with automatic prefetch,
|
||||
persistent per-session memory-ID deduplication, authorized Broker reads, and
|
||||
recall-context stripping to prevent memory recursion.
|
||||
@@ -58,13 +58,12 @@ Hy Memory and MemHub are versioned and released independently.
|
||||
- `search(..., layers=["l1_raw"])` now explicitly searches SDK raw records in
|
||||
pro/ultra mode and keeps existing user/agent/session/source filters; default
|
||||
search continues to hide raw.
|
||||
- Added `import_memory_exact` for idempotent migration of an existing semantic
|
||||
memory with its canonical ID, lifecycle and supersession links, without
|
||||
extraction or reconciliation.
|
||||
- Added SDK-authoritative lifecycle listing with explicit status filters,
|
||||
idempotent durable write outcomes, exact raw import/get/list APIs, and
|
||||
quiesced local data-plane snapshot/restore contracts for the MemHub
|
||||
authority migration.
|
||||
idempotent durable write outcomes, and quiesced local data-plane
|
||||
snapshot/restore contracts for the MemHub authority migration. Exact legacy
|
||||
backfill is now an App-owned privileged offline operation rather than a
|
||||
public `HyMemoryClient` API. Backfill runs against a complete staging copy;
|
||||
cutover atomically swaps SDK roots and restores the previous root on failure.
|
||||
- Physical delete now reports incomplete VDB/Graph cleanup as a partial failure
|
||||
instead of claiming success after only one store was changed.
|
||||
- Coding search no longer injects the version-evolution diff block
|
||||
@@ -87,9 +86,10 @@ Hy Memory and MemHub are versioned and released independently.
|
||||
retry scheduling for their memory tasks.
|
||||
- Clarified that `add(metadata=...)` carries legacy SDK processing options,
|
||||
while persistent caller business context belongs in `meta`.
|
||||
- Added an opt-in `get(..., include_raw=True)` path for reading `L1_RAW`
|
||||
memories, including memories written in lite mode. The default remains
|
||||
hidden for backward compatibility.
|
||||
- Unified exact reads and lists across memory layers: `get(memory_id)` returns
|
||||
the matched layer, while `list_memories(layers=["l1_raw"])` explicitly lists
|
||||
raw records. Separate raw get/list methods and the `include_raw` switch were
|
||||
removed.
|
||||
- Removed MemHub's task-scoped HyPermission store wrappers and global
|
||||
model-content consent switch. The trusted embedded Hy Memory SDK now uses
|
||||
its native local stores directly.
|
||||
|
||||
+7
-6
@@ -273,8 +273,8 @@ catalog 也可以在没有基础 L0 时形成只读的合成画像。
|
||||
|
||||
```python
|
||||
memory = client.get("memory_id")
|
||||
# lite 模式写入的是 L1_RAW;按 ID 读取时必须显式选择返回原文。
|
||||
raw_memory = client.get("memory_id", include_raw=True)
|
||||
# raw ID 返回 L1_RAW,fact ID 返回对应事实层。
|
||||
raw_memory = client.get("raw_id")
|
||||
result = client.update("memory_id", "更新后的内容")
|
||||
result = client.delete("memory_id")
|
||||
result = client.delete_all(user_id="user_001")
|
||||
@@ -284,7 +284,7 @@ page = client.list_memories(
|
||||
limit=100,
|
||||
offset=0,
|
||||
order="desc",
|
||||
layers=None,
|
||||
layers=["l1_raw"], # 显式列出 raw;不传仍沿用默认语义层列表
|
||||
with_evolution=False,
|
||||
)
|
||||
|
||||
@@ -294,9 +294,10 @@ result = client.clone_user("user_001", "user_002", agent_id=None)
|
||||
对应异步方法为 `async_get()`、`async_update()`、`async_delete()`、
|
||||
`async_delete_all()`、`async_list_memories()`、`async_clone_user()`。
|
||||
|
||||
`get()` / `async_get()` 默认继续隐藏 `L1_RAW`,因此不会改变现有调用方的原文访问
|
||||
边界。只有显式传入 `include_raw=True` 才返回该层;这也是 lite 模式按 ID 读取
|
||||
写入内容及其 `meta` 的方式。MemHub 的 HTTP 读取接口不开放此参数。
|
||||
`get()` / `async_get()` 是统一的 ID 精确读取:返回结果的 `layer`、`kind` 和
|
||||
`variant` 表明实际类型。授权由调用 SDK 前的产品边界完成,不由 SDK 根据 layer
|
||||
猜测。`list_memories()` 默认仍不包含 `L1_RAW`;显式传
|
||||
`layers=["l1_raw"]` 时按同一列表合同返回 raw。
|
||||
|
||||
普通 `list_memories()` 返回 `vdb` 桶,并在 Graph 有结果时附带 `graph`;传入
|
||||
`workspace_id` 时返回生产力记忆的 `scene="productivity"` 和 `experiences`。
|
||||
|
||||
+63
-440
@@ -1886,7 +1886,6 @@ class HyMemoryClient:
|
||||
self,
|
||||
memory_id: str,
|
||||
*,
|
||||
include_raw: bool = False,
|
||||
kind: Optional[MemoryKind] = None,
|
||||
variant: Optional[MemoryVariant] = None,
|
||||
) -> Optional[Dict[str, Any]]:
|
||||
@@ -1895,8 +1894,6 @@ class HyMemoryClient:
|
||||
|
||||
Args:
|
||||
memory_id: 记忆 ID
|
||||
include_raw: 是否允许返回 L1_RAW 原始记忆。默认 False,保持原有隐藏行为;
|
||||
lite 模式的记忆均为 L1_RAW,需显式设为 True 才能按 ID 读取。
|
||||
kind: 可选 namespace("chat" / "experience" / "fact")。
|
||||
variant: 可选生产力变体("working" / "coding")。
|
||||
|
||||
@@ -1906,7 +1903,6 @@ class HyMemoryClient:
|
||||
return self._loop_thread.run(
|
||||
self.async_get(
|
||||
memory_id,
|
||||
include_raw=include_raw,
|
||||
kind=kind,
|
||||
variant=variant,
|
||||
)
|
||||
@@ -1978,6 +1974,9 @@ class HyMemoryClient:
|
||||
branch: Optional[str] = None,
|
||||
user_ids: Optional[List[str]] = None,
|
||||
agent_ids: Optional[List[str]] = None,
|
||||
session_ids: Optional[List[str]] = None,
|
||||
include_sources: Optional[List[str]] = None,
|
||||
exclude_sources: Optional[List[str]] = None,
|
||||
layers: Optional[List[Any]] = None,
|
||||
statuses: Optional[List[Any]] = None,
|
||||
with_evolution: bool = False,
|
||||
@@ -2000,6 +1999,9 @@ class HyMemoryClient:
|
||||
user_ids: 可选,多用户批量列举。非空时优先于 user_id,返回并集
|
||||
(分页在合并排序后统一施加)。
|
||||
agent_ids: 可选,多 agent 批量列举。非空时优先于 agent_id。
|
||||
session_ids: 可选,只返回指定 session。
|
||||
include_sources: 可选,只返回指定来源。
|
||||
exclude_sources: 可选,排除指定来源;与 include_sources 冲突时排除优先。
|
||||
layers: 可选,只返回指定层级(字符串或 MemoryLayer)。不传时返回
|
||||
L1_RAW 以外的全部层级,与改动前一致。
|
||||
statuses: 可选,只返回指定生命周期(字符串或 MemoryStatus)。不传时
|
||||
@@ -2015,6 +2017,9 @@ class HyMemoryClient:
|
||||
self.async_list_memories(
|
||||
user_id=user_id, agent_id=agent_id,
|
||||
user_ids=user_ids, agent_ids=agent_ids,
|
||||
session_ids=session_ids,
|
||||
include_sources=include_sources,
|
||||
exclude_sources=exclude_sources,
|
||||
limit=limit, offset=offset, order=order,
|
||||
workspace_id=workspace_id, branch=branch,
|
||||
layers=layers, statuses=statuses, with_evolution=with_evolution,
|
||||
@@ -2975,106 +2980,6 @@ class HyMemoryClient:
|
||||
include_runtime_diag=True,
|
||||
)
|
||||
|
||||
def import_raw(
|
||||
self,
|
||||
content: str,
|
||||
*,
|
||||
user_id: str,
|
||||
agent_id: str = "default_agent",
|
||||
session_id: str = "default_session",
|
||||
source: str = "default",
|
||||
memory_at: Optional[datetime] = None,
|
||||
metadata: Optional[Dict[str, Any]] = None,
|
||||
raw_id: Optional[str] = None,
|
||||
request_id: Optional[str] = None,
|
||||
) -> Dict[str, Any]:
|
||||
"""Persist one exact raw record without extraction or reconciliation."""
|
||||
return self._loop_thread.run(
|
||||
self.async_import_raw(
|
||||
content,
|
||||
user_id=user_id,
|
||||
agent_id=agent_id,
|
||||
session_id=session_id,
|
||||
source=source,
|
||||
memory_at=memory_at,
|
||||
metadata=metadata,
|
||||
raw_id=raw_id,
|
||||
request_id=request_id,
|
||||
),
|
||||
include_runtime_diag=True,
|
||||
)
|
||||
|
||||
def import_memory_exact(
|
||||
self,
|
||||
content: str,
|
||||
*,
|
||||
memory_id: str,
|
||||
user_id: str,
|
||||
agent_id: str = "default_agent",
|
||||
session_id: str = "default_session",
|
||||
source: str = "default",
|
||||
layer: Any = "l2_fact",
|
||||
status: Any = "active",
|
||||
owner: Optional[str] = None,
|
||||
tags: Optional[List[str]] = None,
|
||||
supersedes: Optional[List[str]] = None,
|
||||
superseded_by: Optional[List[str]] = None,
|
||||
is_latest: bool = True,
|
||||
memory_at: Optional[datetime] = None,
|
||||
metadata: Optional[Dict[str, Any]] = None,
|
||||
request_id: Optional[str] = None,
|
||||
) -> Dict[str, Any]:
|
||||
"""Import one existing semantic memory without extraction/reconcile."""
|
||||
return self._loop_thread.run(
|
||||
self.async_import_memory_exact(
|
||||
content,
|
||||
memory_id=memory_id,
|
||||
user_id=user_id,
|
||||
agent_id=agent_id,
|
||||
session_id=session_id,
|
||||
source=source,
|
||||
layer=layer,
|
||||
status=status,
|
||||
owner=owner,
|
||||
tags=tags,
|
||||
supersedes=supersedes,
|
||||
superseded_by=superseded_by,
|
||||
is_latest=is_latest,
|
||||
memory_at=memory_at,
|
||||
metadata=metadata,
|
||||
request_id=request_id,
|
||||
),
|
||||
include_runtime_diag=True,
|
||||
)
|
||||
|
||||
def get_raw(self, raw_id: str) -> Optional[Dict[str, Any]]:
|
||||
"""Read one SDK raw record through the explicit raw API."""
|
||||
return self._loop_thread.run(self.async_get_raw(raw_id))
|
||||
|
||||
def list_raw(
|
||||
self,
|
||||
*,
|
||||
user_id: str,
|
||||
agent_id: Optional[str] = None,
|
||||
session_id: Optional[str] = None,
|
||||
source: Optional[str] = None,
|
||||
limit: int = 100,
|
||||
offset: int = 0,
|
||||
order: str = "desc",
|
||||
) -> Dict[str, Any]:
|
||||
"""List raw records for an authorized SDK owner/scope."""
|
||||
return self._loop_thread.run(
|
||||
self.async_list_raw(
|
||||
user_id=user_id,
|
||||
agent_id=agent_id,
|
||||
session_id=session_id,
|
||||
source=source,
|
||||
limit=limit,
|
||||
offset=offset,
|
||||
order=order,
|
||||
)
|
||||
)
|
||||
|
||||
@_ensure_internal_loop
|
||||
async def async_add_extracted(
|
||||
self,
|
||||
@@ -3227,316 +3132,6 @@ class HyMemoryClient:
|
||||
}
|
||||
|
||||
@_ensure_internal_loop
|
||||
async def async_import_raw(
|
||||
self,
|
||||
content: str,
|
||||
*,
|
||||
user_id: str,
|
||||
agent_id: str = "default_agent",
|
||||
session_id: str = "default_session",
|
||||
source: str = "default",
|
||||
memory_at: Optional[datetime] = None,
|
||||
metadata: Optional[Dict[str, Any]] = None,
|
||||
raw_id: Optional[str] = None,
|
||||
request_id: Optional[str] = None,
|
||||
) -> Dict[str, Any]:
|
||||
"""Persist exact source content as L1_RAW without running a model."""
|
||||
from .utils.source_filter import normalize_source
|
||||
|
||||
if not isinstance(content, str) or not content:
|
||||
raise ValueError("content is required")
|
||||
if not user_id:
|
||||
raise ValueError("user_id is required")
|
||||
request_id = request_id or str(uuid.uuid4())
|
||||
replay = await self._claim_write_outcome(
|
||||
request_id=request_id,
|
||||
user_id=user_id,
|
||||
agent_id=agent_id,
|
||||
session_id=session_id,
|
||||
operation="import_raw",
|
||||
)
|
||||
if replay is not None:
|
||||
return replay
|
||||
try:
|
||||
result = await self._async_import_raw_impl(
|
||||
content,
|
||||
user_id=user_id,
|
||||
agent_id=agent_id,
|
||||
session_id=session_id,
|
||||
source=normalize_source(source),
|
||||
memory_at=memory_at,
|
||||
metadata=metadata,
|
||||
raw_id=raw_id,
|
||||
request_id=request_id,
|
||||
)
|
||||
except BaseException:
|
||||
await self._mark_write_outcome_unknown(request_id)
|
||||
raise
|
||||
await self._finish_write_outcome(request_id, "succeeded", result)
|
||||
return result
|
||||
|
||||
async def _async_import_raw_impl(
|
||||
self,
|
||||
content: str,
|
||||
*,
|
||||
user_id: str,
|
||||
agent_id: str,
|
||||
session_id: str,
|
||||
source: str,
|
||||
memory_at: Optional[datetime],
|
||||
metadata: Optional[Dict[str, Any]],
|
||||
raw_id: Optional[str],
|
||||
request_id: str,
|
||||
) -> Dict[str, Any]:
|
||||
from .models.memory import MemoryLayer, MemoryNode, MemoryStatus, SourceType
|
||||
|
||||
node_id = raw_id or str(uuid.uuid4())
|
||||
existing = await self._vector_store.get_by_id(node_id)
|
||||
if existing is not None:
|
||||
identity = (
|
||||
existing.layer == MemoryLayer.L1_RAW,
|
||||
existing.content == content,
|
||||
existing.user_id == user_id,
|
||||
existing.agent_id == agent_id,
|
||||
existing.session_id == session_id,
|
||||
existing.source == source,
|
||||
)
|
||||
if not all(identity):
|
||||
raise ValueError("raw_id is already bound to different source content")
|
||||
return {
|
||||
"success": True,
|
||||
"raw_id": node_id,
|
||||
"request_id": request_id,
|
||||
"replayed": True,
|
||||
}
|
||||
|
||||
custom = dict(metadata or {})
|
||||
custom["request_id"] = request_id
|
||||
custom["input_kind"] = "raw_exact_import"
|
||||
embedding = await self._embed_service.embed(content)
|
||||
node = MemoryNode(
|
||||
node_id=node_id,
|
||||
user_id=user_id,
|
||||
agent_id=agent_id,
|
||||
session_id=session_id,
|
||||
source=source,
|
||||
layer=MemoryLayer.L1_RAW,
|
||||
content=content,
|
||||
source_type=SourceType.EXPLICIT,
|
||||
status=MemoryStatus.ACTIVE,
|
||||
is_latest=True,
|
||||
embedding=embedding,
|
||||
memory_at=memory_at,
|
||||
custom=custom,
|
||||
)
|
||||
stored_id = await self._vector_store.upsert(node)
|
||||
if stored_id != node_id:
|
||||
raise RuntimeError("raw import changed the requested identity")
|
||||
return {"success": True, "raw_id": node_id, "request_id": request_id}
|
||||
|
||||
@_ensure_internal_loop
|
||||
async def async_import_memory_exact(
|
||||
self,
|
||||
content: str,
|
||||
*,
|
||||
memory_id: str,
|
||||
user_id: str,
|
||||
agent_id: str = "default_agent",
|
||||
session_id: str = "default_session",
|
||||
source: str = "default",
|
||||
layer: Any = "l2_fact",
|
||||
status: Any = "active",
|
||||
owner: Optional[str] = None,
|
||||
tags: Optional[List[str]] = None,
|
||||
supersedes: Optional[List[str]] = None,
|
||||
superseded_by: Optional[List[str]] = None,
|
||||
is_latest: bool = True,
|
||||
memory_at: Optional[datetime] = None,
|
||||
metadata: Optional[Dict[str, Any]] = None,
|
||||
request_id: Optional[str] = None,
|
||||
) -> Dict[str, Any]:
|
||||
"""Import a canonical memory node exactly, with no model invocation."""
|
||||
from .models.memory import MemoryLayer, MemoryStatus
|
||||
from .utils.source_filter import normalize_source
|
||||
|
||||
if not content or not memory_id or not user_id:
|
||||
raise ValueError("content, memory_id and user_id are required")
|
||||
normalized_layer = (
|
||||
layer if isinstance(layer, MemoryLayer) else MemoryLayer.from_string(str(layer))
|
||||
)
|
||||
if normalized_layer == MemoryLayer.L1_RAW:
|
||||
raise ValueError("use import_raw for L1_RAW")
|
||||
normalized_status = (
|
||||
status if isinstance(status, MemoryStatus) else MemoryStatus(str(status).lower())
|
||||
)
|
||||
request_id = request_id or str(uuid.uuid4())
|
||||
replay = await self._claim_write_outcome(
|
||||
request_id=request_id,
|
||||
user_id=user_id,
|
||||
agent_id=agent_id,
|
||||
session_id=session_id,
|
||||
operation="import_memory_exact",
|
||||
)
|
||||
if replay is not None:
|
||||
return replay
|
||||
try:
|
||||
result = await self._async_import_memory_exact_impl(
|
||||
content,
|
||||
memory_id=memory_id,
|
||||
user_id=user_id,
|
||||
agent_id=agent_id,
|
||||
session_id=session_id,
|
||||
source=normalize_source(source),
|
||||
layer=normalized_layer,
|
||||
status=normalized_status,
|
||||
owner=owner,
|
||||
tags=tags or [],
|
||||
supersedes=supersedes or [],
|
||||
superseded_by=superseded_by or [],
|
||||
is_latest=is_latest,
|
||||
memory_at=memory_at,
|
||||
metadata=metadata,
|
||||
request_id=request_id,
|
||||
)
|
||||
except BaseException:
|
||||
await self._mark_write_outcome_unknown(request_id)
|
||||
raise
|
||||
await self._finish_write_outcome(request_id, "succeeded", result)
|
||||
return result
|
||||
|
||||
async def _async_import_memory_exact_impl(
|
||||
self,
|
||||
content: str,
|
||||
*,
|
||||
memory_id: str,
|
||||
user_id: str,
|
||||
agent_id: str,
|
||||
session_id: str,
|
||||
source: str,
|
||||
layer,
|
||||
status,
|
||||
owner: Optional[str],
|
||||
tags: List[str],
|
||||
supersedes: List[str],
|
||||
superseded_by: List[str],
|
||||
is_latest: bool,
|
||||
memory_at: Optional[datetime],
|
||||
metadata: Optional[Dict[str, Any]],
|
||||
request_id: str,
|
||||
) -> Dict[str, Any]:
|
||||
from .models.memory import MemoryNode, SourceType
|
||||
|
||||
existing = await self._vector_store.get_by_id(memory_id)
|
||||
if existing is not None:
|
||||
identity = (
|
||||
existing.content == content,
|
||||
existing.user_id == user_id,
|
||||
existing.agent_id == agent_id,
|
||||
existing.session_id == session_id,
|
||||
existing.source == source,
|
||||
existing.layer == layer,
|
||||
existing.status == status,
|
||||
existing.owner == owner,
|
||||
list(existing.tags or []) == tags,
|
||||
list(existing.supersedes or []) == supersedes,
|
||||
list(existing.superseded_by or []) == superseded_by,
|
||||
existing.is_latest == is_latest,
|
||||
)
|
||||
if not all(identity):
|
||||
raise ValueError("memory_id is already bound to different memory content")
|
||||
return {
|
||||
"success": True,
|
||||
"memory_id": memory_id,
|
||||
"memory_ids": [memory_id],
|
||||
"request_id": request_id,
|
||||
"replayed": True,
|
||||
}
|
||||
|
||||
custom = dict(metadata or {})
|
||||
custom["request_id"] = request_id
|
||||
custom["input_kind"] = "memory_exact_import"
|
||||
embedding = await self._embed_service.embed(content)
|
||||
node = MemoryNode(
|
||||
node_id=memory_id,
|
||||
user_id=user_id,
|
||||
agent_id=agent_id,
|
||||
session_id=session_id,
|
||||
source=source,
|
||||
layer=layer,
|
||||
content=content,
|
||||
source_type=SourceType.EXPLICIT,
|
||||
status=status,
|
||||
owner=owner,
|
||||
tags=list(tags),
|
||||
supersedes=list(supersedes),
|
||||
superseded_by=list(superseded_by),
|
||||
is_latest=is_latest,
|
||||
embedding=embedding,
|
||||
memory_at=memory_at,
|
||||
custom=custom,
|
||||
)
|
||||
stored_id = await self._vector_store.upsert(node)
|
||||
if stored_id != memory_id:
|
||||
raise RuntimeError("exact memory import changed the requested identity")
|
||||
return {
|
||||
"success": True,
|
||||
"memory_id": memory_id,
|
||||
"memory_ids": [memory_id],
|
||||
"request_id": request_id,
|
||||
}
|
||||
|
||||
@_ensure_internal_loop
|
||||
async def async_get_raw(self, raw_id: str) -> Optional[Dict[str, Any]]:
|
||||
from .models.memory import MemoryLayer
|
||||
|
||||
node = await self._vector_store.get_by_id(raw_id)
|
||||
if node is None or node.layer != MemoryLayer.L1_RAW:
|
||||
return None
|
||||
return self._raw_node_to_public(node)
|
||||
|
||||
@_ensure_internal_loop
|
||||
async def async_list_raw(
|
||||
self,
|
||||
*,
|
||||
user_id: str,
|
||||
agent_id: Optional[str] = None,
|
||||
session_id: Optional[str] = None,
|
||||
source: Optional[str] = None,
|
||||
limit: int = 100,
|
||||
offset: int = 0,
|
||||
order: str = "desc",
|
||||
) -> Dict[str, Any]:
|
||||
from .models.memory import MemoryLayer, MemoryStatus
|
||||
|
||||
if not user_id:
|
||||
raise ValueError("user_id is required")
|
||||
if limit < 1 or offset < 0:
|
||||
raise ValueError("limit must be positive and offset must be non-negative")
|
||||
nodes = await self._vector_store.list_by_user(
|
||||
user_id=user_id,
|
||||
agent_id=agent_id,
|
||||
status_filter=[MemoryStatus.ACTIVE],
|
||||
layers=[MemoryLayer.L1_RAW],
|
||||
include_sources=[source] if source else None,
|
||||
limit=100_000,
|
||||
only_latest=False,
|
||||
with_vectors=False,
|
||||
allow_raw_layer=True,
|
||||
)
|
||||
if session_id:
|
||||
nodes = [node for node in nodes if node.session_id == session_id]
|
||||
nodes = self._sort_memory_nodes(nodes, order=order)
|
||||
total = len(nodes)
|
||||
return {
|
||||
"raw": [
|
||||
self._raw_node_to_public(node)
|
||||
for node in nodes[offset: offset + limit]
|
||||
],
|
||||
"total": total,
|
||||
"limit": limit,
|
||||
"offset": offset,
|
||||
}
|
||||
|
||||
async def _search_explicit_raw_layer(
|
||||
self,
|
||||
query: str,
|
||||
@@ -4500,7 +4095,6 @@ class HyMemoryClient:
|
||||
self,
|
||||
memory_id: str,
|
||||
*,
|
||||
include_raw: bool = False,
|
||||
kind: Optional[MemoryKind] = None,
|
||||
variant: Optional[MemoryVariant] = None,
|
||||
) -> Optional[Dict[str, Any]]:
|
||||
@@ -4513,15 +4107,12 @@ class HyMemoryClient:
|
||||
|
||||
Args:
|
||||
memory_id: 记忆 ID
|
||||
include_raw: 是否允许返回 L1_RAW 原始记忆,默认 False。
|
||||
kind: 可选 namespace("chat" / "experience" / "fact")。
|
||||
variant: 可选生产力变体("working" / "coding")。
|
||||
|
||||
Returns:
|
||||
统一 memory dict,或 None。
|
||||
"""
|
||||
from .models.memory import MemoryLayer
|
||||
|
||||
if kind not in (None, "chat", "experience", "fact"):
|
||||
raise ValueError(
|
||||
"kind must be one of: chat, experience, fact"
|
||||
@@ -4583,26 +4174,6 @@ class HyMemoryClient:
|
||||
vdb_node = by_name.get("chat:vdb")
|
||||
graph_node = by_name.get("chat:graph")
|
||||
|
||||
# L1_RAW 默认不对外可见:按 ID 直取走主键查询、绕过向量库的 layer
|
||||
# filter,故在此统一挡掉。SDK 调用方可通过 include_raw=True 显式读取;
|
||||
# HTTP 接口不传该参数,继续保持隐藏。内部写链路不经过此方法。
|
||||
#
|
||||
# 置于 gather 之后、埋点之前:命中 raw 仍照常落 GET 埋点(保留可观测性,
|
||||
# 便于发现有调用方拿 raw id 试探),只是不把内容返回给调用方。
|
||||
# 对调用方的表现与"该 id 不存在"完全一致,不额外暴露"存在但被挡"的信息。
|
||||
if (
|
||||
not include_raw
|
||||
and vdb_node is not None
|
||||
and vdb_node.layer == MemoryLayer.L1_RAW
|
||||
):
|
||||
vdb_node = None
|
||||
if (
|
||||
not include_raw
|
||||
and graph_node is not None
|
||||
and getattr(graph_node, "layer", None) == MemoryLayer.L1_RAW
|
||||
):
|
||||
graph_node = None
|
||||
|
||||
matches = []
|
||||
chat_node = vdb_node if vdb_node is not None else graph_node
|
||||
if chat_node is not None:
|
||||
@@ -4682,7 +4253,8 @@ class HyMemoryClient:
|
||||
"confidence": vdb_node.confidence,
|
||||
"custom": vdb_node.custom or {},
|
||||
"meta": list((vdb_node.custom or {}).get("meta") or []),
|
||||
"source": "vdb",
|
||||
"source": vdb_node.source,
|
||||
"retrieval_source": "vdb",
|
||||
"request_id": request_id,
|
||||
"elapsed_ms": elapsed,
|
||||
}
|
||||
@@ -4709,7 +4281,8 @@ class HyMemoryClient:
|
||||
"owner": getattr(graph_node, "owner", None),
|
||||
"confidence": graph_node.confidence,
|
||||
"custom": getattr(graph_node, "custom", {}) or {},
|
||||
"source": "graph",
|
||||
"source": getattr(graph_node, "source", "") or "",
|
||||
"retrieval_source": "graph",
|
||||
"request_id": request_id,
|
||||
"elapsed_ms": elapsed,
|
||||
}
|
||||
@@ -5282,6 +4855,7 @@ class HyMemoryClient:
|
||||
"user_id": node.user_id,
|
||||
"agent_id": node.agent_id,
|
||||
"session_id": getattr(node, "session_id", None),
|
||||
"source": getattr(node, "source", None),
|
||||
"speculate": getattr(node, "speculate", None),
|
||||
# 不透出 source_raw_memory_id:见 async_search 同处说明。
|
||||
"tags": node.tags or [],
|
||||
@@ -5353,6 +4927,9 @@ class HyMemoryClient:
|
||||
order: str,
|
||||
user_ids: Optional[List[str]] = None,
|
||||
agent_ids: Optional[List[str]] = None,
|
||||
session_ids: Optional[List[str]] = None,
|
||||
include_sources: Optional[List[str]] = None,
|
||||
exclude_sources: Optional[List[str]] = None,
|
||||
layers: Optional[List] = None,
|
||||
statuses: Optional[List] = None,
|
||||
with_evolution: bool = False,
|
||||
@@ -5378,6 +4955,9 @@ class HyMemoryClient:
|
||||
status_filter=effective_statuses,
|
||||
layers=layers,
|
||||
with_vectors=False,
|
||||
allow_raw_layer=bool(
|
||||
layers is not None and MemoryLayer.L1_RAW in layers
|
||||
),
|
||||
)
|
||||
|
||||
# L0 是 user-level,不应被 agent 过滤。默认列表或显式包含 L0 时,额外按
|
||||
@@ -5399,6 +4979,22 @@ class HyMemoryClient:
|
||||
])
|
||||
all_nodes.extend(hit["node"] for hit in l0_hits if hit.get("node") is not None)
|
||||
|
||||
if session_ids:
|
||||
wanted_sessions = set(session_ids)
|
||||
all_nodes = [
|
||||
node for node in all_nodes if node.session_id in wanted_sessions
|
||||
]
|
||||
if include_sources:
|
||||
wanted_sources = set(include_sources)
|
||||
all_nodes = [
|
||||
node for node in all_nodes if node.source in wanted_sources
|
||||
]
|
||||
if exclude_sources:
|
||||
blocked_sources = set(exclude_sources)
|
||||
all_nodes = [
|
||||
node for node in all_nodes if node.source not in blocked_sources
|
||||
]
|
||||
|
||||
all_nodes = self._sort_memory_nodes(all_nodes, order=order)
|
||||
total = len(all_nodes)
|
||||
page_nodes = all_nodes[offset: offset + limit]
|
||||
@@ -5462,6 +5058,9 @@ class HyMemoryClient:
|
||||
order: str,
|
||||
user_ids: Optional[List[str]] = None,
|
||||
agent_ids: Optional[List[str]] = None,
|
||||
session_ids: Optional[List[str]] = None,
|
||||
include_sources: Optional[List[str]] = None,
|
||||
exclude_sources: Optional[List[str]] = None,
|
||||
layers: Optional[List] = None,
|
||||
statuses: Optional[List] = None,
|
||||
) -> Optional[Dict[str, Any]]:
|
||||
@@ -5509,6 +5108,21 @@ class HyMemoryClient:
|
||||
node for node in graph_nodes
|
||||
if getattr(node, "status", None) in effective_statuses
|
||||
]
|
||||
if session_ids:
|
||||
wanted_sessions = set(session_ids)
|
||||
graph_nodes = [
|
||||
node for node in graph_nodes if node.session_id in wanted_sessions
|
||||
]
|
||||
if include_sources:
|
||||
wanted_sources = set(include_sources)
|
||||
graph_nodes = [
|
||||
node for node in graph_nodes if node.source in wanted_sources
|
||||
]
|
||||
if exclude_sources:
|
||||
blocked_sources = set(exclude_sources)
|
||||
graph_nodes = [
|
||||
node for node in graph_nodes if node.source not in blocked_sources
|
||||
]
|
||||
|
||||
graph_nodes = self._sort_memory_nodes(graph_nodes, order=order)
|
||||
total = len(graph_nodes)
|
||||
@@ -5620,6 +5234,9 @@ class HyMemoryClient:
|
||||
branch: Optional[str] = None,
|
||||
user_ids: Optional[List[str]] = None,
|
||||
agent_ids: Optional[List[str]] = None,
|
||||
session_ids: Optional[List[str]] = None,
|
||||
include_sources: Optional[List[str]] = None,
|
||||
exclude_sources: Optional[List[str]] = None,
|
||||
layers: Optional[List[Any]] = None,
|
||||
statuses: Optional[List[Any]] = None,
|
||||
with_evolution: bool = False,
|
||||
@@ -5696,6 +5313,9 @@ class HyMemoryClient:
|
||||
vdb = await self._list_vdb_bucket(
|
||||
user_id=user_id, agent_id=agent_id,
|
||||
user_ids=user_ids, agent_ids=agent_ids,
|
||||
session_ids=session_ids,
|
||||
include_sources=include_sources,
|
||||
exclude_sources=exclude_sources,
|
||||
limit=limit, offset=offset, order=order,
|
||||
layers=effective_layers, statuses=effective_statuses,
|
||||
with_evolution=with_evolution,
|
||||
@@ -5703,6 +5323,9 @@ class HyMemoryClient:
|
||||
graph = await self._list_graph_bucket(
|
||||
user_id=user_id, agent_id=agent_id,
|
||||
user_ids=user_ids, agent_ids=agent_ids,
|
||||
session_ids=session_ids,
|
||||
include_sources=include_sources,
|
||||
exclude_sources=exclude_sources,
|
||||
limit=limit, offset=offset, order=order,
|
||||
layers=effective_layers, statuses=effective_statuses,
|
||||
)
|
||||
|
||||
+3
-1
@@ -9,7 +9,9 @@
|
||||
## Architecture Principles
|
||||
|
||||
1. Memory 读写与存储 backend **只经 SDK**(config / env)。
|
||||
2. MemHub 对 memory 的原子操作 **只经 SDK**;缺接口先补 SDK。
|
||||
2. MemHub 运行时对 memory 的原子操作 **只经 SDK 公共 Client**;缺正常业务
|
||||
接口先补 SDK。Runtime writers 停止后的 App 启动迁移可直接操作底层存储,
|
||||
但不得把迁移专用方法加入公共 Client。
|
||||
3. MemHub **不存 memory 实体**;**可以存 `memory_id`** 做组织/回执。原文进入 **SDK raw**。
|
||||
|
||||
生产入口通过共享 Runtime 租约取得公开 `HyMemoryClient`;MemHub 只在调用前执行
|
||||
|
||||
@@ -3240,13 +3240,14 @@ def create_app(
|
||||
try:
|
||||
client = await _sdk_client_handle(app).client()
|
||||
value = await asyncio.to_thread(
|
||||
client.list_raw,
|
||||
client.list_memories,
|
||||
user_id="local",
|
||||
source=source,
|
||||
layers=["l1_raw"],
|
||||
include_sources=[source] if source else None,
|
||||
offset=offset,
|
||||
limit=limit,
|
||||
)
|
||||
page = HySdkRawPage.from_sdk(value, limit=limit, offset=offset)
|
||||
page = HySdkRawPage.from_memory_list(value, limit=limit, offset=offset)
|
||||
except Exception as error:
|
||||
raise _sdk_http_error(error, "hy_memory_raw_list_failed") from None
|
||||
return SdkRawPage(items=page.raw, total=page.total, offset=offset, limit=limit)
|
||||
|
||||
@@ -431,7 +431,12 @@ def sdk_migrate(options: Sequence[str], stream: TextIO) -> int:
|
||||
MigrationBundleRejected,
|
||||
inspect_sdk_migration_bundle,
|
||||
)
|
||||
from one_memory.sdk_migration_runner import BackfillResult, run_sdk_data_plane_backfill
|
||||
from one_memory.sdk_migration_runner import (
|
||||
BackfillResult,
|
||||
prepare_sdk_migration_staging,
|
||||
run_sdk_data_plane_backfill,
|
||||
sdk_migration_staging,
|
||||
)
|
||||
from one_memory.secrets import KeyringSecretStore, SecretStoreUnavailableError
|
||||
|
||||
database = settings.data_dir / "one-memory.sqlite3"
|
||||
@@ -482,13 +487,27 @@ def sdk_migrate(options: Sequence[str], stream: TextIO) -> int:
|
||||
separators=(",", ":"),
|
||||
)
|
||||
backend_fingerprint = hashlib.sha256(backend_material.encode()).hexdigest()
|
||||
staging = sdk_migration_staging(
|
||||
data_dir=settings.data_dir,
|
||||
migration_id=migration_id,
|
||||
namespace=HY_MEMORY_NAMESPACE,
|
||||
)
|
||||
try:
|
||||
staging_data_dir = prepare_sdk_migration_staging(
|
||||
paths=staging,
|
||||
database_path=database,
|
||||
)
|
||||
except Exception:
|
||||
code = "sdk_migration_staging_failed"
|
||||
staging_data_dir = None
|
||||
|
||||
async def run_backfill() -> BackfillResult:
|
||||
assert staging_data_dir is not None
|
||||
lease = await asyncio.to_thread(
|
||||
acquire_client,
|
||||
profile,
|
||||
api_key=secret,
|
||||
data_dir=settings.data_dir,
|
||||
data_dir=staging_data_dir,
|
||||
)
|
||||
try:
|
||||
return await run_sdk_data_plane_backfill(
|
||||
@@ -506,10 +525,18 @@ def sdk_migrate(options: Sequence[str], stream: TextIO) -> int:
|
||||
await asyncio.to_thread(lease.close)
|
||||
|
||||
try:
|
||||
result = asyncio.run(run_backfill())
|
||||
result = (
|
||||
asyncio.run(run_backfill())
|
||||
if staging_data_dir is not None
|
||||
else None
|
||||
)
|
||||
except Exception:
|
||||
code = "sdk_migration_backfill_failed"
|
||||
else:
|
||||
if result is None:
|
||||
document = {"schema_version": 1, "state": "failed", "code": code}
|
||||
_write_restore(stream, as_json, document, f"SDK backfill 失败:{code}")
|
||||
return 1
|
||||
document = {
|
||||
"schema_version": 1,
|
||||
"state": result.state,
|
||||
@@ -585,20 +612,39 @@ def sdk_cutover(options: Sequence[str], stream: TextIO) -> int:
|
||||
_write_restore(stream, as_json, document, "预览通过;加 --yes 后提交 SDK authority。")
|
||||
return 4
|
||||
|
||||
from one_memory.hy_runtime_config import HY_MEMORY_VERSION
|
||||
from one_memory.hy_runtime_config import HY_MEMORY_NAMESPACE, HY_MEMORY_VERSION
|
||||
from one_memory.sdk_data_plane_migration import DataPlaneMigrationError
|
||||
from one_memory.sdk_migration_bundle import MigrationBundleRejected
|
||||
from one_memory.sdk_migration_runner import commit_reconciled_cutover
|
||||
from one_memory.sdk_migration_runner import (
|
||||
activate_sdk_migration_staging,
|
||||
commit_reconciled_cutover,
|
||||
resume_legacy_cleanup,
|
||||
sdk_migration_staging,
|
||||
)
|
||||
|
||||
try:
|
||||
marker = commit_reconciled_cutover(
|
||||
database_path = settings.data_dir / "one-memory.sqlite3"
|
||||
staging = sdk_migration_staging(
|
||||
data_dir=settings.data_dir,
|
||||
database_path=settings.data_dir / "one-memory.sqlite3",
|
||||
migration_id=migration_id,
|
||||
bundle_id=bundle_id,
|
||||
sdk_version=HY_MEMORY_VERSION,
|
||||
namespace=HY_MEMORY_NAMESPACE,
|
||||
)
|
||||
except (DataPlaneMigrationError, MigrationBundleRejected) as error:
|
||||
marker = activate_sdk_migration_staging(
|
||||
paths=staging,
|
||||
database_path=database_path,
|
||||
commit=lambda: commit_reconciled_cutover(
|
||||
data_dir=settings.data_dir,
|
||||
database_path=database_path,
|
||||
migration_id=migration_id,
|
||||
bundle_id=bundle_id,
|
||||
sdk_version=HY_MEMORY_VERSION,
|
||||
),
|
||||
)
|
||||
resume_legacy_cleanup(
|
||||
data_dir=settings.data_dir,
|
||||
database_path=database_path,
|
||||
)
|
||||
except (DataPlaneMigrationError, MigrationBundleRejected, OSError) as error:
|
||||
code = getattr(error, "code", str(error))
|
||||
document = {
|
||||
"schema_version": 1,
|
||||
@@ -640,10 +686,12 @@ def sdk_migration_status(options: Sequence[str], stream: TextIO) -> int:
|
||||
import sqlite3
|
||||
|
||||
from one_memory.database_access import main_database
|
||||
from one_memory.hy_runtime_config import HY_MEMORY_NAMESPACE
|
||||
from one_memory.sdk_data_plane_migration import (
|
||||
MemoryAuthority,
|
||||
SdkDataPlaneMigrationStore,
|
||||
)
|
||||
from one_memory.sdk_migration_runner import sdk_migration_staging
|
||||
from one_memory.target_schema import legacy_tables_present
|
||||
|
||||
migration_store = SdkDataPlaneMigrationStore(database_path)
|
||||
@@ -653,10 +701,25 @@ def sdk_migration_status(options: Sequence[str], stream: TextIO) -> int:
|
||||
generation = hashlib.sha256(f"{stat.st_size}:{stat.st_mtime_ns}".encode()).hexdigest()[:16]
|
||||
if authority is MemoryAuthority.SDK:
|
||||
legacy_tables = legacy_tables_present(database_path)
|
||||
staging_cleanup = False
|
||||
if marker is not None:
|
||||
staging = sdk_migration_staging(
|
||||
data_dir=settings.data_dir,
|
||||
migration_id=marker.migration_id,
|
||||
namespace=HY_MEMORY_NAMESPACE,
|
||||
)
|
||||
staging_cleanup = (
|
||||
staging.protective_root.exists()
|
||||
or staging.staging_home.exists()
|
||||
)
|
||||
document = {
|
||||
"schema_version": 1,
|
||||
"authority": "sdk",
|
||||
"state": "cleanup_required" if legacy_tables else "complete",
|
||||
"state": (
|
||||
"cleanup_required"
|
||||
if legacy_tables or staging_cleanup
|
||||
else "complete"
|
||||
),
|
||||
"migration_id": marker.migration_id if marker else None,
|
||||
"generation": generation,
|
||||
"legacy_tables": list(legacy_tables),
|
||||
|
||||
@@ -1,4 +1,10 @@
|
||||
"""Migration-only access to the HY SDK's public inventory and import APIs."""
|
||||
"""Privileged offline access to the HY SDK data plane during App migration.
|
||||
|
||||
Runtime product code must use public ``HyMemoryClient`` methods. This module
|
||||
is the deliberately isolated exception: App startup has stopped all writers,
|
||||
so the one-time legacy migration may access SDK stores directly without
|
||||
turning migration mechanics into public Client APIs.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
@@ -6,7 +12,7 @@ import asyncio
|
||||
from collections.abc import Callable, Mapping
|
||||
from datetime import datetime
|
||||
from pathlib import Path
|
||||
from typing import Protocol
|
||||
from typing import Any, Protocol
|
||||
|
||||
from hy_memory import ( # type: ignore[import-untyped]
|
||||
DataPlaneRestore,
|
||||
@@ -14,6 +20,13 @@ from hy_memory import ( # type: ignore[import-untyped]
|
||||
restore_local_data_plane,
|
||||
snapshot_local_data_plane,
|
||||
)
|
||||
from hy_memory.models.memory import ( # type: ignore[import-untyped]
|
||||
MemoryLayer,
|
||||
MemoryNode,
|
||||
MemoryStatus,
|
||||
SourceType,
|
||||
)
|
||||
from hy_memory.utils.source_filter import normalize_source # type: ignore[import-untyped]
|
||||
|
||||
from one_memory.hy_sdk_types import (
|
||||
HySdkMemoryPage,
|
||||
@@ -25,6 +38,11 @@ from one_memory.hy_sdk_types import (
|
||||
|
||||
|
||||
class MigrationClient(Protocol):
|
||||
_loop_thread: Any
|
||||
_vector_store: Any
|
||||
_embed_service: Any
|
||||
_graph_store: Any
|
||||
|
||||
def list_memories(
|
||||
self,
|
||||
*,
|
||||
@@ -36,32 +54,20 @@ class MigrationClient(Protocol):
|
||||
order: str = "desc",
|
||||
) -> Mapping[str, object]: ...
|
||||
|
||||
def import_memory_exact(self, content: str, **kwargs: object) -> Mapping[str, object]: ...
|
||||
|
||||
def list_raw(
|
||||
self,
|
||||
*,
|
||||
user_id: str,
|
||||
agent_id: str | None = None,
|
||||
session_id: str | None = None,
|
||||
source: str | None = None,
|
||||
limit: int = 100,
|
||||
offset: int = 0,
|
||||
order: str = "desc",
|
||||
) -> Mapping[str, object]: ...
|
||||
|
||||
def import_raw(self, content: str, **kwargs: object) -> Mapping[str, object]: ...
|
||||
|
||||
def delete(self, memory_id: str) -> Mapping[str, object]: ...
|
||||
|
||||
def get_write_outcome(self, request_id: str) -> Mapping[str, object] | None: ...
|
||||
|
||||
|
||||
class HySdkMigration:
|
||||
"""Typed async adapter over the SDK's exact migration primitives."""
|
||||
"""Typed offline adapter over one initialized SDK data plane."""
|
||||
|
||||
def __init__(self, client: MigrationClient) -> None:
|
||||
self.client = client
|
||||
self._loop_thread = client._loop_thread
|
||||
self._vector_store = client._vector_store
|
||||
self._embed_service = client._embed_service
|
||||
self._graph_store = client._graph_store
|
||||
|
||||
async def list_memories(
|
||||
self,
|
||||
@@ -85,7 +91,7 @@ class HySdkMigration:
|
||||
)
|
||||
return _memory_page(raw, limit=limit, offset=offset)
|
||||
|
||||
async def import_memory_exact(
|
||||
async def write_legacy_memory(
|
||||
self,
|
||||
*,
|
||||
content: str,
|
||||
@@ -107,27 +113,119 @@ class HySdkMigration:
|
||||
) -> dict[str, object]:
|
||||
raw = await _sdk_call(
|
||||
"hy_sdk_memory_import_failed",
|
||||
self.client.import_memory_exact,
|
||||
content,
|
||||
memory_id=memory_id,
|
||||
self._loop_thread.run,
|
||||
self._write_legacy_memory(
|
||||
content=content,
|
||||
memory_id=memory_id,
|
||||
user_id=user_id,
|
||||
agent_id=agent_id,
|
||||
session_id=session_id,
|
||||
source=source,
|
||||
layer=layer,
|
||||
status=status,
|
||||
owner=owner,
|
||||
tags=tags,
|
||||
supersedes=supersedes,
|
||||
superseded_by=superseded_by,
|
||||
is_latest=is_latest,
|
||||
memory_at=memory_at,
|
||||
metadata=metadata,
|
||||
request_id=request_id,
|
||||
),
|
||||
)
|
||||
return _successful_result(raw, "hy_sdk_memory_import_failed")
|
||||
|
||||
async def _write_legacy_memory(
|
||||
self,
|
||||
*,
|
||||
content: str,
|
||||
memory_id: str,
|
||||
user_id: str,
|
||||
agent_id: str,
|
||||
session_id: str,
|
||||
source: str,
|
||||
layer: str,
|
||||
status: str,
|
||||
owner: str | None,
|
||||
tags: tuple[str, ...],
|
||||
supersedes: tuple[str, ...],
|
||||
superseded_by: tuple[str, ...],
|
||||
is_latest: bool,
|
||||
memory_at: datetime | None,
|
||||
metadata: Mapping[str, object],
|
||||
request_id: str,
|
||||
) -> dict[str, object]:
|
||||
if not content or not memory_id or not user_id:
|
||||
raise ValueError("content, memory_id and user_id are required")
|
||||
normalized_layer = MemoryLayer.from_string(layer)
|
||||
if normalized_layer is MemoryLayer.L1_RAW:
|
||||
raise ValueError("raw content must use the raw migration lane")
|
||||
normalized_status = MemoryStatus(status.lower())
|
||||
normalized_source = normalize_source(source)
|
||||
existing = await self._vector_store.get_by_id(memory_id)
|
||||
if existing is not None:
|
||||
identity = (
|
||||
existing.content == content,
|
||||
existing.user_id == user_id,
|
||||
existing.agent_id == agent_id,
|
||||
existing.session_id == session_id,
|
||||
existing.source == normalized_source,
|
||||
existing.layer == normalized_layer,
|
||||
existing.status == normalized_status,
|
||||
existing.owner == owner,
|
||||
list(existing.tags or []) == list(tags),
|
||||
list(existing.supersedes or []) == list(supersedes),
|
||||
list(existing.superseded_by or []) == list(superseded_by),
|
||||
existing.is_latest == is_latest,
|
||||
)
|
||||
if not all(identity):
|
||||
raise ValueError("memory_id is already bound to different memory content")
|
||||
await self._project_graph(existing)
|
||||
return {
|
||||
"success": True,
|
||||
"memory_id": memory_id,
|
||||
"memory_ids": [memory_id],
|
||||
"request_id": request_id,
|
||||
"replayed": True,
|
||||
}
|
||||
custom = dict(metadata)
|
||||
custom["migration_request_id"] = request_id
|
||||
embedding = await self._embed_service.embed(content)
|
||||
node = MemoryNode(
|
||||
node_id=memory_id,
|
||||
user_id=user_id,
|
||||
agent_id=agent_id,
|
||||
session_id=session_id,
|
||||
source=source,
|
||||
layer=layer,
|
||||
status=status,
|
||||
source=normalized_source,
|
||||
layer=normalized_layer,
|
||||
content=content,
|
||||
source_type=SourceType.EXPLICIT,
|
||||
status=normalized_status,
|
||||
owner=owner,
|
||||
tags=list(tags),
|
||||
supersedes=list(supersedes),
|
||||
superseded_by=list(superseded_by),
|
||||
is_latest=is_latest,
|
||||
embedding=embedding,
|
||||
memory_at=memory_at,
|
||||
metadata=dict(metadata),
|
||||
request_id=request_id,
|
||||
custom=custom,
|
||||
)
|
||||
return _successful_result(raw, "hy_sdk_memory_import_failed")
|
||||
stored_id = await self._vector_store.upsert(node)
|
||||
if stored_id != memory_id:
|
||||
raise RuntimeError("exact migration changed the requested memory identity")
|
||||
await self._project_graph(node)
|
||||
return {
|
||||
"success": True,
|
||||
"memory_id": memory_id,
|
||||
"memory_ids": [memory_id],
|
||||
"request_id": request_id,
|
||||
}
|
||||
|
||||
async def list_raw(
|
||||
async def _project_graph(self, node: MemoryNode) -> None:
|
||||
if self._graph_store is not None:
|
||||
await self._graph_store.upsert_memory_node(node)
|
||||
|
||||
async def read_raw_inventory(
|
||||
self,
|
||||
*,
|
||||
user_id: str,
|
||||
@@ -140,18 +238,57 @@ class HySdkMigration:
|
||||
) -> HySdkRawPage:
|
||||
raw = await _sdk_call(
|
||||
"hy_sdk_raw_list_failed",
|
||||
self.client.list_raw,
|
||||
user_id=user_id,
|
||||
agent_id=agent_id,
|
||||
session_id=session_id,
|
||||
source=source,
|
||||
limit=limit,
|
||||
offset=offset,
|
||||
order=order,
|
||||
self._loop_thread.run,
|
||||
self._read_raw_inventory(
|
||||
user_id=user_id,
|
||||
agent_id=agent_id,
|
||||
session_id=session_id,
|
||||
source=source,
|
||||
limit=limit,
|
||||
offset=offset,
|
||||
order=order,
|
||||
),
|
||||
)
|
||||
return _raw_page(raw, limit=limit, offset=offset)
|
||||
|
||||
async def import_raw(
|
||||
async def _read_raw_inventory(
|
||||
self,
|
||||
*,
|
||||
user_id: str,
|
||||
agent_id: str | None,
|
||||
session_id: str | None,
|
||||
source: str | None,
|
||||
limit: int,
|
||||
offset: int,
|
||||
order: str,
|
||||
) -> dict[str, object]:
|
||||
if not user_id:
|
||||
raise ValueError("user_id is required")
|
||||
nodes = await self._vector_store.list_by_user(
|
||||
user_id=user_id,
|
||||
agent_id=agent_id,
|
||||
status_filter=[MemoryStatus.ACTIVE],
|
||||
layers=[MemoryLayer.L1_RAW],
|
||||
include_sources=[source] if source else None,
|
||||
limit=100_000,
|
||||
only_latest=False,
|
||||
with_vectors=False,
|
||||
allow_raw_layer=True,
|
||||
)
|
||||
if session_id:
|
||||
nodes = [node for node in nodes if node.session_id == session_id]
|
||||
nodes.sort(
|
||||
key=_node_timestamp,
|
||||
reverse=order.lower() != "asc",
|
||||
)
|
||||
return {
|
||||
"raw": [_raw_node(node) for node in nodes[offset : offset + limit]],
|
||||
"total": len(nodes),
|
||||
"limit": limit,
|
||||
"offset": offset,
|
||||
}
|
||||
|
||||
async def write_legacy_raw(
|
||||
self,
|
||||
*,
|
||||
content: str,
|
||||
@@ -166,18 +303,77 @@ class HySdkMigration:
|
||||
) -> dict[str, object]:
|
||||
raw = await _sdk_call(
|
||||
"hy_sdk_raw_import_failed",
|
||||
self.client.import_raw,
|
||||
content,
|
||||
raw_id=raw_id,
|
||||
self._loop_thread.run,
|
||||
self._write_legacy_raw(
|
||||
content=content,
|
||||
raw_id=raw_id,
|
||||
user_id=user_id,
|
||||
agent_id=agent_id,
|
||||
session_id=session_id,
|
||||
source=source,
|
||||
memory_at=memory_at,
|
||||
metadata=metadata,
|
||||
request_id=request_id,
|
||||
),
|
||||
)
|
||||
return _successful_result(raw, "hy_sdk_raw_import_failed")
|
||||
|
||||
async def _write_legacy_raw(
|
||||
self,
|
||||
*,
|
||||
content: str,
|
||||
raw_id: str,
|
||||
user_id: str,
|
||||
agent_id: str,
|
||||
session_id: str,
|
||||
source: str,
|
||||
memory_at: datetime | None,
|
||||
metadata: Mapping[str, object],
|
||||
request_id: str,
|
||||
) -> dict[str, object]:
|
||||
if not content or not raw_id or not user_id:
|
||||
raise ValueError("content, raw_id and user_id are required")
|
||||
normalized_source = normalize_source(source)
|
||||
existing = await self._vector_store.get_by_id(raw_id)
|
||||
if existing is not None:
|
||||
identity = (
|
||||
existing.layer is MemoryLayer.L1_RAW,
|
||||
existing.content == content,
|
||||
existing.user_id == user_id,
|
||||
existing.agent_id == agent_id,
|
||||
existing.session_id == session_id,
|
||||
existing.source == normalized_source,
|
||||
)
|
||||
if not all(identity):
|
||||
raise ValueError("raw_id is already bound to different source content")
|
||||
return {
|
||||
"success": True,
|
||||
"raw_id": raw_id,
|
||||
"request_id": request_id,
|
||||
"replayed": True,
|
||||
}
|
||||
custom = dict(metadata)
|
||||
custom["migration_request_id"] = request_id
|
||||
embedding = await self._embed_service.embed(content)
|
||||
node = MemoryNode(
|
||||
node_id=raw_id,
|
||||
user_id=user_id,
|
||||
agent_id=agent_id,
|
||||
session_id=session_id,
|
||||
source=source,
|
||||
source=normalized_source,
|
||||
layer=MemoryLayer.L1_RAW,
|
||||
content=content,
|
||||
source_type=SourceType.EXPLICIT,
|
||||
status=MemoryStatus.ACTIVE,
|
||||
is_latest=True,
|
||||
embedding=embedding,
|
||||
memory_at=memory_at,
|
||||
metadata=dict(metadata),
|
||||
request_id=request_id,
|
||||
custom=custom,
|
||||
)
|
||||
return _successful_result(raw, "hy_sdk_raw_import_failed")
|
||||
stored_id = await self._vector_store.upsert(node)
|
||||
if stored_id != raw_id:
|
||||
raise RuntimeError("exact migration changed the requested raw identity")
|
||||
return {"success": True, "raw_id": raw_id, "request_id": request_id}
|
||||
|
||||
async def delete(self, memory_id: str) -> dict[str, object]:
|
||||
raw = await _sdk_call(
|
||||
@@ -338,6 +534,28 @@ def _raw_page(raw: object, *, limit: int, offset: int) -> HySdkRawPage:
|
||||
)
|
||||
|
||||
|
||||
def _node_timestamp(node: Any) -> float:
|
||||
value = getattr(node, "memory_at", None) or getattr(node, "gmt_created", None)
|
||||
return value.timestamp() if isinstance(value, datetime) else 0.0
|
||||
|
||||
|
||||
def _raw_node(node: Any) -> dict[str, object]:
|
||||
def timestamp(value: object) -> int | None:
|
||||
return int(value.timestamp()) if isinstance(value, datetime) else None
|
||||
|
||||
return {
|
||||
"raw_id": node.node_id,
|
||||
"content": node.content,
|
||||
"user_id": node.user_id,
|
||||
"agent_id": node.agent_id,
|
||||
"session_id": node.session_id,
|
||||
"source": node.source,
|
||||
"memory_at": timestamp(node.memory_at),
|
||||
"gmt_created": timestamp(node.gmt_created),
|
||||
"custom": node.custom or {},
|
||||
}
|
||||
|
||||
|
||||
def _raw_record(raw: object) -> HySdkRawRecord:
|
||||
if not isinstance(raw, Mapping):
|
||||
raise HySdkMigrationError("hy_sdk_raw_record_shape_invalid")
|
||||
|
||||
@@ -22,7 +22,7 @@ class HySdkRuntimeError(HySdkError):
|
||||
|
||||
|
||||
class HySdkMigrationError(HySdkError):
|
||||
"""A public SDK migration operation failed or returned an invalid shape."""
|
||||
"""The privileged offline SDK data-plane migration failed."""
|
||||
|
||||
|
||||
class HySdkReadError(HySdkError):
|
||||
@@ -200,11 +200,21 @@ class HySdkRawPage(BaseModel):
|
||||
offset: int = Field(ge=0)
|
||||
|
||||
@classmethod
|
||||
def from_sdk(cls, raw: object, *, limit: int, offset: int) -> HySdkRawPage:
|
||||
def from_memory_list(
|
||||
cls,
|
||||
raw: object,
|
||||
*,
|
||||
limit: int,
|
||||
offset: int,
|
||||
) -> HySdkRawPage:
|
||||
"""Parse ``list_memories(layers=['l1_raw'])`` into the App raw DTO."""
|
||||
if not isinstance(raw, Mapping):
|
||||
raise HySdkReadError("hy_memory_raw_list_shape_unknown")
|
||||
rows = raw.get("raw")
|
||||
total = raw.get("total")
|
||||
bucket = raw.get("vdb")
|
||||
if not isinstance(bucket, Mapping):
|
||||
raise HySdkReadError("hy_memory_raw_list_shape_unknown")
|
||||
rows = bucket.get("memories")
|
||||
total = bucket.get("total")
|
||||
if (
|
||||
not isinstance(rows, list)
|
||||
or not isinstance(total, int)
|
||||
@@ -212,8 +222,13 @@ class HySdkRawPage(BaseModel):
|
||||
or total < 0
|
||||
):
|
||||
raise HySdkReadError("hy_memory_raw_list_shape_unknown")
|
||||
converted = []
|
||||
for row in rows:
|
||||
if not isinstance(row, Mapping) or row.get("layer") != "l1_raw":
|
||||
raise HySdkReadError("hy_memory_raw_list_shape_unknown")
|
||||
converted.append({**row, "raw_id": row.get("memory_id")})
|
||||
return cls(
|
||||
raw=tuple(HySdkRawRecord.from_sdk(row) for row in rows),
|
||||
raw=tuple(HySdkRawRecord.from_sdk(row) for row in converted),
|
||||
total=total,
|
||||
limit=limit,
|
||||
offset=offset,
|
||||
|
||||
@@ -115,7 +115,7 @@ class SdkMigrationClient(Protocol):
|
||||
order: str = "desc",
|
||||
) -> Awaitable[HySdkMemoryPage]: ...
|
||||
|
||||
def import_memory_exact(
|
||||
def write_legacy_memory(
|
||||
self,
|
||||
*,
|
||||
content: str,
|
||||
@@ -133,7 +133,7 @@ class SdkMigrationClient(Protocol):
|
||||
request_id: str,
|
||||
) -> Awaitable[dict[str, object]]: ...
|
||||
|
||||
def list_raw(
|
||||
def read_raw_inventory(
|
||||
self,
|
||||
*,
|
||||
user_id: str,
|
||||
@@ -145,7 +145,7 @@ class SdkMigrationClient(Protocol):
|
||||
order: str = "desc",
|
||||
) -> Awaitable[HySdkRawPage]: ...
|
||||
|
||||
def import_raw(
|
||||
def write_legacy_raw(
|
||||
self,
|
||||
*,
|
||||
content: str,
|
||||
@@ -472,7 +472,7 @@ async def execute_memory_migration_plan(
|
||||
request_id = hashlib.sha256(
|
||||
f"{migration_id}:{decision.item_key}:import_exact".encode()
|
||||
).hexdigest()
|
||||
await client.import_memory_exact(
|
||||
await client.write_legacy_memory(
|
||||
content=item.content,
|
||||
memory_id=item.canonical_memory_id,
|
||||
user_id=item.user_id,
|
||||
@@ -514,7 +514,7 @@ async def read_sdk_raw_inventory(
|
||||
records: dict[str, SdkRawInventory] = {}
|
||||
offset = 0
|
||||
while True:
|
||||
page = await client.list_raw(
|
||||
page = await client.read_raw_inventory(
|
||||
user_id=user_id,
|
||||
limit=page_size,
|
||||
offset=offset,
|
||||
@@ -657,9 +657,9 @@ async def execute_raw_migration_plan(
|
||||
raise RuntimeError(f"missing legacy raw inventory for {decision.item_key}")
|
||||
if decision.action == "import_exact":
|
||||
request_id = hashlib.sha256(
|
||||
f"{migration_id}:{decision.item_key}:import_raw".encode()
|
||||
f"{migration_id}:{decision.item_key}:write_legacy_raw".encode()
|
||||
).hexdigest()
|
||||
await client.import_raw(
|
||||
await client.write_legacy_raw(
|
||||
content=item.content,
|
||||
raw_id=decision.raw_id,
|
||||
user_id=item.user_id,
|
||||
|
||||
@@ -4,7 +4,10 @@ from __future__ import annotations
|
||||
|
||||
import hashlib
|
||||
import json
|
||||
import shutil
|
||||
import sqlite3
|
||||
from collections.abc import Callable
|
||||
from contextlib import suppress
|
||||
from dataclasses import dataclass
|
||||
from pathlib import Path
|
||||
|
||||
@@ -47,6 +50,139 @@ class BackfillResult:
|
||||
raw_items: int
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class SdkMigrationStaging:
|
||||
migration_id: str
|
||||
staging_home: Path
|
||||
live_root: Path
|
||||
staging_root: Path
|
||||
protective_root: Path
|
||||
state_file: Path
|
||||
|
||||
|
||||
def sdk_migration_staging(
|
||||
*,
|
||||
data_dir: Path,
|
||||
migration_id: str,
|
||||
namespace: str,
|
||||
) -> SdkMigrationStaging:
|
||||
"""Return same-filesystem paths without trusting the migration ID as a path."""
|
||||
token = hashlib.sha256(migration_id.encode()).hexdigest()[:16]
|
||||
home = data_dir.resolve()
|
||||
staging_home = home / ".sdk-migration" / token
|
||||
live_root = home / "hy-memory" / namespace
|
||||
return SdkMigrationStaging(
|
||||
migration_id=migration_id,
|
||||
staging_home=staging_home,
|
||||
live_root=live_root,
|
||||
staging_root=staging_home / "hy-memory" / namespace,
|
||||
protective_root=live_root.parent / f".{namespace}.pre-migration-{token}",
|
||||
state_file=staging_home / "swap-state.json",
|
||||
)
|
||||
|
||||
|
||||
def _write_staging_state(paths: SdkMigrationStaging, phase: str) -> None:
|
||||
paths.staging_home.mkdir(parents=True, exist_ok=True)
|
||||
temporary = paths.state_file.with_suffix(".tmp")
|
||||
temporary.write_text(
|
||||
json.dumps(
|
||||
{"schema_version": 1, "migration_id": paths.migration_id, "phase": phase},
|
||||
sort_keys=True,
|
||||
),
|
||||
encoding="utf-8",
|
||||
)
|
||||
temporary.replace(paths.state_file)
|
||||
|
||||
|
||||
def recover_sdk_migration_swap(
|
||||
*,
|
||||
paths: SdkMigrationStaging,
|
||||
database_path: Path,
|
||||
) -> None:
|
||||
"""Recover an interrupted directory swap before opening any SDK store."""
|
||||
if not paths.protective_root.exists():
|
||||
return
|
||||
authority = SdkDataPlaneMigrationStore(database_path).authority()
|
||||
if authority.value == "sdk":
|
||||
if not paths.live_root.is_dir():
|
||||
raise DataPlaneMigrationError("sdk_migration_committed_store_missing")
|
||||
shutil.rmtree(paths.protective_root)
|
||||
shutil.rmtree(paths.staging_home, ignore_errors=True)
|
||||
return
|
||||
if paths.live_root.exists():
|
||||
if paths.staging_root.exists():
|
||||
raise DataPlaneMigrationError("sdk_migration_swap_state_ambiguous")
|
||||
paths.staging_root.parent.mkdir(parents=True, exist_ok=True)
|
||||
paths.live_root.replace(paths.staging_root)
|
||||
paths.protective_root.replace(paths.live_root)
|
||||
_write_staging_state(paths, "recovered")
|
||||
|
||||
|
||||
def prepare_sdk_migration_staging(
|
||||
*,
|
||||
paths: SdkMigrationStaging,
|
||||
database_path: Path,
|
||||
) -> Path:
|
||||
"""Build a private copy; incomplete retries restart from the unchanged live root."""
|
||||
recover_sdk_migration_swap(paths=paths, database_path=database_path)
|
||||
migration_store = SdkDataPlaneMigrationStore(database_path)
|
||||
try:
|
||||
progress = migration_store.progress(paths.migration_id)
|
||||
reuse_ready = progress.state == "ready" and progress.phase == "reconciled"
|
||||
except Exception:
|
||||
reuse_ready = False
|
||||
if paths.staging_root.exists():
|
||||
if paths.staging_root.is_symlink() or not paths.staging_root.is_dir():
|
||||
raise DataPlaneMigrationError("sdk_migration_staging_invalid")
|
||||
if reuse_ready:
|
||||
return paths.staging_home
|
||||
shutil.rmtree(paths.staging_root)
|
||||
paths.live_root.mkdir(parents=True, exist_ok=True)
|
||||
from one_memory.hy_sdk_migration import snapshot_data_plane
|
||||
|
||||
snapshot_data_plane(
|
||||
source_root=paths.live_root,
|
||||
target_root=paths.staging_root,
|
||||
writers_stopped=True,
|
||||
)
|
||||
_write_staging_state(paths, "prepared")
|
||||
return paths.staging_home
|
||||
|
||||
|
||||
def activate_sdk_migration_staging(
|
||||
*,
|
||||
paths: SdkMigrationStaging,
|
||||
database_path: Path,
|
||||
commit: Callable[[], CutoverMarker],
|
||||
) -> CutoverMarker:
|
||||
"""Atomically expose staged SDK files and roll them back if cutover fails."""
|
||||
recover_sdk_migration_swap(paths=paths, database_path=database_path)
|
||||
if not paths.staging_root.is_dir():
|
||||
raise DataPlaneMigrationError("sdk_migration_staging_missing")
|
||||
paths.live_root.mkdir(parents=True, exist_ok=True)
|
||||
_write_staging_state(paths, "activating")
|
||||
paths.live_root.replace(paths.protective_root)
|
||||
try:
|
||||
paths.staging_root.replace(paths.live_root)
|
||||
_write_staging_state(paths, "staging_live")
|
||||
marker = commit()
|
||||
except BaseException:
|
||||
if paths.live_root.exists():
|
||||
paths.staging_root.parent.mkdir(parents=True, exist_ok=True)
|
||||
paths.live_root.replace(paths.staging_root)
|
||||
if paths.protective_root.exists():
|
||||
paths.protective_root.replace(paths.live_root)
|
||||
_write_staging_state(paths, "rolled_back")
|
||||
raise
|
||||
with suppress(OSError):
|
||||
_write_staging_state(paths, "committed")
|
||||
# Authority is already committed; leftover paths are detected by
|
||||
# sdk-migration-status and removed by sdk-cleanup on the next startup.
|
||||
shutil.rmtree(paths.protective_root, ignore_errors=True)
|
||||
shutil.rmtree(paths.staging_home, ignore_errors=True)
|
||||
return marker
|
||||
|
||||
|
||||
def prepare_legacy_write_queue_for_cutover(database_path: Path) -> tuple[int, int]:
|
||||
"""Freeze old jobs and move unstarted task rows into the surviving queue."""
|
||||
with main_database(database_path).read(row_factory=sqlite3.Row) as database:
|
||||
@@ -217,7 +353,6 @@ def commit_reconciled_cutover(
|
||||
reconciliation_digest=digest,
|
||||
)
|
||||
inspect_sdk_migration_bundle(data_dir=data_dir, bundle_id=marker.backup_bundle_id)
|
||||
drop_legacy_tables(database_path)
|
||||
return marker
|
||||
|
||||
|
||||
@@ -232,6 +367,16 @@ def resume_legacy_cleanup(*, data_dir: Path, database_path: Path) -> tuple[str,
|
||||
data_dir=data_dir,
|
||||
bundle_id=marker.backup_bundle_id,
|
||||
)
|
||||
from one_memory.hy_runtime_config import HY_MEMORY_NAMESPACE
|
||||
|
||||
recover_sdk_migration_swap(
|
||||
paths=sdk_migration_staging(
|
||||
data_dir=data_dir,
|
||||
migration_id=marker.migration_id,
|
||||
namespace=HY_MEMORY_NAMESPACE,
|
||||
),
|
||||
database_path=database_path,
|
||||
)
|
||||
return drop_legacy_tables(database_path)
|
||||
|
||||
|
||||
|
||||
@@ -107,6 +107,12 @@ SDK_PRIVATE_ACCESS_EXCEPTIONS = {
|
||||
f"{PACKAGE_LABEL}/hy_graph_projection.py": frozenset(
|
||||
{"_graph_store", "_loop_thread", "_run"}
|
||||
),
|
||||
# Startup migration runs while Runtime writers are stopped. It is the one
|
||||
# non-runtime path allowed to manipulate SDK storage directly so that
|
||||
# migration-only mechanics never become public HyMemoryClient methods.
|
||||
f"{PACKAGE_LABEL}/hy_sdk_migration.py": frozenset(
|
||||
{"_embed_service", "_graph_store", "_loop_thread", "_vector_store"}
|
||||
),
|
||||
}
|
||||
|
||||
|
||||
@@ -241,7 +247,7 @@ def test_no_new_module_writes_legacy_memory_authority() -> None:
|
||||
|
||||
|
||||
def test_no_sdk_private_implementation_access() -> None:
|
||||
"""No debt allowlist: MemHub calls public client methods only."""
|
||||
"""Runtime uses public APIs; only Graph and stopped-writer migration are privileged."""
|
||||
offenders: list[str] = []
|
||||
for path in _all_module_files():
|
||||
tree = ast.parse(path.read_text(encoding="utf-8"))
|
||||
@@ -266,16 +272,18 @@ def test_no_sdk_private_implementation_access() -> None:
|
||||
if unexpected:
|
||||
offenders.append(f"{relative}: {sorted(unexpected)}")
|
||||
assert not offenders, (
|
||||
"SDK private implementation access is forbidden; add a public "
|
||||
f"HyMemoryClient API instead: {offenders}"
|
||||
"SDK private implementation access is forbidden outside the isolated "
|
||||
f"Graph and offline-migration boundaries: {offenders}"
|
||||
)
|
||||
|
||||
|
||||
def test_sdk_modules_import_only_the_public_package_root() -> None:
|
||||
"""Implementation submodules are not a public product contract."""
|
||||
"""Runtime imports only public SDK symbols; offline migration is privileged."""
|
||||
offenders: list[str] = []
|
||||
for path in _all_module_files():
|
||||
relative = _relative(path)
|
||||
if relative == f"{PACKAGE_LABEL}/hy_sdk_migration.py":
|
||||
continue
|
||||
tree = ast.parse(path.read_text(encoding="utf-8"))
|
||||
for node in ast.walk(tree):
|
||||
if isinstance(node, ast.Import) and any(
|
||||
|
||||
@@ -302,7 +302,12 @@ def test_sdk_migrate_runs_offline_backfill_without_cutover(tmp_path, monkeypatch
|
||||
pass
|
||||
|
||||
class Lease:
|
||||
client = object()
|
||||
client = SimpleNamespace(
|
||||
_loop_thread=None,
|
||||
_vector_store=None,
|
||||
_embed_service=None,
|
||||
_graph_store=None,
|
||||
)
|
||||
|
||||
def close(self):
|
||||
return None
|
||||
@@ -362,6 +367,14 @@ def test_sdk_cutover_is_offline_confirmed_and_returns_marker(tmp_path, monkeypat
|
||||
completed_at="2026-10-21T00:00:00Z",
|
||||
),
|
||||
)
|
||||
monkeypatch.setattr(
|
||||
"one_memory.sdk_migration_runner.activate_sdk_migration_staging",
|
||||
lambda *, commit, **_kwargs: commit(),
|
||||
)
|
||||
monkeypatch.setattr(
|
||||
"one_memory.sdk_migration_runner.resume_legacy_cleanup",
|
||||
lambda **_kwargs: (),
|
||||
)
|
||||
output = io.StringIO()
|
||||
|
||||
code = cli.run(
|
||||
|
||||
@@ -4,8 +4,10 @@ import asyncio
|
||||
import json
|
||||
|
||||
import pytest
|
||||
from hy_memory.models.memory import MemoryLayer, MemoryStatus
|
||||
|
||||
from one_memory.database_access import close_main_databases, main_database
|
||||
from one_memory.hy_sdk_migration import HySdkMigration
|
||||
from one_memory.hy_sdk_types import (
|
||||
HySdkMemoryPage,
|
||||
HySdkMemoryRecord,
|
||||
@@ -40,10 +42,15 @@ from one_memory.sdk_migration_inventory import (
|
||||
record_raw_migration_plan,
|
||||
)
|
||||
from one_memory.sdk_migration_runner import (
|
||||
activate_sdk_migration_staging,
|
||||
commit_reconciled_cutover,
|
||||
prepare_legacy_write_queue_for_cutover,
|
||||
prepare_sdk_migration_staging,
|
||||
recover_sdk_migration_swap,
|
||||
resume_legacy_cleanup,
|
||||
run_sdk_data_plane_backfill,
|
||||
sdk_cutover_blockers,
|
||||
sdk_migration_staging,
|
||||
)
|
||||
from one_memory.target_schema import legacy_tables_present
|
||||
|
||||
@@ -215,6 +222,8 @@ def test_startup_cutover_freezes_pending_job_input_and_preserves_the_queue(store
|
||||
sdk_version="2.0.5.dev3",
|
||||
)
|
||||
assert marker.migration_id == "m1"
|
||||
assert legacy_tables_present(store.database_path)
|
||||
resume_legacy_cleanup(data_dir=data_dir, database_path=store.database_path)
|
||||
assert legacy_tables_present(store.database_path) == ()
|
||||
assert store.authority() is MemoryAuthority.SDK
|
||||
preserved = DurableWriteQueue(store.database_path, sdk_authority=True)
|
||||
@@ -432,7 +441,7 @@ class _MigrationClient:
|
||||
offset=offset,
|
||||
)
|
||||
|
||||
async def import_memory_exact(self, **kwargs):
|
||||
async def write_legacy_memory(self, **kwargs):
|
||||
self.imported.append(kwargs)
|
||||
return {"success": True, "memory_id": kwargs["memory_id"]}
|
||||
|
||||
@@ -440,7 +449,7 @@ class _MigrationClient:
|
||||
self.deleted.append(memory_id)
|
||||
return {"success": True, "deleted_count": 1}
|
||||
|
||||
async def list_raw(self, *, limit, offset, **_kwargs):
|
||||
async def read_raw_inventory(self, *, limit, offset, **_kwargs):
|
||||
rows = self.raw[offset : offset + limit]
|
||||
return HySdkRawPage(
|
||||
raw=rows,
|
||||
@@ -449,7 +458,7 @@ class _MigrationClient:
|
||||
offset=offset,
|
||||
)
|
||||
|
||||
async def import_raw(self, **kwargs):
|
||||
async def write_legacy_raw(self, **kwargs):
|
||||
self.imported_raw.append(kwargs)
|
||||
return {"success": True, "raw_id": kwargs["raw_id"]}
|
||||
|
||||
@@ -754,3 +763,217 @@ def test_offline_backfill_blocks_cutover_when_an_item_is_quarantined(store):
|
||||
assert result.phase == "review_required"
|
||||
assert result.counts == {"quarantine": 1}
|
||||
assert store.authority() is MemoryAuthority.LEGACY
|
||||
|
||||
|
||||
class _MigrationLoop:
|
||||
def run(self, coroutine):
|
||||
return asyncio.run(coroutine)
|
||||
|
||||
|
||||
class _MigrationVectorStore:
|
||||
def __init__(self):
|
||||
self.nodes = {}
|
||||
|
||||
async def get_by_id(self, memory_id):
|
||||
return self.nodes.get(memory_id)
|
||||
|
||||
async def upsert(self, node):
|
||||
self.nodes[node.node_id] = node
|
||||
return node.node_id
|
||||
|
||||
async def list_by_user(self, *, user_id, layers, **_kwargs):
|
||||
return [
|
||||
node
|
||||
for node in self.nodes.values()
|
||||
if node.user_id == user_id and node.layer in layers
|
||||
]
|
||||
|
||||
|
||||
class _MigrationEmbedder:
|
||||
async def embed(self, _content):
|
||||
return [0.1, 0.2]
|
||||
|
||||
|
||||
class _MigrationGraphStore:
|
||||
def __init__(self):
|
||||
self.nodes = {}
|
||||
|
||||
async def upsert_memory_node(self, node):
|
||||
self.nodes[node.node_id] = node
|
||||
return node.node_id
|
||||
|
||||
|
||||
class _PrivilegedMigrationClient:
|
||||
def __init__(self):
|
||||
self._loop_thread = _MigrationLoop()
|
||||
self._vector_store = _MigrationVectorStore()
|
||||
self._embed_service = _MigrationEmbedder()
|
||||
self._graph_store = _MigrationGraphStore()
|
||||
|
||||
def list_memories(self, **_kwargs):
|
||||
return {"vdb": {"memories": [], "total": 0}, "request_id": "list"}
|
||||
|
||||
def delete(self, _memory_id):
|
||||
return {"success": True, "deleted_count": 1}
|
||||
|
||||
def get_write_outcome(self, _request_id):
|
||||
return None
|
||||
|
||||
|
||||
def test_offline_migration_writes_sdk_stores_without_public_client_import_methods():
|
||||
client = _PrivilegedMigrationClient()
|
||||
migration = HySdkMigration(client)
|
||||
|
||||
raw_result = asyncio.run(
|
||||
migration.write_legacy_raw(
|
||||
content="verbatim",
|
||||
raw_id="raw-1",
|
||||
user_id="local",
|
||||
agent_id="one-memory",
|
||||
session_id="session",
|
||||
source="cursor",
|
||||
memory_at=None,
|
||||
metadata={"role": "user"},
|
||||
request_id="migrate-raw-1",
|
||||
)
|
||||
)
|
||||
memory_result = asyncio.run(
|
||||
migration.write_legacy_memory(
|
||||
content="prefers tea",
|
||||
memory_id="memory-1",
|
||||
user_id="local",
|
||||
agent_id="one-memory",
|
||||
session_id="session",
|
||||
source="legacy-governance",
|
||||
layer="l2_fact",
|
||||
status="active",
|
||||
owner="user",
|
||||
tags=("preference",),
|
||||
supersedes=(),
|
||||
superseded_by=(),
|
||||
is_latest=True,
|
||||
memory_at=None,
|
||||
metadata={},
|
||||
request_id="migrate-memory-1",
|
||||
)
|
||||
)
|
||||
raw_page = asyncio.run(migration.read_raw_inventory(user_id="local"))
|
||||
|
||||
assert raw_result["raw_id"] == "raw-1"
|
||||
assert memory_result["memory_id"] == "memory-1"
|
||||
assert [item.raw_id for item in raw_page.raw] == ["raw-1"]
|
||||
assert client._vector_store.nodes["raw-1"].layer is MemoryLayer.L1_RAW
|
||||
assert client._vector_store.nodes["memory-1"].status is MemoryStatus.ACTIVE
|
||||
assert client._graph_store.nodes["memory-1"].content == "prefers tea"
|
||||
assert not hasattr(client, "import_raw")
|
||||
assert not hasattr(client, "import_memory_exact")
|
||||
|
||||
|
||||
def test_raw_http_dto_uses_the_unified_memory_list_shape():
|
||||
page = HySdkRawPage.from_memory_list(
|
||||
{
|
||||
"vdb": {
|
||||
"memories": [
|
||||
{
|
||||
"memory_id": "raw-1",
|
||||
"content": "verbatim",
|
||||
"layer": "l1_raw",
|
||||
"status": "active",
|
||||
"user_id": "local",
|
||||
"agent_id": "one-memory",
|
||||
"session_id": "session",
|
||||
"source": "cursor",
|
||||
"custom": {"role": "user"},
|
||||
}
|
||||
],
|
||||
"total": 1,
|
||||
"limit": 100,
|
||||
"offset": 0,
|
||||
}
|
||||
},
|
||||
limit=100,
|
||||
offset=0,
|
||||
)
|
||||
assert page.raw[0].raw_id == "raw-1"
|
||||
assert page.raw[0].custom == {"role": "user"}
|
||||
|
||||
|
||||
def _staging_fixture(tmp_path, store):
|
||||
data_dir = tmp_path / "home"
|
||||
live = data_dir / "hy-memory" / "governed-v2"
|
||||
live.mkdir(parents=True)
|
||||
(live / "sdk-only.txt").write_text("old", encoding="utf-8")
|
||||
paths = sdk_migration_staging(
|
||||
data_dir=data_dir,
|
||||
migration_id="migration-1",
|
||||
namespace="governed-v2",
|
||||
)
|
||||
prepare_sdk_migration_staging(
|
||||
paths=paths,
|
||||
database_path=store.database_path,
|
||||
)
|
||||
return paths
|
||||
|
||||
|
||||
def test_staging_preserves_sdk_only_data_and_swaps_after_commit(tmp_path, store):
|
||||
paths = _staging_fixture(tmp_path, store)
|
||||
staged = paths.staging_root / "sdk-only.txt"
|
||||
assert staged.read_text(encoding="utf-8") == "old"
|
||||
staged.write_text("migrated", encoding="utf-8")
|
||||
marker = object()
|
||||
|
||||
result = activate_sdk_migration_staging(
|
||||
paths=paths,
|
||||
database_path=store.database_path,
|
||||
commit=lambda: marker,
|
||||
)
|
||||
|
||||
assert result is marker
|
||||
assert (paths.live_root / "sdk-only.txt").read_text(encoding="utf-8") == "migrated"
|
||||
assert not paths.protective_root.exists()
|
||||
assert not paths.staging_home.exists()
|
||||
|
||||
|
||||
def test_incomplete_staging_retry_restarts_from_unchanged_live_root(tmp_path, store):
|
||||
paths = _staging_fixture(tmp_path, store)
|
||||
(paths.staging_root / "sdk-only.txt").write_text("partial", encoding="utf-8")
|
||||
|
||||
prepare_sdk_migration_staging(
|
||||
paths=paths,
|
||||
database_path=store.database_path,
|
||||
)
|
||||
|
||||
assert (paths.staging_root / "sdk-only.txt").read_text(encoding="utf-8") == "old"
|
||||
|
||||
|
||||
def test_failed_cutover_restores_live_store_and_keeps_staging(tmp_path, store):
|
||||
paths = _staging_fixture(tmp_path, store)
|
||||
(paths.staging_root / "sdk-only.txt").write_text("migrated", encoding="utf-8")
|
||||
|
||||
def fail():
|
||||
raise DataPlaneMigrationError("cutover_failed")
|
||||
|
||||
with pytest.raises(DataPlaneMigrationError, match="cutover_failed"):
|
||||
activate_sdk_migration_staging(
|
||||
paths=paths,
|
||||
database_path=store.database_path,
|
||||
commit=fail,
|
||||
)
|
||||
|
||||
assert (paths.live_root / "sdk-only.txt").read_text(encoding="utf-8") == "old"
|
||||
assert (paths.staging_root / "sdk-only.txt").read_text(encoding="utf-8") == "migrated"
|
||||
|
||||
|
||||
def test_restart_recovers_swap_when_authority_was_not_committed(tmp_path, store):
|
||||
paths = _staging_fixture(tmp_path, store)
|
||||
(paths.staging_root / "sdk-only.txt").write_text("migrated", encoding="utf-8")
|
||||
paths.live_root.replace(paths.protective_root)
|
||||
paths.staging_root.replace(paths.live_root)
|
||||
|
||||
recover_sdk_migration_swap(
|
||||
paths=paths,
|
||||
database_path=store.database_path,
|
||||
)
|
||||
|
||||
assert (paths.live_root / "sdk-only.txt").read_text(encoding="utf-8") == "old"
|
||||
assert (paths.staging_root / "sdk-only.txt").read_text(encoding="utf-8") == "migrated"
|
||||
@@ -116,7 +116,10 @@ import {
|
||||
startStableRuntimeRepair,
|
||||
} from "./stable-runtime";
|
||||
import { STARTUP_SCREEN_URL, startupScreenHtml } from "./startup-screen";
|
||||
import { runStartupSdkMigration } from "./startup-sdk-migration";
|
||||
import {
|
||||
requireCompletedStartupSdkMigration,
|
||||
runStartupSdkMigration,
|
||||
} from "./startup-sdk-migration";
|
||||
import { resolveTrialProfile } from "./trial-profile";
|
||||
import { createWindowOptions } from "./window-options";
|
||||
|
||||
@@ -1097,6 +1100,7 @@ async function initialize(isQuitting: () => boolean): Promise<void> {
|
||||
...(migration.code === undefined ? {} : { error_code: migration.code }),
|
||||
details: { state: migration.state },
|
||||
});
|
||||
requireCompletedStartupSdkMigration(migration);
|
||||
};
|
||||
|
||||
try {
|
||||
|
||||
@@ -1,5 +1,8 @@
|
||||
import { expect, it, vi } from "vitest";
|
||||
import { runStartupSdkMigration } from "./startup-sdk-migration";
|
||||
import {
|
||||
requireCompletedStartupSdkMigration,
|
||||
runStartupSdkMigration,
|
||||
} from "./startup-sdk-migration";
|
||||
|
||||
const core = { command: "/core/one-memory", args: [] };
|
||||
|
||||
@@ -89,6 +92,9 @@ it("reports an unreadable migration status as deferred", async () => {
|
||||
state: "deferred",
|
||||
code: "sdk_migration_status_unavailable",
|
||||
});
|
||||
expect(() => requireCompletedStartupSdkMigration(result)).toThrow(
|
||||
"sdk_migration_status_unavailable",
|
||||
);
|
||||
});
|
||||
|
||||
it("resumes legacy table cleanup under the maintenance fence", async () => {
|
||||
|
||||
@@ -9,6 +9,14 @@ export interface StartupMigrationResult {
|
||||
code?: string;
|
||||
}
|
||||
|
||||
export function requireCompletedStartupSdkMigration(
|
||||
result: StartupMigrationResult,
|
||||
): void {
|
||||
if (result.state === "deferred") {
|
||||
throw new Error(result.code ?? "sdk_migration_incomplete");
|
||||
}
|
||||
}
|
||||
|
||||
interface CommandResult {
|
||||
exitCode: number;
|
||||
document: Record<string, unknown>;
|
||||
|
||||
@@ -129,12 +129,15 @@ directly for low-latency Cypher projection and store-width administration.
|
||||
This exception does not extend to VDB, Coding, prompt, reconcile, or lifecycle.
|
||||
|
||||
- Storage backends:仅 SDK config/env(Chroma/FAISS/Qdrant、Kuzu/Neo4j、cache/history sqlite|mysql、coding sqlite 等)。
|
||||
- Queue:pending `memory_write_jobs` 只表示尚未调用 SDK 的待处理请求,因此没有 SDK raw/memory ID。worker 单次调用 SDK `add()`;SDK 内部完成 raw 写入与 extraction/reconciliation,成功后 MemHub 仅将返回的 raw ID / memory IDs 记作 job outcome。`import_raw()` 只用于历史数据的 exact migration,不拆分正常写路径。
|
||||
- Queue:pending `memory_write_jobs` 只表示尚未调用 SDK 的待处理请求,因此没有 SDK raw/memory ID。worker 单次调用 SDK `add()`;SDK 内部完成 raw 写入与 extraction/reconciliation,成功后 MemHub 仅将返回的 raw ID / memory IDs 记作 job outcome。旧用户启动迁移在 Runtime writers 停止期间直接操作 SDK 存储,不向公开 `HyMemoryClient` 增加迁移专用方法,也不拆分正常写路径。
|
||||
- Recall:Host Bridge / Core 先做宿主安装凭据与 tool grant,再调 **SDK search/get**。当前产品没有逐 memory 的 A-harness→B-harness 可见性开关,默认允许跨 harness 搜索;未来若增加来源限制,直接透传 SDK `source` / `include_sources` / `exclude_sources`,不在 MemHub 建 memory ACL。
|
||||
- Context wire:cutover marker 提交前只使用 legacy Context v1;提交后 Host Bridge 只使用 SDK-native Context v2。两种 schema 原样校验、原样返回,不把 SDK item 补造成 v1 的 `revision`、`evidence_ids`、`paths` 或 `adoption`,已选择的 v2 路径失败时也不回退 legacy 权威。
|
||||
- Ask wire:marker 前保留 Governance Ask v1;marker 后 Web / Host Bridge 只调用 SDK Ask v2。v2 的引用只含 SDK 原生 `memory_id/content/layer/status/source/gmt_created`,生成过程中重复 `get` 校验同一 SDK 快照,不补造 revision、Evidence 或 scopes,SDK 失败不回退 v1。
|
||||
- Matter:未形成已发布产品合同,不迁移占位数据;所有 `matter*` 表在升级清理时物理删除,fresh install 不创建,Runtime 不注册 Matter API。
|
||||
- 旧用户迁移:由 App 在可见的启动 loading 阶段统一执行;此时 Core 尚未对外提供产品读写。启动流程创建/校验完整 bundle、续跑 ledger、完成 SDK backfill 与 reconciliation,满足全部门禁后才提交 marker 并进入主界面。普通用户不需要另跑 CLI,也不按旧 App 内 SDK 版本分叉迁移。
|
||||
- SDK backfill 只写同文件系统的私有 staging 副本;验证后先原子切换 SDK
|
||||
root,再提交 authority marker。切换或 marker 提交失败必须恢复原 live root;
|
||||
App 不得在 migration `deferred` 状态继续启动 Runtime。
|
||||
- Matter 若未来重新开发,需以新的 SDK-native 合同重新设计,不继承当前占位 schema。
|
||||
|
||||
Pinned SDK 版本见发布锁文件 / `MEMORY.md`(随发布更新)。
|
||||
|
||||
@@ -163,6 +163,13 @@ Before cleanup, every surviving reference is rewritten to its canonical SDK
|
||||
ID. Alias, item-ledger, and detailed cutover tables are then dropped; the
|
||||
authority singleton and verified bundle remain.
|
||||
|
||||
Backfill never mutates the live SDK root. Startup first copies the complete
|
||||
local SDK data plane, including SDK-only records, to a same-filesystem staging
|
||||
home. Exact legacy writes and reconciliation run there. Cutover renames the
|
||||
live root to a protective path, renames staging into place, and only then
|
||||
commits SDK authority. A failed commit restores the previous root; restart
|
||||
recovery resolves any interrupted rename before opening SDK stores.
|
||||
|
||||
## 8. Queue and unknown outcomes
|
||||
|
||||
Queue rows and leases remain MemHub management state.
|
||||
@@ -172,8 +179,11 @@ Queue rows and leases remain MemHub management state.
|
||||
- Its worker calls SDK `add()` once. The SDK owns raw persistence plus
|
||||
extraction/reconciliation; a successful job stores only the returned raw ID,
|
||||
canonical memory IDs and terminal outcome.
|
||||
- Exact `import_raw()` is a historical migration primitive, not the first half
|
||||
of a two-stage normal write path.
|
||||
- Legacy raw and semantic backfill is an App-owned privileged offline
|
||||
operation while Runtime writers are stopped. It writes SDK stores directly;
|
||||
no migration-only import/get/list method is added to public
|
||||
`HyMemoryClient`, and this is not the first half of a two-stage normal write
|
||||
path.
|
||||
|
||||
- `hy_invoked=0` may be safely retried after normal lease recovery.
|
||||
- `memory_write_jobs.hy_invoked=1`, `state=unknown`, or any non-terminal SDK
|
||||
|
||||
@@ -12,9 +12,8 @@ L1_RAW 对外读路径不可见:闸门焊在向量库唯一查询入口。
|
||||
hybrid_search_native / list_by_user 四个查询方法的共同必经处,
|
||||
上层无法绕开,新增接口自动受保护。
|
||||
|
||||
按 ID 直取走主键查询、不经过 filter,故在 `client.async_get` 处默认挡住。
|
||||
Python SDK 调用方可显式传 `include_raw=True` 读取;HTTP 接口不传该参数,
|
||||
继续保持原有隐藏行为。
|
||||
按 ID 直取是精确读取:raw ID 返回 L1_RAW,fact ID 返回对应事实。权限由
|
||||
调用 SDK 前的产品边界检查,不由 SDK 根据 layer 猜测。
|
||||
|
||||
内部管线需读原始对话时显式 allow_raw_layer=True 放行:
|
||||
- `writer._get_recent_dialogue_history`:提取前拼多轮对话历史喂 LLM
|
||||
@@ -125,8 +124,8 @@ class TestListNoLongerFiltersInProcess:
|
||||
"list 又出现进程内过滤 raw 的写法,应交由 vdb 侧 filter 完成"
|
||||
|
||||
|
||||
class TestGetByIdRawOptIn:
|
||||
"""按 ID 直取默认隐藏 raw,只有 SDK 显式选择时才返回。"""
|
||||
class TestGetByIdReturnsExactLayer:
|
||||
"""按 ID 直取返回命中的真实层级。"""
|
||||
|
||||
@staticmethod
|
||||
def _client(node):
|
||||
@@ -153,35 +152,29 @@ class TestGetByIdRawOptIn:
|
||||
user_id="u1",
|
||||
agent_id="a1",
|
||||
session_id="s1",
|
||||
source="cursor",
|
||||
content="原始对话",
|
||||
layer=MemoryLayer.L1_RAW,
|
||||
status=MemoryStatus.ACTIVE,
|
||||
custom={"meta": [{"source": "test"}]},
|
||||
)
|
||||
|
||||
def test_default_still_hides_raw(self):
|
||||
def test_raw_id_returns_lite_memory(self):
|
||||
from hy_memory.client import HyMemoryClient
|
||||
|
||||
client = self._client(self._raw_node())
|
||||
raw_get = HyMemoryClient.async_get.__wrapped__
|
||||
assert run(raw_get(client, "raw-1")) is None
|
||||
|
||||
def test_explicit_include_raw_returns_lite_memory(self):
|
||||
from hy_memory.client import HyMemoryClient
|
||||
|
||||
client = self._client(self._raw_node())
|
||||
raw_get = HyMemoryClient.async_get.__wrapped__
|
||||
result = run(raw_get(client, "raw-1", include_raw=True))
|
||||
result = run(raw_get(client, "raw-1"))
|
||||
|
||||
assert result["memory_id"] == "raw-1"
|
||||
assert result["content"] == "原始对话"
|
||||
assert result["layer"] == MemoryLayer.L1_RAW.value
|
||||
assert result["source"] == "cursor"
|
||||
assert result["retrieval_source"] == "vdb"
|
||||
assert result["meta"] == [{"source": "test"}]
|
||||
|
||||
def test_public_get_exposes_keyword_only_opt_in(self):
|
||||
def test_public_get_has_no_raw_visibility_switch(self):
|
||||
import inspect
|
||||
from hy_memory.client import HyMemoryClient
|
||||
|
||||
param = inspect.signature(HyMemoryClient.get).parameters["include_raw"]
|
||||
assert param.default is False
|
||||
assert param.kind is inspect.Parameter.KEYWORD_ONLY
|
||||
assert "include_raw" not in inspect.signature(HyMemoryClient.get).parameters
|
||||
@@ -36,13 +36,16 @@ def _node(
|
||||
content="x",
|
||||
layer=MemoryLayer.L2_FACT,
|
||||
status=MemoryStatus.ACTIVE,
|
||||
session_id="s1",
|
||||
source="default",
|
||||
):
|
||||
import random
|
||||
n = MemoryNode(
|
||||
node_id=str(uuid.uuid4()),
|
||||
user_id=user_id,
|
||||
agent_id="a1",
|
||||
session_id="s1",
|
||||
session_id=session_id,
|
||||
source=source,
|
||||
content=content,
|
||||
layer=layer,
|
||||
status=status,
|
||||
@@ -159,30 +162,53 @@ class TestVdbBucketLayerFilter:
|
||||
))
|
||||
assert {m["content"] for m in got["memories"]} == {"fact", "summary"}
|
||||
|
||||
def test_explicit_raw_is_blocked(self, faiss_store):
|
||||
"""显式只要 L1_RAW 也拿不到:raw 对外读路径一律不可见(契约变更)。
|
||||
|
||||
改动前:默认排除只是缺省策略,调用方点名 L1_RAW 即以调用方为准。
|
||||
改动后:向量库侧 allow_raw_layer 默认 False,排除条件随请求下发,
|
||||
raw 不出库;调用方无论传什么 layers 都拿不到原始对话。
|
||||
仅内部管线(writer 拼对话历史、clone 全量复制)显式放行。
|
||||
"""
|
||||
def test_explicit_raw_returns_raw(self, faiss_store):
|
||||
"""默认列表不含 raw;调用方点名 L1_RAW 时按统一 list 合同返回。"""
|
||||
self._seed(faiss_store)
|
||||
got = run(_client_with(faiss_store)._list_vdb_bucket(
|
||||
user_id="u1", agent_id=None, limit=100, offset=0, order="desc",
|
||||
layers=[MemoryLayer.L1_RAW],
|
||||
))
|
||||
assert got["memories"] == []
|
||||
assert got["total"] == 0
|
||||
assert [m["content"] for m in got["memories"]] == ["raw"]
|
||||
assert got["memories"][0]["layer"] == MemoryLayer.L1_RAW.value
|
||||
assert got["total"] == 1
|
||||
|
||||
def test_raw_mixed_with_other_layers_only_drops_raw(self, faiss_store):
|
||||
"""raw 与其他层混传时只剔除 raw,其余层照常返回。"""
|
||||
def test_raw_mixed_with_other_layers_returns_both(self, faiss_store):
|
||||
self._seed(faiss_store)
|
||||
got = run(_client_with(faiss_store)._list_vdb_bucket(
|
||||
user_id="u1", agent_id=None, limit=100, offset=0, order="desc",
|
||||
layers=[MemoryLayer.L1_RAW, MemoryLayer.L2_FACT],
|
||||
))
|
||||
assert [m["content"] for m in got["memories"]] == ["fact"]
|
||||
assert {m["content"] for m in got["memories"]} == {"raw", "fact"}
|
||||
|
||||
def test_raw_uses_generic_session_and_source_filters(self, faiss_store):
|
||||
run(faiss_store.upsert_batch([
|
||||
_node(
|
||||
content="cursor raw",
|
||||
layer=MemoryLayer.L1_RAW,
|
||||
session_id="s1",
|
||||
source="cursor",
|
||||
),
|
||||
_node(
|
||||
content="claude raw",
|
||||
layer=MemoryLayer.L1_RAW,
|
||||
session_id="s2",
|
||||
source="claude",
|
||||
),
|
||||
]))
|
||||
got = run(_client_with(faiss_store)._list_vdb_bucket(
|
||||
user_id="u1",
|
||||
agent_id=None,
|
||||
limit=100,
|
||||
offset=0,
|
||||
order="desc",
|
||||
layers=[MemoryLayer.L1_RAW],
|
||||
session_ids=["s2"],
|
||||
include_sources=["claude"],
|
||||
exclude_sources=["cursor"],
|
||||
))
|
||||
assert [item["content"] for item in got["memories"]] == ["claude raw"]
|
||||
assert got["memories"][0]["source"] == "claude"
|
||||
|
||||
def test_status_filter_defaults_to_active_and_can_read_superseded(self, faiss_store):
|
||||
run(faiss_store.upsert_batch([
|
||||
|
||||
+50
-232
@@ -1,69 +1,22 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import inspect
|
||||
from unittest.mock import AsyncMock
|
||||
|
||||
import pytest
|
||||
|
||||
from hy_memory.client import HyMemoryClient
|
||||
from hy_memory.models.memory import MemoryLayer, MemoryStatus
|
||||
|
||||
|
||||
class OutcomeCache:
|
||||
def __init__(self):
|
||||
self.rows = {}
|
||||
|
||||
async def claim_write_outcome(
|
||||
self, request_id, user_id, agent_id, session_id, operation
|
||||
):
|
||||
if request_id in self.rows:
|
||||
return self.rows[request_id]
|
||||
self.rows[request_id] = {
|
||||
"request_id": request_id,
|
||||
"user_id": user_id,
|
||||
"agent_id": agent_id,
|
||||
"session_id": session_id,
|
||||
"operation": operation,
|
||||
"status": "running",
|
||||
"result": {},
|
||||
}
|
||||
return None
|
||||
|
||||
async def finish_write_outcome(self, request_id, status, result):
|
||||
self.rows[request_id].update(status=status, result=dict(result))
|
||||
return True
|
||||
|
||||
async def get_write_outcome(self, request_id):
|
||||
return self.rows.get(request_id)
|
||||
from hy_memory.models.memory import (
|
||||
MemoryLayer,
|
||||
MemoryNode,
|
||||
MemoryStatus,
|
||||
SourceType,
|
||||
)
|
||||
|
||||
|
||||
class RawStore:
|
||||
def __init__(self):
|
||||
self.nodes = {}
|
||||
self.upserts = 0
|
||||
|
||||
async def get_by_id(self, node_id):
|
||||
return self.nodes.get(node_id)
|
||||
|
||||
async def upsert(self, node):
|
||||
self.upserts += 1
|
||||
self.nodes[node.node_id] = node
|
||||
return node.node_id
|
||||
|
||||
async def list_by_user(
|
||||
self,
|
||||
*,
|
||||
user_id,
|
||||
agent_id=None,
|
||||
include_sources=None,
|
||||
**_kwargs,
|
||||
):
|
||||
return [
|
||||
node
|
||||
for node in self.nodes.values()
|
||||
if node.user_id == user_id
|
||||
and (agent_id is None or node.agent_id == agent_id)
|
||||
and (not include_sources or node.source in include_sources)
|
||||
]
|
||||
def __init__(self, nodes: list[MemoryNode]):
|
||||
self.nodes = nodes
|
||||
|
||||
async def search(
|
||||
self,
|
||||
@@ -77,7 +30,7 @@ class RawStore:
|
||||
**_kwargs,
|
||||
):
|
||||
rows = []
|
||||
for node in self.nodes.values():
|
||||
for node in self.nodes:
|
||||
if node.user_id not in user_ids:
|
||||
continue
|
||||
if agent_ids and node.agent_id not in agent_ids:
|
||||
@@ -92,186 +45,51 @@ class RawStore:
|
||||
return rows[:limit]
|
||||
|
||||
|
||||
def _client():
|
||||
def raw_node(raw_id: str, session_id: str, source: str) -> MemoryNode:
|
||||
return MemoryNode(
|
||||
node_id=raw_id,
|
||||
user_id="u1",
|
||||
agent_id="a1",
|
||||
session_id=session_id,
|
||||
source=source,
|
||||
layer=MemoryLayer.L1_RAW,
|
||||
content=f"content {raw_id}",
|
||||
source_type=SourceType.EXPLICIT,
|
||||
status=MemoryStatus.ACTIVE,
|
||||
is_latest=True,
|
||||
embedding=[0.1, 0.2],
|
||||
)
|
||||
|
||||
|
||||
def test_migration_only_methods_are_not_public_client_api():
|
||||
for name in (
|
||||
"get_raw",
|
||||
"async_get_raw",
|
||||
"list_raw",
|
||||
"async_list_raw",
|
||||
"import_raw",
|
||||
"async_import_raw",
|
||||
"import_memory_exact",
|
||||
"async_import_memory_exact",
|
||||
):
|
||||
assert not hasattr(HyMemoryClient, name)
|
||||
assert "include_raw" not in inspect.signature(HyMemoryClient.get).parameters
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_existing_search_contract_can_explicitly_search_raw():
|
||||
client = HyMemoryClient.__new__(HyMemoryClient)
|
||||
client._cache = OutcomeCache()
|
||||
client._vector_store = RawStore()
|
||||
client._vector_store = RawStore(
|
||||
[
|
||||
raw_node("raw-1", "s1", "cursor"),
|
||||
raw_node("raw-2", "s2", "claude"),
|
||||
]
|
||||
)
|
||||
client._embed_service = AsyncMock()
|
||||
client._embed_service.embed.return_value = [0.1, 0.2]
|
||||
return client
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_import_raw_preserves_identity_without_extraction():
|
||||
client = _client()
|
||||
result = await HyMemoryClient.async_import_raw.__wrapped__(
|
||||
result = await HyMemoryClient._search_explicit_raw_layer.__wrapped__(
|
||||
client,
|
||||
"verbatim",
|
||||
user_id="u1",
|
||||
agent_id="a1",
|
||||
session_id="s1",
|
||||
source="cursor",
|
||||
raw_id="raw-1",
|
||||
request_id="request-1",
|
||||
metadata={"role": "user", "source_order": 7},
|
||||
)
|
||||
|
||||
assert result == {
|
||||
"success": True,
|
||||
"raw_id": "raw-1",
|
||||
"request_id": "request-1",
|
||||
}
|
||||
node = client._vector_store.nodes["raw-1"]
|
||||
assert node.layer == MemoryLayer.L1_RAW
|
||||
assert node.content == "verbatim"
|
||||
assert node.custom["role"] == "user"
|
||||
assert node.custom["source_order"] == 7
|
||||
assert node.custom["input_kind"] == "raw_exact_import"
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_import_raw_request_replay_does_not_write_twice():
|
||||
client = _client()
|
||||
first = await HyMemoryClient.async_import_raw.__wrapped__(
|
||||
client, "same", user_id="u1", raw_id="raw-1", request_id="request-1"
|
||||
)
|
||||
second = await HyMemoryClient.async_import_raw.__wrapped__(
|
||||
client, "same", user_id="u1", raw_id="raw-1", request_id="request-1"
|
||||
)
|
||||
|
||||
assert second == {**first, "replayed": True}
|
||||
assert client._vector_store.upserts == 1
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_existing_raw_id_must_match_exact_content_and_owner():
|
||||
client = _client()
|
||||
await HyMemoryClient.async_import_raw.__wrapped__(
|
||||
client, "first", user_id="u1", raw_id="raw-1", request_id="request-1"
|
||||
)
|
||||
|
||||
with pytest.raises(ValueError, match="different source content"):
|
||||
await HyMemoryClient.async_import_raw.__wrapped__(
|
||||
client, "changed", user_id="u1", raw_id="raw-1", request_id="request-2"
|
||||
)
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_raw_get_and_list_use_explicit_api():
|
||||
client = _client()
|
||||
for raw_id, session_id, source in (
|
||||
("raw-1", "s1", "cursor"),
|
||||
("raw-2", "s2", "claude"),
|
||||
):
|
||||
await HyMemoryClient.async_import_raw.__wrapped__(
|
||||
client,
|
||||
raw_id,
|
||||
user_id="u1",
|
||||
session_id=session_id,
|
||||
source=source,
|
||||
raw_id=raw_id,
|
||||
request_id=f"request-{raw_id}",
|
||||
)
|
||||
|
||||
item = await HyMemoryClient.async_get_raw.__wrapped__(client, "raw-1")
|
||||
assert item["raw_id"] == "raw-1"
|
||||
assert item["content"] == "raw-1"
|
||||
|
||||
page = await HyMemoryClient.async_list_raw.__wrapped__(
|
||||
client,
|
||||
user_id="u1",
|
||||
source="claude",
|
||||
limit=10,
|
||||
offset=0,
|
||||
)
|
||||
assert page["total"] == 1
|
||||
assert [item["raw_id"] for item in page["raw"]] == ["raw-2"]
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_exact_memory_import_preserves_id_lifecycle_and_chain():
|
||||
client = _client()
|
||||
result = await HyMemoryClient.async_import_memory_exact.__wrapped__(
|
||||
client,
|
||||
"older preference",
|
||||
memory_id="memory-1",
|
||||
user_id="u1",
|
||||
agent_id="a1",
|
||||
session_id="s1",
|
||||
source="cursor",
|
||||
layer="l2_fact",
|
||||
status="superseded",
|
||||
owner="user",
|
||||
tags=["preference"],
|
||||
superseded_by=["memory-2"],
|
||||
is_latest=False,
|
||||
request_id="request-memory-1",
|
||||
)
|
||||
|
||||
assert result["memory_ids"] == ["memory-1"]
|
||||
node = client._vector_store.nodes["memory-1"]
|
||||
assert node.content == "older preference"
|
||||
assert node.layer == MemoryLayer.L2_FACT
|
||||
assert node.status == MemoryStatus.SUPERSEDED
|
||||
assert node.superseded_by == ["memory-2"]
|
||||
assert node.is_latest is False
|
||||
assert node.custom["input_kind"] == "memory_exact_import"
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_exact_memory_import_refuses_same_id_with_different_body():
|
||||
client = _client()
|
||||
await HyMemoryClient.async_import_memory_exact.__wrapped__(
|
||||
client,
|
||||
"first",
|
||||
memory_id="memory-1",
|
||||
user_id="u1",
|
||||
request_id="request-memory-1",
|
||||
)
|
||||
|
||||
with pytest.raises(ValueError, match="different memory content"):
|
||||
await HyMemoryClient.async_import_memory_exact.__wrapped__(
|
||||
client,
|
||||
"changed",
|
||||
memory_id="memory-1",
|
||||
user_id="u1",
|
||||
request_id="request-memory-2",
|
||||
)
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_exact_memory_import_rejects_raw_layer():
|
||||
client = _client()
|
||||
with pytest.raises(ValueError, match="use import_raw"):
|
||||
await HyMemoryClient.async_import_memory_exact.__wrapped__(
|
||||
client,
|
||||
"raw",
|
||||
memory_id="memory-1",
|
||||
user_id="u1",
|
||||
layer="l1_raw",
|
||||
request_id="request-memory-1",
|
||||
)
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_existing_search_contract_can_explicitly_search_raw_with_source_filter():
|
||||
client = _client()
|
||||
for raw_id, session_id, source in (
|
||||
("raw-1", "s1", "cursor"),
|
||||
("raw-2", "s2", "claude"),
|
||||
):
|
||||
await HyMemoryClient.async_import_raw.__wrapped__(
|
||||
client,
|
||||
f"content {raw_id}",
|
||||
user_id="u1",
|
||||
agent_id="a1",
|
||||
session_id=session_id,
|
||||
source=source,
|
||||
raw_id=raw_id,
|
||||
request_id=f"request-{raw_id}",
|
||||
)
|
||||
|
||||
result = await client._search_explicit_raw_layer(
|
||||
"content",
|
||||
user_ids=["u1"],
|
||||
agent_ids=["a1"],
|
||||
|
||||
Reference in new issue
Block a user