Merge pull request #195 from Tencent-Hunyuan/feat/memory-panel-all-scenes

feat(memhub): memory panel lists and searches chat, working and coding
This commit is contained in:
feitianxiang authored and GitHub committed 2026-10-27 18:04:32 +08:00
commit b43c967baa
38 files changed
+2212 -180

No files matched your search

+2 -2
View File
@@ -18,7 +18,7 @@ If you run into a problem, please open an Issue with the version, platform,repro
## 发行与平台合同
源码当前固定使用公开 PyPI 正式版 `hy-memory==2.0.5`。MemHub 源码版本为 `1.0.5`
源码当前固定使用公开 PyPI 正式版 `hy-memory==2.0.6`。MemHub 源码版本为 `1.0.5`
(build 14),GitHub 仓库为
[`Tencent-Hunyuan/Hy-Memory`](https://github.com/Tencent-Hunyuan/Hy-Memory),npm 包名为
`memhub-cli`。当前代码架构见
@@ -117,7 +117,7 @@ MemHub 的 SQLite 数据默认位于 `~/.one-memory`,不在 npm 包或 Runtime
## Hy Memory
SDK 依赖固定为公开 PyPI 正式版 `hy-memory==2.0.5`。SDK 配置、权限边界及 search
SDK 依赖固定为公开 PyPI 正式版 `hy-memory==2.0.6`。SDK 配置、权限边界及 search
返回格式见 [`MEMORY.md`](MEMORY.md)。HY search 返回的 `content` 已包含 SDK 统一生成的
逻辑时间和演化上下文;调用方不应再次转换时间戳或重复拼接 `When`。
+1 -1
View File
@@ -2352,7 +2352,7 @@
{
"SPDXID": "SPDXRef-Package-hy-memory",
"name": "hy-memory",
"versionInfo": "2.0.5",
"versionInfo": "2.0.6",
"downloadLocation": "NOASSERTION",
"filesAnalyzed": false,
"licenseConcluded": "Apache-2.0",
+1 -1
View File
@@ -9,7 +9,7 @@ Per-package license texts are not reproduced here yet; the license
column is filled in as part of release review, and the SBOM records
`NOASSERTION` until then rather than guessing.
## hy-memory 2.0.5
## hy-memory 2.0.6
License: Apache-2.0
+9 -3
View File
@@ -152,7 +152,7 @@ export function renderMemoryPageText(document: MemoryPageDocument): string {
];
for (const memory of document.memories) {
lines.push(
`- ${memory.memory_id} ${memory.status}/${memory.layer} source=${memory.source ?? "未知"}`,
`- ${memory.memory_id} [${memory.scene}] ${memory.status}/${memory.layer} source=${memory.source ?? "未知"}`,
` ${memory.content}${memory.content_truncated ? ` …(共 ${memory.content_characters} 字)` : ""}`,
);
}
@@ -171,6 +171,7 @@ export function renderMemoryDetailText(document: MemoryDetailDocument): string {
}
const lines = [
`记忆 ${memory.memory_id}`,
`场景:${memory.scene} 类型:${memory.kind}${memory.variant ? `(${memory.variant})` : ""}`,
`状态:${memory.status} 层:${memory.layer} 来源:${memory.source ?? "未知"}`,
`owner:${memory.owner ?? "未知"} latest:${memory.is_latest}`,
`raw IDs:${(memory.source_raw_ids as string[]).join(",") || "无"}`,
@@ -188,6 +189,10 @@ function describeMemory(memory: SdkMemoryRecord): Record<string, unknown> {
const budgeted = budget(memory.content ?? "");
return {
memory_id: memory.memory_id,
// Pre-2.0.6 Core listed chat memories only and named no scene.
scene: memory.scene ?? "chat",
kind: memory.kind ?? "chat",
variant: memory.variant ?? null,
status: memory.status,
layer: memory.layer,
is_latest: memory.is_latest,
@@ -231,8 +236,9 @@ function sdkMemory(value: unknown): SdkMemoryRecord | null {
!strings(value.superseded_by) ||
!strings(value.tags) ||
!strings(value.source_raw_ids) ||
!text(value.user_id) ||
!text(value.agent_id)
// Working/coding memories may carry an empty user or agent id.
typeof value.user_id !== "string" ||
typeof value.agent_id !== "string"
)
return null;
return value as unknown as SdkMemoryRecord;
@@ -697,6 +697,66 @@ describe("CLI-21 memory reads", () => {
expect(report.memories[1].content.length).toBeLessThan(400);
});
it("lists working and coding memories with their scene and kind", async () => {
transport.get.mockResolvedValue({
status: 200,
data: {
total: 2,
offset: 0,
limit: 20,
totals: { chat: 1, working: 1, coding: 0 },
window_id: null,
window_reset: false,
memories: [
memory("m1", "对话记忆"),
{
...memory("e1", "修复测试\n固定随机种子"),
layer: "experience",
agent_id: "",
owner: null,
source_raw_ids: [],
scene: "working",
kind: "experience",
variant: "working",
task: "修复测试",
solution: "固定随机种子",
created_at: 1_790_000_000,
updated_at: 1_790_000_100,
},
],
},
});
const streams = captureStreams();
try {
expect(await run(["memories", "list", "--json"], {})).toBe(0);
} finally {
streams.restore();
}
const report = JSON.parse(streams.stdout());
expect(report.page).toMatchObject({ state: "ok", total: 2, returned: 2 });
expect(report.memories[0]).toMatchObject({
memory_id: "m1",
scene: "chat",
kind: "chat",
variant: null,
});
expect(report.memories[1]).toMatchObject({
memory_id: "e1",
scene: "working",
kind: "experience",
variant: "working",
layer: "experience",
});
const text = captureStreams();
try {
expect(await run(["memories", "list"], {})).toBe(0);
} finally {
text.restore();
}
expect(text.stdout()).toContain("- e1 [working] active/experience");
});
it("passes the requested filters through as query parameters", async () => {
await run(
[
+1 -1
View File
@@ -12,7 +12,7 @@ dependencies = [
"fastapi>=0.141.1",
"filelock>=3.32.4",
# Public SDK version validated against MemHub's permission and recall contracts.
"hy-memory==2.0.5",
"hy-memory==2.0.6",
# chromadb arrives through the HY SDK; locked here so an unrelated upgrade
# cannot move the SDK's store. Built-in embedding consumes numpy directly.
"chromadb==1.5.9",
+204 -57
View File
@@ -91,6 +91,7 @@ from one_memory.hy_runtime_config import (
set_diagnostic_log_level,
)
from one_memory.hy_sdk_runtime import (
AmbiguousMemoryIdError,
SdkClientHandle,
builtin_retrieval_snapshot,
close_all_shared_runtimes,
@@ -105,6 +106,7 @@ from one_memory.hy_sdk_types import (
HySdkRawRecord,
HySdkReadError,
HySdkUserProfile,
LibraryHit,
library_keyword_hits,
parse_sdk_raw_search,
parse_sdk_search,
@@ -158,6 +160,7 @@ from one_memory.imports.sources.registry import (
)
from one_memory.imports.storage import EvidenceStore
from one_memory.imports.supervisor import ImportSupervisor
from one_memory.library_search_window import LibrarySearchWindows, window_key
from one_memory.library_upgrade import LibraryUpgradeError, prepare_library
from one_memory.loop_watchdog import LoopWatchdog
from one_memory.memory_governance import (
@@ -402,10 +405,20 @@ class ConnectSourcesResponse(BaseModel):
request_id: str | None = None
SdkMemoryKind = Literal["chat", "experience", "fact"]
SdkMemoryVariant = Literal["working", "coding"]
class SdkMemoryUpdateRequest(BaseModel):
model_config = ConfigDict(extra="forbid")
# Chat: the memory text. Experience: the solution. Fact: the statement.
content: str = Field(min_length=1, max_length=200_000)
# Optional namespace, as listed on the memory (`kind`, `variant`).
kind: SdkMemoryKind | None = None
variant: SdkMemoryVariant | None = None
# Experiences only: also replace the task.
task: str | None = Field(default=None, min_length=1, max_length=200_000)
class SdkUserProfileResponse(BaseModel):
@@ -2841,8 +2854,21 @@ def create_app(
source: str | None = Query(default=None, min_length=1),
query: str | None = Query(default=None, max_length=512),
offset: int = Query(default=0, ge=0),
limit: int = Query(default=100, ge=1, le=500),
limit: int = Query(default=LIBRARY_PAGE_SIZE, ge=1, le=500),
window_id: str | None = Query(default=None, min_length=1, max_length=64),
) -> HySdkMemoryPage:
"""One page of memories across chat, working and coding.
Without `query`: `list_memories(scene="all")`, newest update first;
`total` is the SDK's count across the three scenes.
With `query`: keyword search over one SDK relevance window (see
`_library_search_page`). The first request (no `window_id`) searches
and returns a `window_id`; later pages pass it back with their offset
and are cut from the same matches, so pages never overlap or skip. A
`window_id` that expired or was searched with other filters searches
again and answers page 1 with `window_reset: true`.
"""
try:
client = await _sdk_client_handle(app).client()
search_query = (query or "").strip()
@@ -2852,59 +2878,25 @@ def create_app(
status_code=422,
detail="sdk_search_supports_active_status_only",
)
# SDK search has no count-only contract and, on the local store,
# no keyword channel. Ask for one bounded relevance window with
# no score floor, then keep the rows that contain the words, so
# a word in the library is found and a word in none of it finds
# nothing. The whole library is never listed in Core.
# A larger window made one search take long enough to freeze
# the page; 20 is the bounded window the library searches.
fetch = 20
search_raw = await asyncio.to_thread(
client.search,
search_query,
user_ids=["local"],
agent_ids=[],
include_sources=[source] if source else None,
limit=fetch,
min_score=0.0,
schema_min_score=0.0,
schema_limit=fetch,
# The library lists stored memories; proactive intentions
# are a recall-time lane the list never shows.
intention_limit=0,
scene="normal",
reader=HY_MEMORY_READER,
)
hit_ids, window = library_keyword_hits(search_raw, search_query)
rows = await _get_sdk_rows(client, list(hit_ids[offset : offset + limit]))
memories: list[HySdkMemoryRecord] = []
skipped = 0
for row in rows:
if row is None:
continue
try:
record = HySdkMemoryRecord.from_sdk(row)
except HySdkReadError:
skipped += 1
continue
if record.status == "active":
memories.append(record)
_log_skipped_sdk_memories(skipped)
return HySdkMemoryPage(
memories=tuple(memories),
total=len(hit_ids),
limit=limit,
return await _library_search_page(
app,
client,
query=search_query,
source=source,
statuses=list(statuses),
offset=offset,
search_window=fetch if window >= fetch else None,
limit=limit,
window_id=window_id,
)
raw = await asyncio.to_thread(
client.list_memories,
user_id="local",
scene="all",
statuses=list(statuses),
include_sources=[source] if source else None,
offset=offset,
limit=limit,
order="desc",
)
page, skipped = HySdkMemoryPage.from_sdk_skipping_invalid(
raw, limit=limit, offset=offset
@@ -3072,9 +3064,18 @@ def create_app(
response_model=HySdkMemoryRecord,
dependencies=protected,
)
async def get_sdk_memory(memory_id: str) -> HySdkMemoryRecord:
async def get_sdk_memory(
memory_id: str,
kind: SdkMemoryKind | None = None,
variant: SdkMemoryVariant | None = None,
) -> HySdkMemoryRecord:
_check_memory_namespace(kind, variant)
try:
memory = await _get_sdk_memory(_sdk_client_handle(app), memory_id)
memory = await _get_sdk_memory(
_sdk_client_handle(app), memory_id, kind=kind, variant=variant
)
except AmbiguousMemoryIdError:
raise HTTPException(status_code=409, detail="memory_id_ambiguous") from None
except Exception as error:
raise _sdk_http_error(error, "hy_memory_get_failed") from None
if memory is None:
@@ -3090,13 +3091,40 @@ def create_app(
memory_id: str,
request: SdkMemoryUpdateRequest,
) -> HySdkMemoryRecord:
_check_memory_namespace(request.kind, request.variant)
if request.task is not None and request.kind in {"chat", "fact"}:
raise HTTPException(status_code=422, detail="memory_task_requires_experience")
namespace = _namespace_kwargs(request.kind, request.variant)
try:
client = await app.state.sdk_write_coordinator.client()
result = await asyncio.to_thread(client.update, memory_id, request.content)
if not isinstance(result, dict) or result.get("success") is not True:
update_kwargs: dict[str, object] = dict(namespace)
if request.task is not None:
update_kwargs["task"] = request.task
result = await asyncio.to_thread(
client.update, memory_id, request.content, **update_kwargs
)
if not isinstance(result, dict):
raise HySdkError("hy_memory_update_rejected")
raw = await asyncio.to_thread(client.get, memory_id)
if result.get("success") is not True:
if "not found" in str(result.get("error_message") or ""):
raise HTTPException(status_code=404, detail="memory_not_found")
raise HySdkError("hy_memory_update_rejected")
# The SDK names the namespace it wrote; read that one back.
if result.get("kind") in {"experience", "fact"}:
namespace = {"kind": result["kind"]}
if result.get("variant") in {"working", "coding"}:
namespace["variant"] = result["variant"]
raw = await asyncio.to_thread(client.get, memory_id, **namespace)
memory = None if raw is None else HySdkMemoryRecord.from_sdk(raw)
except HTTPException:
raise
except AmbiguousMemoryIdError:
raise HTTPException(status_code=409, detail="memory_id_ambiguous") from None
except ValueError as error:
if request.task is None:
raise _sdk_http_error(error, "hy_memory_update_failed") from None
# The SDK refuses `task` for anything but an experience.
raise HTTPException(status_code=422, detail="memory_task_requires_experience") from None
except Exception as error:
raise _sdk_http_error(error, "hy_memory_update_failed") from None
if memory is None:
@@ -3108,10 +3136,17 @@ def create_app(
status_code=204,
dependencies=protected,
)
async def delete_sdk_memory(memory_id: str) -> Response:
async def delete_sdk_memory(
memory_id: str,
kind: SdkMemoryKind | None = None,
variant: SdkMemoryVariant | None = None,
) -> Response:
_check_memory_namespace(kind, variant)
try:
client = await app.state.sdk_write_coordinator.client()
result = await asyncio.to_thread(client.delete, memory_id)
result = await asyncio.to_thread(
client.delete, memory_id, **_namespace_kwargs(kind, variant)
)
if not isinstance(result, dict):
raise HySdkError("hy_memory_delete_rejected")
error_code = result.get("error_code")
@@ -3119,6 +3154,8 @@ def create_app(
raise HySdkError(str(error_code or "hy_memory_delete_incomplete"))
if result.get("success") is not True and result.get("deleted_count") != 0:
raise HySdkError("hy_memory_delete_rejected")
except AmbiguousMemoryIdError:
raise HTTPException(status_code=409, detail="memory_id_ambiguous") from None
except Exception as error:
raise _sdk_http_error(error, "hy_memory_delete_failed") from None
return Response(status_code=204)
@@ -4438,25 +4475,135 @@ def _log_skipped_sdk_memories(count: int) -> None:
_SDK_GET_CONCURRENCY = 8
async def _get_sdk_rows(client: Any, memory_ids: list[str]) -> list[object]:
async def _get_sdk_rows(client: Any, hits: list[LibraryHit]) -> list[object]:
gate = asyncio.Semaphore(_SDK_GET_CONCURRENCY)
async def one(memory_id: str) -> object:
async def one(hit: LibraryHit) -> object:
async with gate:
return await asyncio.to_thread(client.get, memory_id)
# Name the namespace the search found the row in, so an id that
# also exists elsewhere cannot make the read ambiguous.
return await asyncio.to_thread(
client.get, hit.memory_id, **_namespace_kwargs(hit.kind, hit.variant)
)
return list(await asyncio.gather(*(one(memory_id) for memory_id in memory_ids)))
return list(await asyncio.gather(*(one(hit) for hit in hits)))
async def _get_sdk_memory(
handle: SdkClientHandle,
memory_id: str,
*,
kind: str | None = None,
variant: str | None = None,
) -> HySdkMemoryRecord | None:
client = await handle.client()
raw = await asyncio.to_thread(client.get, memory_id)
raw = await asyncio.to_thread(client.get, memory_id, **_namespace_kwargs(kind, variant))
return None if raw is None else HySdkMemoryRecord.from_sdk(raw)
def _namespace_kwargs(kind: str | None, variant: str | None) -> dict[str, str]:
"""The SDK's kind/variant arguments, only the ones the caller gave."""
namespace: dict[str, str] = {}
if kind is not None:
namespace["kind"] = kind
if variant is not None:
namespace["variant"] = variant
return namespace
def _check_memory_namespace(kind: str | None, variant: str | None) -> None:
if kind == "chat" and variant is not None:
raise HTTPException(status_code=422, detail="memory_variant_requires_productivity_kind")
# Keyword search reads one SDK relevance window this large, once per window.
LIBRARY_SEARCH_WINDOW = 200
# The memory panel's page size; the API still accepts 1..500 for the CLI.
LIBRARY_PAGE_SIZE = 20
def _library_search_windows(app: FastAPI) -> LibrarySearchWindows:
windows = getattr(app.state, "library_search_windows", None)
if windows is None:
windows = LibrarySearchWindows()
app.state.library_search_windows = windows
return cast(LibrarySearchWindows, windows)
async def _library_search_page(
app: FastAPI,
client: Any,
*,
query: str,
source: str | None,
statuses: list[str],
offset: int,
limit: int,
window_id: str | None,
) -> HySdkMemoryPage:
"""One page of a keyword search, cut from a cached result window.
SDK search has no count-only contract and, on the local store, no keyword
channel. The search asks once for a bounded relevance window across chat,
working and coding (no reranking, no score floor), keeps the rows that
contain every word, and caches those matches under a `window_id`. Every
page of that search is a slice of the same matches, read fresh from the
SDK so edits and deletes show. The whole library is never listed in Core.
"""
windows = _library_search_windows(app)
key = window_key(query, source, statuses)
window = windows.get(window_id, key) if window_id else None
window_reset = window_id is not None and window is None
if window is None:
fetch = LIBRARY_SEARCH_WINDOW
search_raw = await asyncio.to_thread(
client.search,
query,
scene="all",
rerank=False,
user_ids=["local"],
agent_ids=[],
include_sources=[source] if source else None,
limit=fetch,
min_score=0.0,
working_fact_min_score=0.0,
working_experience_min_score=0.0,
schema_min_score=0.0,
schema_limit=fetch,
# The library lists stored memories; proactive intentions are a
# recall-time lane the list never shows.
intention_limit=0,
reader=HY_MEMORY_READER,
)
hits, held = library_keyword_hits(search_raw, query)
window = windows.put(key, hits, fetch if held >= fetch else None)
if window_reset:
offset = 0
rows = await _get_sdk_rows(client, list(window.hits[offset : offset + limit]))
memories: list[HySdkMemoryRecord] = []
skipped = 0
for row in rows:
if row is None:
continue
try:
record = HySdkMemoryRecord.from_sdk(row)
except HySdkReadError:
skipped += 1
continue
if record.status == "active":
memories.append(record)
_log_skipped_sdk_memories(skipped)
return HySdkMemoryPage(
memories=tuple(memories),
total=len(window.hits),
limit=limit,
offset=offset,
search_window=window.search_window,
window_id=window.window_id,
window_reset=window_reset,
)
async def _search_sdk_memories(
handle: SdkClientHandle,
query: str,
@@ -14,7 +14,7 @@ from pathlib import Path
from typing import Literal, cast
HY_MEMORY_DISTRIBUTION = "hy-memory"
HY_MEMORY_VERSION = "2.0.5"
HY_MEMORY_VERSION = "2.0.6"
HY_MEMORY_NAMESPACE = "governed-v2"
DiagnosticLogLevel = Literal["error", "warning", "info", "debug"]
@@ -17,6 +17,9 @@ from pathlib import Path
from typing import Any, ClassVar
from hy_memory import ( # type: ignore[import-untyped]
AmbiguousMemoryIdError as AmbiguousMemoryIdError, # re-export for api.py
)
from hy_memory import (
HyMemoryClient,
MemoryConfig,
SharedRuntime,
+243 -21
View File
@@ -3,6 +3,8 @@
from __future__ import annotations
from collections.abc import Mapping
from datetime import UTC, datetime
from typing import Literal, NamedTuple, cast
from pydantic import BaseModel, ConfigDict, Field
@@ -29,7 +31,21 @@ class HySdkReadError(HySdkError):
"""A public SDK read operation failed or returned an invalid shape."""
MemoryScene = Literal["chat", "working", "coding"]
MemoryKindName = Literal["chat", "experience", "fact"]
MemoryVariantName = Literal["working", "coding"]
class HySdkMemoryRecord(BaseModel):
"""One memory of any scene, in the unified shape the library lists.
Chat memories keep every field they always had. Working/coding memories
(experiences and facts) carry ``scene``/``kind``/``variant`` so a client
can open, edit and delete them again without guessing the namespace; an
experience also keeps ``task`` and ``solution`` apart, and ``content`` is
the readable ``task\nsolution`` text the SDK lists.
"""
model_config = ConfigDict(frozen=True, extra="forbid")
memory_id: str
@@ -48,17 +64,36 @@ class HySdkMemoryRecord(BaseModel):
source: str | None = None
memory_at: int | None = None
gmt_created: int | None = None
scene: MemoryScene = "chat"
kind: MemoryKindName = "chat"
# The productivity namespace ("working" | "coding"); None for chat. Pass it
# back with `kind` on get/update/delete.
variant: MemoryVariantName | None = None
task: str | None = None
solution: str | None = None
# Epoch seconds. `updated_at` falls back to `created_at`.
created_at: int | None = None
updated_at: int | None = None
@classmethod
def from_sdk(cls, raw: object) -> HySdkMemoryRecord:
if not isinstance(raw, Mapping):
raise HySdkReadError("hy_memory_record_shape_unknown")
if raw.get("kind") in {"experience", "fact"}:
return cls._from_productivity(raw)
if any(
not isinstance(raw.get(key), str) or not str(raw[key]).strip()
for key in ("memory_id", "content")
):
raise HySdkReadError("hy_memory_record_shape_unknown")
try:
gmt_created = (
raw.get("gmt_created") if isinstance(raw.get("gmt_created"), int) else None
)
created_at = _epoch(raw.get("created_at"))
if created_at is None:
created_at = gmt_created
updated_at = _epoch(raw.get("updated_at"))
return cls(
memory_id=str(raw["memory_id"]),
content=str(raw["content"]),
@@ -77,9 +112,69 @@ class HySdkMemoryRecord(BaseModel):
),
source=str(raw["source"]) if isinstance(raw.get("source"), str) else None,
memory_at=raw.get("memory_at") if isinstance(raw.get("memory_at"), int) else None,
gmt_created=(
raw.get("gmt_created") if isinstance(raw.get("gmt_created"), int) else None
gmt_created=gmt_created,
created_at=created_at,
updated_at=updated_at if updated_at is not None else created_at,
)
except (KeyError, TypeError, ValueError) as error:
raise HySdkReadError("hy_memory_record_shape_unknown") from error
@classmethod
def _from_productivity(cls, raw: Mapping[str, object]) -> HySdkMemoryRecord:
"""An experience or fact from list/search(scene="all") or get().
get() names the namespace in ``variant`` (its ``scene`` is the legacy
"productivity"); list/search name it in ``scene``.
"""
kind = str(raw["kind"])
variant = next(
(
value
for value in (raw.get("variant"), raw.get("scene"))
if value in {"working", "coding"}
),
None,
)
id_key = "experience_id" if kind == "experience" else "fact_id"
memory_id = raw.get("memory_id") or raw.get(id_key)
if variant is None or not isinstance(memory_id, str) or not memory_id.strip():
raise HySdkReadError("hy_memory_record_shape_unknown")
task: str | None = None
solution: str | None = None
if kind == "experience":
task = str(raw.get("task") or "").strip()
solution = str(raw.get("solution") or "").strip()
content = "\n".join(part for part in (task, solution) if part)
else:
statement = raw.get("statement")
content = str(statement if isinstance(statement, str) else raw.get("content") or "")
if not content.strip():
raise HySdkReadError("hy_memory_record_shape_unknown")
status = str(raw.get("status") or "active")
created_at = _epoch(raw.get("created_at"))
updated_at = _epoch(raw.get("updated_at"))
try:
return cls(
memory_id=memory_id,
content=content,
layer=kind,
status=status,
is_latest=status == "active",
tags=_string_tuple(raw.get("tags")),
user_id=str(raw.get("user_id") or ""),
agent_id=str(raw.get("agent_id") or ""),
session_id=(
str(raw["session_id"]) if isinstance(raw.get("session_id"), str) else None
),
source=str(raw["source"]) if isinstance(raw.get("source"), str) else None,
gmt_created=created_at,
scene=cast(MemoryScene, variant),
kind=cast(MemoryKindName, kind),
variant=cast(MemoryVariantName, variant),
task=task,
solution=solution,
created_at=created_at,
updated_at=updated_at if updated_at is not None else created_at,
)
except (KeyError, TypeError, ValueError) as error:
raise HySdkReadError("hy_memory_record_shape_unknown") from error
@@ -95,6 +190,15 @@ class HySdkMemoryPage(BaseModel):
# Set on a search whose relevance window was full: matches were looked for
# only among this many most relevant memories, so `total` may undercount.
search_window: int | None = None
# Keyword search only: the server-side result window this page was cut
# from. Pass it back with the next offset to page the same results.
window_id: str | None = None
# True when the window_id the client sent had expired or did not match the
# query: the search ran again and this is page 1 of the new window.
window_reset: bool = False
# Empty-query listing only: how many memories each scene holds under the
# same filters (`total` is their sum).
totals: dict[str, int] | None = None
@classmethod
def from_sdk(cls, raw: object, *, limit: int, offset: int) -> HySdkMemoryPage:
@@ -123,6 +227,8 @@ class HySdkMemoryPage(BaseModel):
) -> tuple[HySdkMemoryPage, int]:
if not isinstance(raw, Mapping):
raise HySdkReadError("hy_memory_list_shape_unknown")
if raw.get("scene") == "all":
return cls._parse_all_scenes(raw, limit=limit, offset=offset, skip_invalid=skip_invalid)
rows: list[object] = []
total = 0
for bucket_name, rows_name in (("vdb", "memories"), ("graph", "nodes")):
@@ -163,6 +269,62 @@ class HySdkMemoryPage(BaseModel):
)
return page, skipped
@classmethod
def _parse_all_scenes(
cls,
raw: Mapping[str, object],
*,
limit: int,
offset: int,
skip_invalid: bool,
) -> tuple[HySdkMemoryPage, int]:
"""``list_memories(scene="all")``: chat, working and coding, newest first."""
rows = raw.get("memories")
total = raw.get("total")
if (
not isinstance(rows, list)
or not isinstance(total, int)
or isinstance(total, bool)
or total < 0
):
raise HySdkReadError("hy_memory_list_shape_unknown")
totals_raw = raw.get("totals")
totals = (
{
str(scene): count
for scene, count in totals_raw.items()
if isinstance(count, int) and not isinstance(count, bool) and count >= 0
}
if isinstance(totals_raw, Mapping)
else None
)
memories: list[HySdkMemoryRecord] = []
seen: set[tuple[str, str, str | None]] = set()
skipped = 0
for row in rows:
try:
record = HySdkMemoryRecord.from_sdk(row)
except HySdkReadError:
if not skip_invalid:
raise
skipped += 1
continue
key = (record.memory_id, record.kind, record.variant)
if key in seen:
continue
seen.add(key)
memories.append(record)
return (
cls(
memories=tuple(memories),
total=total,
limit=limit,
offset=offset,
totals=totals,
),
skipped,
)
class HySdkRawRecord(BaseModel):
model_config = ConfigDict(frozen=True, extra="forbid")
@@ -327,38 +489,79 @@ def parse_sdk_search(raw: object, *, error_code: str) -> tuple[SemanticMemoryHit
return tuple(found)
def library_keyword_hits(raw: object, query: str) -> tuple[tuple[str, ...], int]:
"""Ids in an SDK search window whose text contains every query term.
class LibraryHit(NamedTuple):
"""One keyword match in a library search window, with its namespace."""
memory_id: str
kind: MemoryKindName
variant: MemoryVariantName | None
def library_keyword_hits(raw: object, query: str) -> tuple[tuple[LibraryHit, ...], int]:
"""Memories in an SDK search window whose text contains every query term.
The local SDK store has no keyword channel (Chroma's keyword search is
empty), so search is pure vector similarity: a word in the library can
score below the floor, and a word in none of it still scores above. The
library search asks the SDK for its most relevant window and keeps only the
rows that actually contain the words, most relevant first. Returns those ids
and how many distinct rows the window held.
rows that actually contain the words, most relevant first. Returns those
hits and how many distinct rows the window held.
A ``search(scene="all")`` window mixes chat memories with working/coding
experiences and facts; their text is the unified ``content`` (an
experience's task and solution, a fact's statement). The fused order is
kept as the SDK ranked it.
"""
rows = _reply_rows(raw, "hy_memory_search_shape_unknown")
terms = query.casefold().split()
seen: set[str] = set()
matched: list[str] = []
for item in sorted(rows, key=_score, reverse=True):
memory_id = item.get("memory_id") or item.get("id")
if not isinstance(memory_id, str) or not memory_id or memory_id in seen:
if isinstance(raw, Mapping) and raw.get("scene") == "all":
rows = raw.get("memories")
if not isinstance(rows, list) or any(not isinstance(row, Mapping) for row in rows):
raise HySdkReadError("hy_memory_search_shape_unknown")
ordered: list[Mapping[str, object]] = list(rows)
else:
ordered = sorted(
_reply_rows(raw, "hy_memory_search_shape_unknown"), key=_score, reverse=True
)
seen: set[LibraryHit] = set()
matched: list[LibraryHit] = []
for item in ordered:
hit = _library_hit(item)
if hit is None or hit in seen:
continue
seen.add(memory_id)
content = item.get("content")
seen.add(hit)
tags = item.get("tags")
text = " ".join(
[
content if isinstance(content, str) else "",
*(tag for tag in (tags if isinstance(tags, list) else ()) if isinstance(tag, str)),
]
).casefold()
parts = [item.get(key) for key in ("content", "task", "solution", "statement")] + list(
tags if isinstance(tags, list) else ()
)
text = " ".join(part for part in parts if isinstance(part, str)).casefold()
if all(term in text for term in terms):
matched.append(memory_id)
matched.append(hit)
return tuple(matched), len(seen)
def _library_hit(item: Mapping[str, object]) -> LibraryHit | None:
kind = item.get("kind")
if kind in {"experience", "fact"}:
variant = item.get("variant") if item.get("variant") in {"working", "coding"} else None
variant = variant or (
item.get("scene") if item.get("scene") in {"working", "coding"} else None
)
memory_id = item.get("memory_id") or item.get(
"experience_id" if kind == "experience" else "fact_id"
)
if variant is None or not isinstance(memory_id, str) or not memory_id:
return None
return LibraryHit(
memory_id,
"experience" if kind == "experience" else "fact",
"coding" if variant == "coding" else "working",
)
memory_id = item.get("memory_id") or item.get("id")
if not isinstance(memory_id, str) or not memory_id:
return None
return LibraryHit(memory_id, "chat", None)
def parse_sdk_raw_search(raw: object) -> tuple[HySdkRawHit, ...]:
return tuple(
HySdkRawHit.from_sdk_search(row)
@@ -366,6 +569,25 @@ def parse_sdk_raw_search(raw: object) -> tuple[HySdkRawHit, ...]:
)
def _epoch(value: object) -> int | None:
"""Epoch seconds from an int or an ISO-8601 string (naive means UTC)."""
if isinstance(value, bool):
return None
if isinstance(value, int):
return value
if isinstance(value, float):
return int(value)
if isinstance(value, str) and value.strip():
try:
parsed = datetime.fromisoformat(value.strip())
except ValueError:
return None
if parsed.tzinfo is None:
parsed = parsed.replace(tzinfo=UTC)
return int(parsed.timestamp())
return None
def _string_tuple(value: object) -> tuple[str, ...]:
if not isinstance(value, list):
return ()
@@ -0,0 +1,110 @@
"""Short-lived result windows for the memory library's keyword search.
A library search asks the SDK once for a bounded relevance window and keeps
the keyword matches. Paging must cut every page from that same list: running
the search again per page can rank differently and duplicate or skip memories
between pages. The matches are kept here under an opaque ``window_id`` that
the client passes back with the next offset.
A window is bound to the filters it was searched with (query, source,
statuses). It expires after ``ttl_seconds`` without being read, and the store
keeps at most ``max_windows`` windows, dropping the least recently read first.
Only memory identities are kept, never content: each page is read fresh from
the SDK, so an edit or a delete shows on the next page read.
"""
from __future__ import annotations
import secrets
import threading
import time
from collections import OrderedDict
from collections.abc import Callable
from dataclasses import dataclass, field
from one_memory.hy_sdk_types import LibraryHit
DEFAULT_WINDOW_TTL_SECONDS = 300.0
DEFAULT_MAX_WINDOWS = 32
WindowKey = tuple[str, str | None, tuple[str, ...]]
def window_key(query: str, source: str | None, statuses: list[str]) -> WindowKey:
return (query, source, tuple(sorted(set(statuses))))
@dataclass(frozen=True)
class SearchWindow:
window_id: str
key: WindowKey
hits: tuple[LibraryHit, ...]
# Set when the SDK window was full, so matches outside it were not looked
# for and `total` may undercount.
search_window: int | None
@dataclass
class _Entry:
window: SearchWindow
last_read: float = field(default=0.0)
class LibrarySearchWindows:
def __init__(
self,
*,
ttl_seconds: float = DEFAULT_WINDOW_TTL_SECONDS,
max_windows: int = DEFAULT_MAX_WINDOWS,
clock: Callable[[], float] = time.monotonic,
) -> None:
self._ttl = ttl_seconds
self._max = max(1, max_windows)
self._clock = clock
self._entries: OrderedDict[str, _Entry] = OrderedDict()
self._lock = threading.Lock()
def get(self, window_id: str, key: WindowKey) -> SearchWindow | None:
"""The window, if it is still live and was searched with `key`."""
with self._lock:
now = self._clock()
self._expire(now)
entry = self._entries.get(window_id)
if entry is None or entry.window.key != key:
return None
entry.last_read = now
self._entries.move_to_end(window_id)
return entry.window
def put(
self,
key: WindowKey,
hits: tuple[LibraryHit, ...],
search_window: int | None,
) -> SearchWindow:
window = SearchWindow(
window_id=secrets.token_urlsafe(12),
key=key,
hits=hits,
search_window=search_window,
)
with self._lock:
now = self._clock()
self._expire(now)
self._entries[window.window_id] = _Entry(window=window, last_read=now)
while len(self._entries) > self._max:
self._entries.popitem(last=False)
return window
def __len__(self) -> int:
with self._lock:
return len(self._entries)
def _expire(self, now: float) -> None:
stale = [
window_id
for window_id, entry in self._entries.items()
if now - entry.last_read >= self._ttl
]
for window_id in stale:
del self._entries[window_id]
+3 -1
View File
@@ -129,6 +129,7 @@
| `test_intake_dedupe.py` | `contract` | A4-07: hook and scan share one identity, so order cannot duplicate. |
| `test_internal_preset.py` | `contract` | The optional model preset a configured build carries. |
| `test_jsonl_snapshot_tail.py` | `contract` | Bounded EOF handling on synthetic files only, never original host history. |
| `test_library_search_window.py` | `unit` | The library search window store and the unified record/keyword parsing. |
| `test_live_capture.py` | `integration` | Visible hook text lands in Evidence before the transcript file is flushed. |
| `test_live_sync.py` | `contract` | 覆盖 live sync preempts a reader batch before it emits work、live sync cuts in front of a running history import、live sync does not resume a history import the user paused 等 24 个场景。 |
| `test_live_sync_file_incremental.py` | `contract` | 覆盖 unchanged file metadata avoids a job and content read、changed file content is read once by the import job、workbuddy first live poll baselines history then imports only append 等 6 个场景。 |
@@ -156,6 +157,7 @@
| `test_memory_content_import.py` | `integration` | 覆盖 existing memory preview keeps unknown author and exact original、invalid existing memory records never leave a partial upload、memory content requires explicit mode and rejects vendor guessing 等 5 个场景。 |
| `test_memory_evidence_freshness.py` | `contract` | 新鲜度拆除后:读路径恒为 current;证据修订 / 畸形 proof / segment ref 不再藏记忆或 503。 |
| `test_memory_freshness_integration_review.py` | `integration` | Independent synthetic read-path review; no lifespan, model or user database. |
| `test_memory_library_all_scenes.py` | `integration` | The v2 memory library across chat, working and coding memories. |
| `test_memory_notes.py` | `integration` | 覆盖 empty library can save a note without a matter or configured model、note requires authentication explicit scope and nonempty content、saved note evidence returns raw and consent reference from same record 等 6 个场景。 |
| `test_memory_qa.py` | `contract` | 覆盖 search terms keep meaningful single chinese character、streamed answer emits recall then incremental grounded text、governance change during generation stops the old answer 等 14 个场景。 |
| `test_memory_qa_api.py` | `integration` | 覆盖 ask stream endpoint emits recall deltas and final、ask stream endpoint requires the local token 场景。 |
@@ -304,4 +306,4 @@
| `test_workspace_recall.py` | `contract` | Workspace memories were unreachable from every host; these pin the way in. |
| `test_write_queue_lease_renewal.py` | `contract` | Rule 8: a lease must outlive the work it covers, and be renewed while it runs. |
共 299 个测试文件。
共 301 个测试文件。
@@ -14,8 +14,8 @@ from pathlib import Path
import pytest
PRIMARY_MARKERS = ("unit", "contract", "integration", "performance", "live")
EXPECTED_TEST_FILE_COUNT = 299
EXPECTED_TEST_FILES_SHA256 = "7b6afc4f9da9d5ba2829639f38d964f89c78bb71d9cadae3dba9b4aad91e3a2c"
EXPECTED_TEST_FILE_COUNT = 301
EXPECTED_TEST_FILES_SHA256 = "1e5f3a85f124a26ed67861e07fba7c670512a82139db64c1b7ba767ca37ed563"
# Pure transformations, validation models, parity checks and state machines.
# This list is intentionally small: uncertain files default to contract.
@@ -31,6 +31,7 @@ UNIT_FILES = frozenset(
"test_graph_width_guard.py",
"test_host_capabilities.py",
"test_input_sanitization.py",
"test_library_search_window.py",
"test_memory_server_config_state.py",
"test_memory_server_contracts.py",
"test_memory_server_job_state.py",
@@ -77,6 +78,7 @@ INTEGRATION_FILES = frozenset(
"test_measure_c1_loop_blocking_contract.py",
"test_memory_content_import.py",
"test_memory_freshness_integration_review.py",
"test_memory_library_all_scenes.py",
"test_memory_notes.py",
"test_memory_qa_api.py",
"test_memory_server_durable_queue.py",
@@ -0,0 +1,118 @@
"""The library search window store and the unified record/keyword parsing."""
from one_memory.hy_sdk_types import HySdkMemoryPage, HySdkMemoryRecord, library_keyword_hits
from one_memory.library_search_window import LibrarySearchWindows, window_key
def test_window_is_bound_to_its_filters_and_expires_when_idle():
now = [0.0]
windows = LibrarySearchWindows(ttl_seconds=10, clock=lambda: now[0])
key = window_key("deploy", None, ["active"])
window = windows.put(key, (), None)
assert windows.get(window.window_id, key) is window
assert windows.get(window.window_id, window_key("deploy", "cursor", ["active"])) is None
now[0] = 9.0
assert windows.get(window.window_id, key) is window # reading keeps it alive
now[0] = 18.0
assert windows.get(window.window_id, key) is window
now[0] = 28.5
assert windows.get(window.window_id, key) is None
assert len(windows) == 0
def test_window_store_is_bounded_least_recently_read_first():
windows = LibrarySearchWindows(max_windows=2)
key = window_key("q", None, ["active"])
first = windows.put(key, (), None)
second = windows.put(key, (), None)
assert windows.get(first.window_id, key) is first
third = windows.put(key, (), None)
assert len(windows) == 2
assert windows.get(second.window_id, key) is None
assert windows.get(first.window_id, key) is first
assert windows.get(third.window_id, key) is third
def test_keyword_hits_read_unified_items_in_fused_order():
raw = {
"scene": "all",
"memories": [
{"memory_id": "c1", "scene": "chat", "kind": "chat", "content": "Deploy on Fridays"},
{
"memory_id": "e1",
"experience_id": "e1",
"scene": "working",
"kind": "experience",
"content": "Deploy service\nUse blue/green",
},
{
"memory_id": "f1",
"fact_id": "f1",
"scene": "coding",
"kind": "fact",
"content": "Nothing relevant",
"statement": "Nothing relevant",
},
{"memory_id": "c1", "scene": "chat", "kind": "chat", "content": "Deploy on Fridays"},
],
}
hits, held = library_keyword_hits(raw, "deploy")
assert held == 3
assert [(hit.memory_id, hit.kind, hit.variant) for hit in hits] == [
("c1", "chat", None),
("e1", "experience", "working"),
]
def test_productivity_get_becomes_a_unified_record():
record = HySdkMemoryRecord.from_sdk(
{
"memory_id": "e1",
"kind": "experience",
"variant": "coding",
"scene": "productivity",
"content": "solution only",
"task": "the task",
"solution": "solution only",
"user_id": "local",
"agent_id": "codebuddy",
"source": "codebuddy",
"created_at": "2026-10-01T00:00:00",
"updated_at": None,
}
)
assert (record.scene, record.kind, record.variant) == ("coding", "experience", "coding")
assert record.content == "the task\nsolution only"
assert record.status == "active"
assert record.created_at == record.updated_at == 1_790_812_800
def test_all_scene_list_page_keeps_totals_and_skips_invalid_rows():
page, skipped = HySdkMemoryPage.from_sdk_skipping_invalid(
{
"scene": "all",
"memories": [
{"memory_id": "c1", "content": "chat", "scene": "chat", "kind": "chat"},
{"memory_id": "bad", "content": "", "scene": "chat", "kind": "chat"},
{
"memory_id": "f1",
"kind": "fact",
"scene": "working",
"content": "fact",
"status": "active",
},
],
"total": 3,
"totals": {"chat": 2, "working": 1, "coding": 0},
},
limit=20,
offset=0,
)
assert skipped == 1
assert page.total == 3
assert page.totals == {"chat": 2, "working": 1, "coding": 0}
assert [(m.memory_id, m.scene, m.kind) for m in page.memories] == [
("c1", "chat", "chat"),
("f1", "working", "fact"),
]
@@ -0,0 +1,551 @@
"""The v2 memory library across chat, working and coding memories.
Keyword search pages are cut from one cached SDK result window (window_id),
the empty-query list mixes the three scenes newest first, and detail, edit
and delete pass the memory's kind/variant through to the SDK.
"""
import asyncio
import pytest
from httpx import ASGITransport, AsyncClient
from hy_memory import AmbiguousMemoryIdError
from pydantic import SecretStr
from one_memory.api import create_app
from one_memory.config import Settings
from one_memory.library_search_window import LibrarySearchWindows
from one_memory.secrets import InMemorySecretStore
def application(tmp_path):
return create_app(
Settings(
data_dir=tmp_path / "data",
source_home=tmp_path,
api_token=SecretStr("library-test"),
imports_enabled=False,
automatic_memory_processing=False,
live_sync_enabled=False,
),
secret_store=InMemorySecretStore(),
)
def client_for(app):
return AsyncClient(
transport=ASGITransport(app=app),
base_url="http://test",
headers={"Authorization": "Bearer library-test"},
)
def chat_row(memory_id, content, *, source="cursor"):
return {
"memory_id": memory_id,
"content": content,
"layer": "l2_fact",
"status": "active",
"user_id": "local",
"agent_id": "all-apps",
"session_id": "s1",
"source": source,
"tags": [],
"gmt_created": 1_790_000_000,
}
def experience_row(memory_id, task, solution, *, variant="working", source="codebuddy"):
return {
"experience_id": memory_id,
"user_id": "local",
"agent_id": "codebuddy",
"task": task,
"solution": solution,
"source": source,
"created_at": "2026-10-01T08:00:00",
"updated_at": "2026-10-02T08:00:00",
"_variant": variant,
}
def fact_row(memory_id, statement, *, variant="coding", source="codebuddy"):
return {
"fact_id": memory_id,
"user_id": "local",
"agent_id": "codebuddy",
"statement": statement,
"status": "active",
"source": source,
"created_at": "2026-10-03T08:00:00",
"_variant": variant,
}
class FakeSdk:
"""Public-API shaped client holding chat, experience and fact memories."""
def __init__(self):
self.chat = {}
self.productivity = {}
self.search_calls = []
self.list_calls = []
self.get_calls = []
self.update_calls = []
self.delete_calls = []
self.reverse_next_search = False
def add_chat(self, row):
self.chat[row["memory_id"]] = row
def add_productivity(self, kind, row):
memory_id = row["experience_id"] if kind == "experience" else row["fact_id"]
self.productivity[(kind, row["_variant"], memory_id)] = row
def _unified(self, kind, variant, row):
if kind == "experience":
content = f"{row['task']}\n{row['solution']}"
memory_id = row["experience_id"]
else:
content = row["statement"]
memory_id = row["fact_id"]
return {
**{key: value for key, value in row.items() if not key.startswith("_")},
"scene": variant,
"kind": kind,
"memory_id": memory_id,
"content": content,
"created_at": 1_790_000_000,
"updated_at": 1_790_000_100,
}
def _matches(self, memory_id, kind, variant):
found = []
if kind in (None, "chat") and variant is None and memory_id in self.chat:
found.append(("chat", None, self.chat[memory_id]))
for (row_kind, row_variant, row_id), row in self.productivity.items():
if row_id != memory_id:
continue
if kind not in (None, row_kind) or variant not in (None, row_variant):
continue
found.append((row_kind, row_variant, row))
if len(found) > 1:
raise AmbiguousMemoryIdError(
memory_id,
[f"{v}:{k}" if v else k for k, v, _ in found],
)
return found[0] if found else None
def search(self, query, **kwargs):
self.search_calls.append((query, kwargs))
include = kwargs.get("include_sources") or []
items = [
{**row, "scene": "chat", "kind": "chat"}
for row in self.chat.values()
if not include or row["source"] in include
]
items += [
self._unified(kind, variant, row)
for (kind, variant, _id), row in self.productivity.items()
if not include or row["source"] in include
]
if self.reverse_next_search:
items.reverse()
items = items[: kwargs["limit"]]
for rank, item in enumerate(items, start=1):
item["score"] = round(1 / (60 + rank), 6)
return {
"request_id": "search",
"scene": "all",
"memories": items,
"lanes": {"chat": 0, "working": 0, "coding": 0},
"errors": {},
"elapsed_ms": 1.0,
}
def list_memories(self, **kwargs):
self.list_calls.append(kwargs)
assert kwargs["scene"] == "all"
include = kwargs.get("include_sources") or []
chat = [
{**row, "scene": "chat", "kind": "chat", "created_at": 10, "updated_at": 10}
for row in self.chat.values()
if not include or row["source"] in include
]
productivity = [
{**self._unified(kind, variant, row), "status": "active"}
for (kind, variant, _id), row in self.productivity.items()
if kind == "experience" and (not include or row["source"] in include)
]
rows = productivity + chat
page = rows[kwargs["offset"] : kwargs["offset"] + kwargs["limit"]]
return {
"scene": "all",
"memories": page,
"total": len(rows),
"totals": {
"chat": len(chat),
"working": sum(1 for row in productivity if row["scene"] == "working"),
"coding": sum(1 for row in productivity if row["scene"] == "coding"),
},
"errors": {},
"limit": kwargs["limit"],
"offset": kwargs["offset"],
"request_id": "list",
"elapsed_ms": 1.0,
}
def get(self, memory_id, kind=None, variant=None):
self.get_calls.append((memory_id, kind, variant))
match = self._matches(memory_id, kind, variant)
if match is None:
return None
row_kind, row_variant, row = match
if row_kind == "chat":
return {**row, "kind": "chat", "variant": None}
payload = {key: value for key, value in row.items() if not key.startswith("_")}
return {
"memory_id": memory_id,
"kind": row_kind,
"variant": row_variant,
"scene": "productivity",
"content": row["solution"] if row_kind == "experience" else row["statement"],
"layer": row_kind,
**payload,
}
def update(self, memory_id, content, kind=None, variant=None, task=None):
self.update_calls.append((memory_id, content, kind, variant, task))
match = self._matches(memory_id, kind, variant)
if match is None:
return {"success": False, "error_message": f"memory_id '{memory_id}' not found"}
row_kind, row_variant, row = match
if row_kind == "chat":
row["content"] = content
return {"success": True, "memory_id": memory_id}
if row_kind == "experience":
row["solution"] = content
if task is not None:
row["task"] = task
else:
row["statement"] = content
return {"success": True, "memory_id": memory_id, "kind": row_kind, "variant": row_variant}
def delete(self, memory_id, kind=None, variant=None):
self.delete_calls.append((memory_id, kind, variant))
match = self._matches(memory_id, kind, variant)
if match is None:
return {"success": False, "deleted_count": 0}
row_kind, row_variant, row = match
if row_kind == "chat":
del self.chat[memory_id]
else:
del self.productivity[(row_kind, row_variant, memory_id)]
return {"success": True, "deleted_count": 1}
def run_with_sdk(tmp_path, monkeypatch, scenario):
async def main():
app = application(tmp_path)
sdk = FakeSdk()
class Handle:
async def client(self):
return sdk
async def close(self):
return None
async def write_client():
return sdk
async with app.router.lifespan_context(app):
app.state.sdk_client_handle = Handle()
monkeypatch.setattr(app.state.sdk_write_coordinator, "client", write_client)
async with client_for(app) as client:
await scenario(app, sdk, client)
asyncio.run(main())
@pytest.mark.fresh_sdk
def test_search_pages_come_from_one_cached_window_without_duplicates(tmp_path, monkeypatch):
async def scenario(app, sdk, client):
for index in range(30):
sdk.add_chat(chat_row(f"chat-{index:02d}", f"deploy note {index}"))
for index in range(15):
sdk.add_productivity(
"experience",
experience_row(f"exp-{index:02d}", f"deploy task {index}", "run make"),
)
sdk.add_chat(chat_row("other", "unrelated words"))
first = await client.get("/api/v2/memory-library", params={"query": "deploy"})
assert first.status_code == 200
body = first.json()
assert body["total"] == 45
assert body["limit"] == 20
assert body["offset"] == 0
assert body["window_reset"] is False
assert body["search_window"] is None
window_id = body["window_id"]
assert window_id
assert len(sdk.search_calls) == 1
query, options = sdk.search_calls[0]
assert query == "deploy"
assert options["scene"] == "all"
assert options["rerank"] is False
assert options["limit"] == 200
assert options["min_score"] == 0.0
# A second search would now rank differently; pages must not care.
sdk.reverse_next_search = True
seen = [item["memory_id"] for item in body["memories"]]
for offset in (20, 40):
page = await client.get(
"/api/v2/memory-library",
params={"query": "deploy", "offset": offset, "window_id": window_id},
)
assert page.status_code == 200
data = page.json()
assert data["window_id"] == window_id
assert data["window_reset"] is False
assert data["offset"] == offset
assert data["total"] == 45
seen += [item["memory_id"] for item in data["memories"]]
assert len(sdk.search_calls) == 1
assert len(seen) == 45
assert len(set(seen)) == 45
scenes = {item["memory_id"]: item for item in body["memories"]}
assert {item["scene"] for item in scenes.values()} <= {"chat", "working"}
# Each productivity row is read back in the namespace it was found in.
productivity_gets = [call for call in sdk.get_calls if call[0].startswith("exp-")]
assert productivity_gets
assert all(call[1:] == ("experience", "working") for call in productivity_gets)
chat_gets = [call for call in sdk.get_calls if call[0].startswith("chat-")]
assert all(call[1:] == ("chat", None) for call in chat_gets)
run_with_sdk(tmp_path, monkeypatch, scenario)
@pytest.mark.fresh_sdk
def test_expired_or_mismatched_window_searches_again_and_restarts(tmp_path, monkeypatch):
async def scenario(app, sdk, client):
now = [1000.0]
app.state.library_search_windows = LibrarySearchWindows(
ttl_seconds=60, clock=lambda: now[0]
)
for index in range(25):
sdk.add_chat(chat_row(f"chat-{index:02d}", f"deploy note {index}"))
first = (await client.get("/api/v2/memory-library", params={"query": "deploy"})).json()
window_id = first["window_id"]
now[0] += 61
expired = await client.get(
"/api/v2/memory-library",
params={"query": "deploy", "offset": 20, "window_id": window_id},
)
data = expired.json()
assert data["window_reset"] is True
assert data["offset"] == 0
assert data["window_id"] != window_id
assert len(data["memories"]) == 20
assert len(sdk.search_calls) == 2
# A window searched for other words is not reused for this query.
mismatched = await client.get(
"/api/v2/memory-library",
params={"query": "note", "offset": 20, "window_id": data["window_id"]},
)
assert mismatched.json()["window_reset"] is True
assert mismatched.json()["offset"] == 0
unknown = await client.get(
"/api/v2/memory-library",
params={"query": "deploy", "window_id": "never-issued"},
)
assert unknown.json()["window_reset"] is True
run_with_sdk(tmp_path, monkeypatch, scenario)
@pytest.mark.fresh_sdk
def test_keyword_filter_reads_experience_and_fact_text(tmp_path, monkeypatch):
async def scenario(app, sdk, client):
sdk.add_chat(chat_row("chat-1", "lunch preferences"))
sdk.add_productivity(
"experience",
experience_row("exp-1", "Fix flaky tests", "Pin the pytest seed"),
)
sdk.add_productivity("fact", fact_row("fact-1", "The pytest suite runs in CI"))
response = await client.get(
"/api/v2/memory-library", params={"query": "PYTEST", "source": "codebuddy"}
)
body = response.json()
assert body["total"] == 2
assert sdk.search_calls[0][1]["include_sources"] == ["codebuddy"]
by_id = {item["memory_id"]: item for item in body["memories"]}
experience = by_id["exp-1"]
assert experience["scene"] == "working"
assert experience["kind"] == "experience"
assert experience["variant"] == "working"
assert experience["task"] == "Fix flaky tests"
assert experience["solution"] == "Pin the pytest seed"
assert experience["content"] == "Fix flaky tests\nPin the pytest seed"
assert experience["status"] == "active"
assert experience["updated_at"] > experience["created_at"]
fact = by_id["fact-1"]
assert (fact["scene"], fact["kind"], fact["variant"]) == ("coding", "fact", "coding")
assert fact["content"] == "The pytest suite runs in CI"
run_with_sdk(tmp_path, monkeypatch, scenario)
@pytest.mark.fresh_sdk
def test_full_search_window_is_reported(tmp_path, monkeypatch):
async def scenario(app, sdk, client):
for index in range(205):
sdk.add_chat(chat_row(f"chat-{index:03d}", f"note {index}"))
body = (await client.get("/api/v2/memory-library", params={"query": "note"})).json()
assert body["search_window"] == 200
assert body["total"] == 200
run_with_sdk(tmp_path, monkeypatch, scenario)
@pytest.mark.fresh_sdk
def test_empty_query_lists_all_scenes_with_source_filter_and_totals(tmp_path, monkeypatch):
async def scenario(app, sdk, client):
sdk.add_chat(chat_row("chat-1", "cursor chat", source="cursor"))
sdk.add_chat(chat_row("chat-2", "codebuddy chat", source="codebuddy"))
sdk.add_productivity("experience", experience_row("exp-w", "task w", "solution w"))
sdk.add_productivity(
"experience", experience_row("exp-c", "task c", "solution c", variant="coding")
)
response = await client.get("/api/v2/memory-library")
body = response.json()
assert response.status_code == 200
assert body["limit"] == 20
assert body["total"] == 4
assert body["totals"] == {"chat": 2, "working": 1, "coding": 1}
assert body["window_id"] is None
assert [item["scene"] for item in body["memories"]] == [
"working",
"coding",
"chat",
"chat",
]
call = sdk.list_calls[0]
assert call["scene"] == "all"
assert call["include_sources"] is None
assert call["statuses"] == ["active"]
assert (call["offset"], call["limit"]) == (0, 20)
filtered = await client.get(
"/api/v2/memory-library", params={"source": "codebuddy", "offset": 1}
)
data = filtered.json()
assert sdk.list_calls[1]["include_sources"] == ["codebuddy"]
assert sdk.list_calls[1]["offset"] == 1
assert data["total"] == 3
assert data["offset"] == 1
assert [item["memory_id"] for item in data["memories"]] == ["exp-c", "chat-2"]
run_with_sdk(tmp_path, monkeypatch, scenario)
@pytest.mark.fresh_sdk
def test_detail_edit_delete_pass_kind_and_variant(tmp_path, monkeypatch):
async def scenario(app, sdk, client):
sdk.add_chat(chat_row("chat-1", "hello"))
sdk.add_productivity("experience", experience_row("exp-1", "Old task", "Old solution"))
sdk.add_productivity("fact", fact_row("fact-1", "Old statement"))
detail = await client.get(
"/api/v2/memories/exp-1", params={"kind": "experience", "variant": "working"}
)
assert detail.status_code == 200
record = detail.json()
assert (record["scene"], record["kind"], record["variant"]) == (
"working",
"experience",
"working",
)
assert record["task"] == "Old task"
assert record["solution"] == "Old solution"
assert record["content"] == "Old task\nOld solution"
assert sdk.get_calls[-1] == ("exp-1", "experience", "working")
# Chat detail with no namespace keeps the plain SDK call.
chat = await client.get("/api/v2/memories/chat-1")
assert chat.json()["kind"] == "chat"
assert chat.json()["scene"] == "chat"
assert sdk.get_calls[-1] == ("chat-1", None, None)
edited = await client.put(
"/api/v2/memories/exp-1",
json={
"content": "New solution",
"task": "New task",
"kind": "experience",
"variant": "working",
},
)
assert edited.status_code == 200
assert sdk.update_calls[-1] == (
"exp-1",
"New solution",
"experience",
"working",
"New task",
)
assert edited.json()["solution"] == "New solution"
assert edited.json()["task"] == "New task"
fact = await client.put(
"/api/v2/memories/fact-1",
json={"content": "New statement", "kind": "fact", "variant": "coding"},
)
assert fact.status_code == 200
assert fact.json()["content"] == "New statement"
refused = await client.put(
"/api/v2/memories/fact-1",
json={"content": "x", "task": "y", "kind": "fact"},
)
assert refused.status_code == 422
deleted = await client.delete(
"/api/v2/memories/fact-1", params={"kind": "fact", "variant": "coding"}
)
assert deleted.status_code == 204
assert sdk.delete_calls[-1] == ("fact-1", "fact", "coding")
bad = await client.get("/api/v2/memories/exp-1", params={"kind": "nope"})
assert bad.status_code == 422
run_with_sdk(tmp_path, monkeypatch, scenario)
@pytest.mark.fresh_sdk
def test_ambiguous_memory_id_is_a_conflict(tmp_path, monkeypatch):
async def scenario(app, sdk, client):
sdk.add_chat(chat_row("dup", "chat text"))
sdk.add_productivity("experience", experience_row("dup", "task", "solution"))
for method in ("get", "delete"):
response = await getattr(client, method)("/api/v2/memories/dup")
assert response.status_code == 409
assert response.json()["detail"] == "memory_id_ambiguous"
update = await client.put("/api/v2/memories/dup", json={"content": "x"})
assert update.status_code == 409
assert update.json()["detail"] == "memory_id_ambiguous"
resolved = await client.get("/api/v2/memories/dup", params={"kind": "chat"})
assert resolved.status_code == 200
assert resolved.json()["content"] == "chat text"
run_with_sdk(tmp_path, monkeypatch, scenario)
+34 -18
View File
@@ -313,6 +313,16 @@ def test_sdk_memory_library_uses_sdk_search_and_skips_empty_rows_without_mutatio
include_sources = kwargs.get("include_sources") or []
if include_sources:
rows = [row for row in rows if row["source"] in include_sources]
if kwargs.get("scene") == "all":
return {
"scene": "all",
"memories": [{**row, "scene": "chat", "kind": "chat"} for row in rows],
"total": len(rows),
"totals": {"chat": len(rows), "working": 0, "coding": 0},
"errors": {},
"limit": kwargs["limit"],
"offset": kwargs["offset"],
}
return {
"vdb": {
"memories": rows,
@@ -325,25 +335,28 @@ def test_sdk_memory_library_uses_sdk_search_and_skips_empty_rows_without_mutatio
def search(self, query, **kwargs):
search_calls.append((query, kwargs))
return {
"memories": {
"schema": [],
"proactive": [],
"normal": [
{
"memory_id": "release",
"content": "Release plan",
"score": 0.9,
},
{
"memory_id": "retired",
"content": "Release plan, first draft",
"score": 0.8,
},
],
}
"scene": "all",
"memories": [
{
"memory_id": "release",
"content": "Release plan",
"score": 0.9,
"scene": "chat",
"kind": "chat",
},
{
"memory_id": "retired",
"content": "Release plan, first draft",
"score": 0.8,
"scene": "chat",
"kind": "chat",
},
],
"lanes": {"chat": 2, "working": 0, "coding": 0},
"errors": {},
}
def get(self, memory_id):
def get(self, memory_id, **_namespace):
return records.get(memory_id)
def delete(self, memory_id):
@@ -375,7 +388,9 @@ def test_sdk_memory_library_uses_sdk_search_and_skips_empty_rows_without_mutatio
assert [item["memory_id"] for item in body["memories"]] == ["release"]
assert search_calls[0][0] == "release"
assert search_calls[0][1]["include_sources"] == ["cursor"]
assert search_calls[0][1]["limit"] == 20
assert search_calls[0][1]["limit"] == 200
assert search_calls[0][1]["scene"] == "all"
assert search_calls[0][1]["rerank"] is False
assert search_calls[0][1]["intention_limit"] == 0
assert search_calls[0][1]["min_score"] == 0.0
assert list_calls == []
@@ -397,6 +412,7 @@ def test_sdk_memory_library_uses_sdk_search_and_skips_empty_rows_without_mutatio
assert listed.json()["total"] == 1
assert deleted == []
assert list_calls[0]["limit"] == 100
assert list_calls[0]["scene"] == "all"
assert "sdk_memory_invalid_rows_skipped count=1" in caplog.messages
graph = await client.get("/api/v2/memories/graph")
@@ -91,7 +91,7 @@ def test_bundle_contains_management_sdk_config_and_verified_manifest(tmp_path):
assert manifest["bundle_id"] == "migration-1"
assert manifest["sdk_namespace"] == "governed-v2"
assert manifest["runtime_build"] == "build-13"
assert manifest["snapshot_sdk_version"] == "2.0.5"
assert manifest["snapshot_sdk_version"] == "2.0.6"
paths = {item["path"]: item for item in manifest["files"]}
assert {
"management/one-memory.sqlite3",
+4 -4
View File
@@ -991,7 +991,7 @@ wheels = [
[[package]]
name = "hy-memory"
version = "2.0.5"
version = "2.0.6"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "chromadb" },
@@ -1008,9 +1008,9 @@ dependencies = [
{ name = "scikit-learn" },
{ name = "tiktoken" },
]
sdist = { url = "https://files.pythonhosted.org/packages/94/56/7a279122bb56b067c390b748b8a83b6a90bb2a8f237c1d3989b6317681cd/hy_memory-2.0.5.tar.gz", hash = "sha256:bfb1e9e9815377477702327fa318331d555b8256a67b5bad8d70d6a682bd9a4f", size = 839060, upload-time = "2026-10-22T12:59:30.502Z" }
sdist = { url = "https://files.pythonhosted.org/packages/18/98/312ac8bcbe41e63200a93d296300b2e6748e1a532dad01f009dad57ba408/hy_memory-2.0.6.tar.gz", hash = "sha256:d0f54d54867ee7e722a8dd1a88a00c0576904435f15ae7899fe640840bff74bc", size = 855200, upload-time = "2026-10-27T07:53:10.022Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/c5/97/090ca18b6a13d0216d2df607cb67732ff7a8eba2d0359b9ad95283b2cbdc/hy_memory-2.0.5-py3-none-any.whl", hash = "sha256:c24649d188eaa03d3b4e4e4ec20e818da884170b5f775f3cf23ecfa6b60523d6", size = 902923, upload-time = "2026-10-22T12:59:28.018Z" },
{ url = "https://files.pythonhosted.org/packages/27/f1/d4866cffe426d77b857168cc694b15df866f3d9f45152c17c148e1595db4/hy_memory-2.0.6-py3-none-any.whl", hash = "sha256:b0e666f8e89d1763bb31c0e67502df74434134a638ff6fd1912de9e936be2483", size = 918955, upload-time = "2026-10-27T07:53:07.686Z" },
]
[[package]]
@@ -1837,7 +1837,7 @@ requires-dist = [
{ name = "fastapi", specifier = ">=0.141.1" },
{ name = "filelock", specifier = ">=3.32.4" },
{ name = "httpx", specifier = ">=0.28.1" },
{ name = "hy-memory", specifier = "==2.0.5" },
{ name = "hy-memory", specifier = "==2.0.6" },
{ name = "keyring", specifier = ">=25.7.0" },
{ name = "numpy", specifier = "==2.5.2" },
{ name = "onnxruntime", specifier = "==1.23.2" },
+38
View File
@@ -589,6 +589,44 @@ it("names the SDK's no-source bucket 未识别来源, apart from a source under
expect(screen.getByText("只在最相关的 500 条记忆里查找关键词")).toBeTruthy();
});
it("badges each listed memory with its scene and pages 20 at a time", () => {
const items = ["对话一条", "工作经验", "编码事实"].map((content, i) => ({
...memories[0],
memory_id: `x${i}`,
content,
})) as GovernedMemory[];
const view = pagedView({
items,
total: 45,
offset: 20,
limit: 20,
sdk_memory_refs: {
x0: { scene: "chat", kind: "chat", variant: null },
x1: { scene: "working", kind: "experience", variant: "working" },
x2: { scene: "coding", kind: "fact", variant: "coding" },
},
});
render(<Host simplified items={items} libraryView={view} />);
const list = screen.getByRole("list", { name: "地图记忆列表" });
expect(
within(list).getByRole("button", { name: /对话一条/ }).textContent,
).toContain("对话");
expect(
within(list).getByRole("button", { name: /工作经验/ }).textContent,
).toContain("工作");
expect(
within(list).getByRole("button", { name: /编码事实/ }).textContent,
).toContain("编码");
expect(screen.getByText(/第 2 \/ 3 页/)).toBeTruthy();
// A short page (a row deleted since the search) still offers the next page.
const next = screen.getByRole("button", { name: "下一页记忆" });
expect((next as HTMLButtonElement).disabled).toBe(false);
fireEvent.click(next);
expect(view.onPage).toHaveBeenCalledWith(40);
fireEvent.click(screen.getByRole("button", { name: "上一页记忆" }));
expect(view.onPage).toHaveBeenCalledWith(0);
});
it("says a filtered source is empty rather than that nothing matched", () => {
render(
<Host
+21 -2
View File
@@ -7,6 +7,7 @@ import type {
MemoryLibraryPage,
MemoryProcessingSummary,
SdkAskMemory,
SdkMemoryScene,
SourceResponse,
} from "@one-memory/contracts";
import type { GuiLocale } from "@one-memory/i18n";
@@ -151,6 +152,13 @@ function sourceStamp(
return dayStamp(fallback, locale);
}
/** The list's type badge for an SDK memory's scene. */
const SCENE_LABELS: Record<SdkMemoryScene, string> = {
chat: "对话",
working: "工作",
coding: "编码",
};
export function MemoryMap(props: Props) {
const { locale, text, count, plain } = useMemoryLanguage();
const {
@@ -787,6 +795,18 @@ export function MemoryMap(props: Props) {
>
<span className={`map-state ${m.status}`} aria-hidden="true" />
<span className="map-memory-content">{m.content}</span>
{library?.page?.sdk_memory_refs?.[m.memory_id] ? (
<span
className="map-memory-scene"
data-scene={library.page.sdk_memory_refs[m.memory_id].scene}
>
{text(
SCENE_LABELS[
library.page.sdk_memory_refs[m.memory_id].scene
],
)}
</span>
) : null}
{m.tags.length ? (
<span className="map-memory-tags wb-tags">
{m.tags.map((tag) => (
@@ -894,8 +914,7 @@ export function MemoryMap(props: Props) {
aria-label={text("下一页记忆")}
title={text("下一页记忆")}
disabled={
library.page.offset + library.page.items.length >=
library.page.total
library.page.offset + library.page.limit >= library.page.total
}
onClick={() => {
clear();
+3 -2
View File
@@ -226,7 +226,8 @@ it("shows the three-column surface without four modes or a management button", a
source: undefined,
query: undefined,
offset: 0,
limit: 100,
limit: 20,
windowId: null,
},
expect.any(AbortSignal),
);
@@ -240,7 +241,7 @@ it("shows the three-column surface without four modes or a management button", a
expect(reads.sdkLibrary).toHaveBeenCalledWith({
statuses: ["active"],
offset: 0,
limit: 100,
limit: 20,
}),
);
fireEvent(window, new Event("one-memory:manage"));
@@ -59,9 +59,14 @@ vi.mock("./MemoryMap", () => ({
onSave(event: { preventDefault(): void }): void;
onDelete(): void;
libraryView?: {
page: { source_ids_by_memory?: Record<string, string[]> } | null;
page: {
source_ids_by_memory?: Record<string, string[]>;
offset: number;
} | null;
unattributedSource?: string;
onSource(value: string): void;
onQuery(value: string): void;
onPage(offset: number): void;
resetPage(): void;
};
}) => (
@@ -85,6 +90,31 @@ vi.mock("./MemoryMap", () => ({
</span>
<span data-testid="map-busy">{String(busy)}</span>
<span data-testid="map-detail">{detail?.memory.memory_id ?? ""}</span>
<span data-testid="map-offset">{libraryView?.page?.offset ?? ""}</span>
<button type="button" onClick={() => onOpenMemory("e1")}>
open e1
</button>
<button
type="button"
onClick={() => {
libraryView?.onQuery("deploy");
libraryView?.resetPage();
}}
>
search deploy
</button>
<button
type="button"
onClick={() => {
libraryView?.onQuery("other");
libraryView?.resetPage();
}}
>
search other
</button>
<button type="button" onClick={() => libraryView?.onPage(20)}>
page 2
</button>
<button type="button" onClick={() => onOpenMemory("a")}>
open a
</button>
@@ -178,7 +208,8 @@ it("draws the map from SDK memories on SDK authority, without legacy reads", asy
source: undefined,
query: undefined,
offset: 0,
limit: 100,
limit: 20,
windowId: null,
},
expect.any(AbortSignal),
);
@@ -236,7 +267,9 @@ it("opens an SDK memory in place instead of switching away from the map", async
);
fireEvent.click(screen.getByText("open a"));
await waitFor(() => expect(api.sdkMemory).toHaveBeenCalledWith("a"));
await waitFor(() =>
expect(api.sdkMemory).toHaveBeenCalledWith("a", undefined),
);
// The inspector stays on the map; the reader is never moved to a second list.
expect(screen.getByTestId("map").hidden).toBe(false);
expect(screen.getByTestId("sdk-library").textContent).toBe("");
@@ -291,6 +324,7 @@ it("saves and deletes an opened memory through v2 without a stale-read block", a
"a",
"memory a",
expect.any(String),
undefined,
),
);
await waitFor(() =>
@@ -300,7 +334,11 @@ it("saves and deletes an opened memory through v2 without a stale-read block", a
fireEvent.click(screen.getByText("delete"));
fireEvent.click(await screen.findByRole("button", { name: "确认删除" }));
await waitFor(() =>
expect(api.deleteSdkMemory).toHaveBeenCalledWith("a", expect.any(String)),
expect(api.deleteSdkMemory).toHaveBeenCalledWith(
"a",
expect.any(String),
undefined,
),
);
await waitFor(() =>
expect(screen.getByTestId("map-detail").textContent).toBe(""),
@@ -385,3 +423,153 @@ it("groups and draws SDK memories by source with the real map model", async () =
).toEqual(["a", "b"]);
expect(drawn.edges.length).toBeGreaterThan(0);
});
const experience = (memory_id: string): SdkMemoryRecord => ({
...record(memory_id),
content: "Fix flaky tests\nPin the seed",
layer: "experience",
scene: "coding",
kind: "experience",
variant: "coding",
task: "Fix flaky tests",
solution: "Pin the seed",
created_at: 1_761_000_000,
updated_at: 1_761_000_100,
});
it("opens, edits and deletes a coding experience by its kind and variant", async () => {
api.sdkLibrary.mockResolvedValue({
memories: [record("a"), experience("e1")],
total: 2,
limit: 20,
offset: 0,
});
api.sdkGraph.mockResolvedValue(null);
api.sdkMemory.mockResolvedValue(experience("e1"));
api.updateSdkMemory.mockResolvedValue({
...experience("e1"),
solution: "Pin the seed",
});
api.deleteSdkMemory.mockResolvedValue(undefined);
render(<MemorySpace simplified active />);
await waitFor(() =>
expect(screen.getByTestId("map-loaded").textContent).toBe("true"),
);
// A productivity memory has no graph node; the page still draws.
expect(screen.getByTestId("map-memories").textContent).toBe(
"a:confirmed,e1:confirmed",
);
fireEvent.click(screen.getByText("open e1"));
await waitFor(() =>
expect(api.sdkMemory).toHaveBeenCalledWith("e1", {
kind: "experience",
variant: "coding",
}),
);
await waitFor(() =>
expect(screen.getByTestId("map-detail").textContent).toBe("e1"),
);
// The editor holds the solution, not the joined task and solution.
fireEvent.click(screen.getByText("save"));
await waitFor(() =>
expect(api.updateSdkMemory).toHaveBeenCalledWith(
"e1",
"Pin the seed",
expect.any(String),
{ kind: "experience", variant: "coding" },
),
);
await waitFor(() =>
expect(screen.getByTestId("map-busy").textContent).toBe("false"),
);
fireEvent.click(screen.getByText("delete"));
fireEvent.click(await screen.findByRole("button", { name: "确认删除" }));
await waitFor(() =>
expect(api.deleteSdkMemory).toHaveBeenCalledWith("e1", expect.any(String), {
kind: "experience",
variant: "coding",
}),
);
});
it("pages a keyword search through one window and restarts when it resets", async () => {
sdkAuthority();
api.sdkGraph.mockResolvedValue(null);
render(<MemorySpace simplified active />);
await waitFor(() =>
expect(screen.getByTestId("map-loaded").textContent).toBe("true"),
);
// The server keeps window "w1" until it expires, then answers any page
// asked of it with page 1 of a fresh window "w2".
const live = new Set(["w1"]);
api.sdkLibrary.mockImplementation(
async (request: { offset: number; windowId?: string | null }) => {
const reset = !!request.windowId && !live.has(request.windowId);
const offset = reset ? 0 : request.offset;
const windowId = reset || live.has("w2") ? "w2" : "w1";
if (reset) live.add("w2");
return {
memories: [record(`${windowId}-${offset}`)],
total: 45,
limit: 20,
offset,
window_id: windowId,
window_reset: reset,
};
},
);
fireEvent.click(screen.getByText("search deploy"));
await waitFor(() =>
expect(screen.getByTestId("map-memories").textContent).toBe(
"w1-0:confirmed",
),
);
expect(api.sdkLibrary).toHaveBeenLastCalledWith(
expect.objectContaining({ query: "deploy", offset: 0, windowId: null }),
expect.any(AbortSignal),
);
fireEvent.click(screen.getByText("page 2"));
await waitFor(() =>
expect(screen.getByTestId("map-memories").textContent).toBe(
"w1-20:confirmed",
),
);
expect(api.sdkLibrary).toHaveBeenLastCalledWith(
expect.objectContaining({ query: "deploy", offset: 20, windowId: "w1" }),
expect.any(AbortSignal),
);
// Back to page 1 on the same window, then the window expires: asking for
// page 2 again restarts at page 1 of the new window.
fireEvent.click(screen.getByText("search deploy"));
await waitFor(() =>
expect(screen.getByTestId("map-offset").textContent).toBe("0"),
);
live.delete("w1");
fireEvent.click(screen.getByText("page 2"));
await waitFor(() =>
expect(api.sdkLibrary).toHaveBeenLastCalledWith(
expect.objectContaining({ query: "deploy", offset: 0, windowId: "w2" }),
expect.any(AbortSignal),
),
);
await waitFor(() =>
expect(screen.getByTestId("map-memories").textContent).toBe(
"w2-0:confirmed",
),
);
expect(screen.getByTestId("map-offset").textContent).toBe("0");
// A new query drops the window.
fireEvent.click(screen.getByText("search other"));
await waitFor(() =>
expect(api.sdkLibrary).toHaveBeenLastCalledWith(
expect.objectContaining({ query: "other", offset: 0, windowId: null }),
expect.any(AbortSignal),
),
);
});
+57 -9
View File
@@ -49,7 +49,13 @@ import { subscribePoll } from "./poll-scheduler";
import { R6Button } from "./R6Controls";
import { SdkMemoryLibrary } from "./SdkMemoryLibrary";
import { SdkRawHistory } from "./SdkRawHistory";
import { sdkDetail, sdkLibraryPage } from "./sdk-memory-map";
import {
SDK_MEMORY_PAGE_SIZE,
sdkDetail,
sdkEditableText,
sdkLibraryPage,
sdkNamespace,
} from "./sdk-memory-map";
import { sourceNameOr, UNATTRIBUTED_SOURCE_ID } from "./source-label";
import { traceUiAction } from "./ui-trace";
import { isDefiniteRefusal, writeTraceStatus } from "./write-outcome";
@@ -188,6 +194,16 @@ export function MemorySpace({
const [sdkLibraryQuery, setSdkLibraryQuery] = useState("");
const [sdkLibrarySource, setSdkLibrarySource] = useState("");
const [sdkLibraryOffset, setSdkLibraryOffset] = useState(0);
// A keyword search pages through one server-side result window. The id is
// kept for the query and source it was searched with; a new query or
// source starts without one, so the server searches afresh.
const sdkSearchWindow = useRef<{ key: string; id: string | null }>({
key: "",
id: null,
});
// The page the list shows, for the namespace of a memory being opened.
const sdkPageRef = useRef<MemoryLibraryPage | null>(null);
sdkPageRef.current = sdkLibrary.page;
const sdkMapVisible = simplified && active && displayedMode === "map";
useEffect(() => {
if (!sdkMapVisible) return;
@@ -197,7 +213,10 @@ export function MemorySpace({
let cancelled = false;
let controller: AbortController | null = null;
const source = sdkLibrarySource || undefined;
const query = sdkLibraryQuery || undefined;
const query = sdkLibraryQuery.trim() || undefined;
const windowKey = JSON.stringify([query ?? "", source ?? ""]);
if (sdkSearchWindow.current.key !== windowKey)
sdkSearchWindow.current = { key: windowKey, id: null };
const read = async (first: boolean) => {
controller?.abort();
const request = new AbortController();
@@ -211,17 +230,37 @@ export function MemorySpace({
source,
query,
offset: sdkLibraryOffset,
limit: 100,
limit: SDK_MEMORY_PAGE_SIZE,
windowId: query ? sdkSearchWindow.current.id : null,
},
request.signal,
),
// The overlay is decoration: the list stands without it.
// The overlay is decoration: the list stands without it. It reads
// chat memories only, newest first; a mixed page's chat rows are
// among the newest offset+page chat memories, so that prefix holds
// every relation the page can draw.
getSdkMemoryGraph(
{ source, offset: query ? 0 : sdkLibraryOffset, limit: 100 },
{
source,
offset: 0,
limit: query
? 100
: Math.min(
300,
Math.max(100, sdkLibraryOffset + SDK_MEMORY_PAGE_SIZE),
),
},
request.signal,
).catch(() => null),
]);
if (cancelled) return;
if (sdkSearchWindow.current.key === windowKey)
sdkSearchWindow.current = {
key: windowKey,
id: page.window_id ?? null,
};
// The window expired: the server searched again and answered page 1.
if (page.window_reset && sdkLibraryOffset !== 0) setSdkLibraryOffset(0);
setSdkLibrary({
page: sdkLibraryPage(page),
loading: false,
@@ -547,12 +586,16 @@ export function MemorySpace({
// The inspector reads the memory through v2 and renders it in place.
// Staying here keeps the map's list, graph and detail where the design
// put them.
const result = sdkDetail(await getSdkMemory(memoryId));
const record = await getSdkMemory(
memoryId,
sdkNamespace(sdkPageRef.current?.sdk_memory_refs?.[memoryId]),
);
const result = sdkDetail(record);
if (epoch !== detailEpoch.current || result.memory.memory_id !== memoryId)
return;
setDetail(result);
setDetailLoad(null);
setDraft(result.memory.content);
setDraft(sdkEditableText(record));
} catch (reason) {
if (epoch === detailEpoch.current) {
setError(message(reason, t));
@@ -586,7 +629,11 @@ export function MemorySpace({
// An SDK memory is removed outright: there is no revision to undo.
const trace = traceUiAction("memory", "confirm");
try {
await deleteSdkMemory(detail.memory.memory_id, trace.traceId);
await deleteSdkMemory(
detail.memory.memory_id,
trace.traceId,
sdkNamespace(detail.sdk_ref),
);
trace.done("succeeded");
closeDetail();
bumpSdkMap();
@@ -613,10 +660,11 @@ export function MemorySpace({
detail.memory.memory_id,
draft,
trace.traceId,
sdkNamespace(detail.sdk_ref),
);
trace.done("succeeded");
setDetail(sdkDetail(saved));
setDraft(saved.content);
setDraft(sdkEditableText(saved));
bumpSdkMap();
} catch (reason) {
trace.done(writeTraceStatus(reason), {
+132 -2
View File
@@ -84,14 +84,144 @@ describe("SDK memory library", () => {
await userEvent.type(input, "changed");
await userEvent.click(screen.getByRole("button", { name: "保存修改" }));
await waitFor(() =>
expect(updateSdkMemory).toHaveBeenCalledWith("memory-1", "changed"),
expect(updateSdkMemory).toHaveBeenCalledWith(
"memory-1",
"changed",
undefined,
undefined,
),
);
await userEvent.click(screen.getByRole("button", { name: "删除记忆" }));
await waitFor(() =>
expect(deleteSdkMemory).toHaveBeenCalledWith("memory-1"),
expect(deleteSdkMemory).toHaveBeenCalledWith(
"memory-1",
undefined,
undefined,
),
);
expect(screen.queryByText("撤销")).toBeNull();
expect(screen.queryByText(/版本/)).toBeNull();
expect(screen.queryByRole("button", { name: "已删除" })).toBeNull();
});
it("pages 20 at a time with a type badge for each scene", async () => {
const rows = Array.from({ length: 20 }, (_, index) => ({
...memory,
memory_id: `m-${index}`,
content: `row ${index}`,
...(index === 1
? {
scene: "working" as const,
kind: "experience" as const,
variant: "working" as const,
}
: index === 2
? {
scene: "coding" as const,
kind: "fact" as const,
variant: "coding" as const,
}
: { scene: "chat" as const, kind: "chat" as const }),
}));
getSdkMemoryLibrary.mockImplementation(
async ({ offset }: { offset: number }) => ({
memories: rows,
total: 45,
offset,
limit: 20,
}),
);
render(<SdkMemoryLibrary active />);
expect(await screen.findByText("第 1 / 3 页")).toBeTruthy();
expect(getSdkMemoryLibrary).toHaveBeenLastCalledWith({
statuses: ["active"],
offset: 0,
limit: 20,
});
const badges = document.querySelectorAll(".sdk-memory-scene");
expect([...badges].slice(0, 3).map((badge) => badge.textContent)).toEqual([
"对话",
"工作",
"编码",
]);
const previous = screen.getByRole("button", { name: "上一页" });
const next = screen.getByRole("button", { name: "下一页" });
expect((previous as HTMLButtonElement).disabled).toBe(true);
await userEvent.click(next);
expect(await screen.findByText("第 2 / 3 页")).toBeTruthy();
await userEvent.click(screen.getByRole("button", { name: "下一页" }));
expect(await screen.findByText("第 3 / 3 页")).toBeTruthy();
await waitFor(() =>
expect(
(screen.getByRole("button", { name: "下一页" }) as HTMLButtonElement)
.disabled,
).toBe(true),
);
expect(getSdkMemoryLibrary).toHaveBeenLastCalledWith({
statuses: ["active"],
offset: 40,
limit: 20,
});
await userEvent.click(screen.getByRole("button", { name: "上一页" }));
expect(await screen.findByText("第 2 / 3 页")).toBeTruthy();
});
it("edits an experience's solution and task by its kind and variant", async () => {
const experience = {
...memory,
memory_id: "exp-1",
content: "Fix tests\nPin seed",
layer: "experience",
scene: "coding" as const,
kind: "experience" as const,
variant: "coding" as const,
task: "Fix tests",
solution: "Pin seed",
};
getSdkMemoryLibrary.mockResolvedValue({
memories: [experience],
total: 1,
offset: 0,
limit: 20,
});
getSdkMemory.mockResolvedValue(experience);
updateSdkMemory.mockImplementation((_id, content, _trace, edit) =>
Promise.resolve({
...experience,
solution: content,
task: edit?.task ?? experience.task,
}),
);
render(<SdkMemoryLibrary active />);
await userEvent.click(
await screen.findByRole("button", { name: /Fix tests/ }),
);
expect(getSdkMemory).toHaveBeenCalledWith("exp-1", {
kind: "experience",
variant: "coding",
});
const solution = screen.getByRole("textbox", { name: "做法" });
expect((solution as HTMLTextAreaElement).value).toBe("Pin seed");
const task = screen.getByRole("textbox", { name: "任务" });
await userEvent.clear(task);
await userEvent.type(task, "Fix flaky tests");
await userEvent.clear(solution);
await userEvent.type(solution, "Pin the seed");
await userEvent.click(screen.getByRole("button", { name: "保存修改" }));
await waitFor(() =>
expect(updateSdkMemory).toHaveBeenCalledWith(
"exp-1",
"Pin the seed",
undefined,
{ kind: "experience", variant: "coding", task: "Fix flaky tests" },
),
);
await userEvent.click(screen.getByRole("button", { name: "删除记忆" }));
await waitFor(() =>
expect(deleteSdkMemory).toHaveBeenCalledWith("exp-1", undefined, {
kind: "experience",
variant: "coding",
}),
);
});
});
+101 -26
View File
@@ -1,4 +1,8 @@
import type { SdkMemoryRecord, SdkMemoryStatus } from "@one-memory/contracts";
import type {
SdkMemoryRecord,
SdkMemoryScene,
SdkMemoryStatus,
} from "@one-memory/contracts";
import { useCallback, useEffect, useRef, useState } from "react";
import {
deleteSdkMemory,
@@ -8,6 +12,18 @@ import {
} from "./api";
import { useGuiLanguage } from "./gui-language";
import { R6Button } from "./R6Controls";
import {
SDK_MEMORY_PAGE_SIZE,
sdkEditableText,
sdkMemoryRef,
sdkNamespace,
} from "./sdk-memory-map";
const SCENE_LABELS = {
chat: "sdk.memory.scene.chat",
working: "sdk.memory.scene.working",
coding: "sdk.memory.scene.coding",
} as const satisfies Record<SdkMemoryScene, string>;
export function SdkMemoryLibrary({
active,
@@ -27,9 +43,11 @@ export function SdkMemoryLibrary({
const [total, setTotal] = useState(0);
const [selected, setSelected] = useState<SdkMemoryRecord | null>(null);
const [draft, setDraft] = useState("");
// Experiences only: the task, edited next to the solution in `draft`.
const [taskDraft, setTaskDraft] = useState("");
const [busy, setBusy] = useState(false);
const [error, setError] = useState("");
const limit = 100;
const limit = SDK_MEMORY_PAGE_SIZE;
const refresh = useCallback(async () => {
if (!active) return;
@@ -56,20 +74,32 @@ export function SdkMemoryLibrary({
void refresh();
}, [refresh]);
const open = useCallback(async (memoryId: string) => {
setBusy(true);
setError("");
try {
const memory = await getSdkMemory(memoryId);
setSelected(memory);
setDraft(memory.content);
} catch {
setError(translation.current("sdk.memory.detailLoadFailed"));
} finally {
setBusy(false);
}
const select = useCallback((memory: SdkMemoryRecord) => {
setSelected(memory);
setDraft(sdkEditableText(memory));
setTaskDraft(memory.task ?? "");
}, []);
const open = useCallback(
async (memoryId: string, listed?: SdkMemoryRecord) => {
setBusy(true);
setError("");
try {
select(
await getSdkMemory(
memoryId,
listed ? sdkNamespace(sdkMemoryRef(listed)) : undefined,
),
);
} catch {
setError(translation.current("sdk.memory.detailLoadFailed"));
} finally {
setBusy(false);
}
},
[select],
);
useEffect(() => {
if (active && focusedMemoryId) void open(focusedMemoryId);
}, [active, focusedMemoryId, open]);
@@ -79,8 +109,20 @@ export function SdkMemoryLibrary({
setBusy(true);
setError("");
try {
const memory = await updateSdkMemory(selected.memory_id, draft);
setSelected(memory);
const namespace = sdkNamespace(sdkMemoryRef(selected));
const task =
selected.kind === "experience" &&
taskDraft.trim() &&
taskDraft !== (selected.task ?? "")
? taskDraft
: undefined;
const memory = await updateSdkMemory(
selected.memory_id,
draft,
undefined,
namespace && { ...namespace, ...(task ? { task } : {}) },
);
select(memory);
await refresh();
onChanged?.();
} catch {
@@ -95,7 +137,11 @@ export function SdkMemoryLibrary({
setBusy(true);
setError("");
try {
await deleteSdkMemory(selected.memory_id);
await deleteSdkMemory(
selected.memory_id,
undefined,
sdkNamespace(sdkMemoryRef(selected)),
);
setSelected(null);
await refresh();
onChanged?.();
@@ -136,13 +182,22 @@ export function SdkMemoryLibrary({
<p role="status">{t("sdk.memory.loading")}</p>
) : null}
<ul>
{rows.map((memory) => (
<li key={memory.memory_id}>
<button type="button" onClick={() => void open(memory.memory_id)}>
{memory.content}
</button>
</li>
))}
{rows.map((memory) => {
const ref = sdkMemoryRef(memory);
return (
<li key={`${ref.scene}:${ref.kind}:${memory.memory_id}`}>
<span className="sdk-memory-scene" data-scene={ref.scene}>
{t(SCENE_LABELS[ref.scene])}
</span>
<button
type="button"
onClick={() => void open(memory.memory_id, memory)}
>
{memory.content}
</button>
</li>
);
})}
</ul>
<nav aria-label={t("sdk.memory.pagination")}>
<R6Button
@@ -152,6 +207,12 @@ export function SdkMemoryLibrary({
>
{t("sdk.memory.previous")}
</R6Button>
<span>
{t("sdk.memory.page", {
page: total === 0 ? 0 : Math.floor(offset / limit) + 1,
pages: Math.ceil(total / limit),
})}
</span>
<R6Button
variant="quiet"
disabled={busy || offset + limit >= total}
@@ -162,11 +223,25 @@ export function SdkMemoryLibrary({
</nav>
{selected ? (
<aside aria-label={t("sdk.memory.detail")}>
<p>{selected.layer}</p>
<p>
{t(SCENE_LABELS[sdkMemoryRef(selected).scene])} · {selected.layer}
</p>
{selected.status === "active" ? (
<>
{selected.kind === "experience" ? (
<textarea
aria-label={t("sdk.memory.task")}
value={taskDraft}
disabled={busy}
onChange={(event) => setTaskDraft(event.target.value)}
/>
) : null}
<textarea
aria-label={t("sdk.memory.content")}
aria-label={
selected.kind === "experience"
? t("sdk.memory.solution")
: t("sdk.memory.content")
}
value={draft}
disabled={busy}
onChange={(event) => setDraft(event.target.value)}
+67 -2
View File
@@ -1,16 +1,28 @@
import { beforeEach, describe, expect, it, vi } from "vitest";
const { get } = vi.hoisted(() => ({ get: vi.fn() }));
const { get, put, del } = vi.hoisted(() => ({
get: vi.fn(),
put: vi.fn(),
del: vi.fn(),
}));
vi.mock("axios", () => ({
default: {
create: () => ({
get,
put,
delete: del,
interceptors: { request: { use: vi.fn() } },
}),
},
}));
import { getHomeSummary, getSdkMemoryLibrary } from "./api";
import {
deleteSdkMemory,
getHomeSummary,
getSdkMemory,
getSdkMemoryLibrary,
updateSdkMemory,
} from "./api";
describe("paged memory library client", () => {
beforeEach(() => vi.clearAllMocks());
@@ -35,6 +47,59 @@ describe("paged memory library client", () => {
expect(params.get("query")).toBe("release");
});
it("pages 20 at a time and sends the search window only with a query", async () => {
get.mockResolvedValue({
data: { memories: [], total: 0, offset: 0, limit: 20 },
});
await getSdkMemoryLibrary({ query: "deploy", offset: 20, windowId: "w1" });
let params = get.mock.calls[0][1].params as URLSearchParams;
expect(params.get("limit")).toBe("20");
expect(params.get("offset")).toBe("20");
expect(params.get("window_id")).toBe("w1");
await getSdkMemoryLibrary({ windowId: "w1" });
params = get.mock.calls[1][1].params as URLSearchParams;
expect(params.has("window_id")).toBe(false);
expect(params.get("limit")).toBe("20");
});
it("names an experience's kind and variant on get, update and delete", async () => {
get.mockResolvedValue({ data: { memory_id: "e1" } });
put.mockResolvedValue({ data: { memory_id: "e1" } });
del.mockResolvedValue({});
const namespace = { kind: "experience", variant: "coding" } as const;
await getSdkMemory("e1", namespace);
expect(get).toHaveBeenCalledWith("/api/v2/memories/e1", {
params: { kind: "experience", variant: "coding" },
});
await getSdkMemory("c1");
expect(get).toHaveBeenLastCalledWith("/api/v2/memories/c1");
await updateSdkMemory("e1", "new solution", "trace-1", {
...namespace,
task: "new task",
});
expect(put).toHaveBeenCalledWith(
"/api/v2/memories/e1",
{
content: "new solution",
kind: "experience",
variant: "coding",
task: "new task",
},
{ headers: { "X-One-Memory-Trace-Id": "trace-1" } },
);
await deleteSdkMemory("e1", "trace-2", namespace);
expect(del).toHaveBeenCalledWith("/api/v2/memories/e1", {
headers: { "X-One-Memory-Trace-Id": "trace-2" },
params: { kind: "experience", variant: "coding" },
});
await deleteSdkMemory("c1");
expect(del).toHaveBeenLastCalledWith("/api/v2/memories/c1", undefined);
});
it("forwards cancellation for inactive or superseded views", async () => {
const controller = new AbortController();
get.mockResolvedValueOnce({
+52 -8
View File
@@ -25,9 +25,11 @@ import {
type ModelProfileInput,
type ModelProfilePatch,
type OnboardingState,
type SdkMemoryKind,
type SdkMemoryPage,
type SdkMemoryRecord,
type SdkMemoryStatus,
type SdkMemoryVariant,
type SdkRawContextItem,
type SdkRawPage,
type SourceDirectoryCandidate,
@@ -63,6 +65,7 @@ import {
recoverableFileUpload,
} from "./file-upload-client";
import { recoverableProbe } from "./model-probe-client";
import { SDK_MEMORY_PAGE_SIZE } from "./sdk-memory-map";
export const DEFAULT_CORE_URL = "http://127.0.0.1:8765";
@@ -694,13 +697,16 @@ export async function getSdkMemoryLibrary(
source,
query,
offset = 0,
limit = 100,
limit = SDK_MEMORY_PAGE_SIZE,
windowId,
}: {
statuses?: SdkMemoryStatus[];
source?: string;
query?: string;
offset?: number;
limit?: number;
/** Keyword search: the window a previous page returned. */
windowId?: string | null;
} = {},
signal?: AbortSignal,
): Promise<SdkMemoryPage> {
@@ -710,7 +716,10 @@ export async function getSdkMemoryLibrary(
});
for (const status of statuses) params.append("statuses", status);
if (source) params.set("source", source);
if (query?.trim()) params.set("query", query.trim());
if (query?.trim()) {
params.set("query", query.trim());
if (windowId) params.set("window_id", windowId);
}
const response = await client.get<SdkMemoryPage>("/api/v2/memory-library", {
params,
signal,
@@ -718,10 +727,32 @@ export async function getSdkMemoryLibrary(
return response.data;
}
export async function getSdkMemory(memoryId: string): Promise<SdkMemoryRecord> {
const response = await client.get<SdkMemoryRecord>(
`/api/v2/memories/${encodeURIComponent(memoryId)}`,
);
/** The namespace of an SDK memory, for get/update/delete. Chat memories need
* none; an experience or fact names its kind and working/coding variant. */
export interface SdkMemoryNamespace {
kind?: SdkMemoryKind;
variant?: SdkMemoryVariant | null;
}
function namespaceParams(
namespace: SdkMemoryNamespace | undefined,
): Record<string, string> | undefined {
if (!namespace?.kind && !namespace?.variant) return undefined;
const params: Record<string, string> = {};
if (namespace.kind) params.kind = namespace.kind;
if (namespace.variant) params.variant = namespace.variant;
return params;
}
export async function getSdkMemory(
memoryId: string,
namespace?: SdkMemoryNamespace,
): Promise<SdkMemoryRecord> {
const params = namespaceParams(namespace);
const path = `/api/v2/memories/${encodeURIComponent(memoryId)}`;
const response = params
? await client.get<SdkMemoryRecord>(path, { params })
: await client.get<SdkMemoryRecord>(path);
return response.data;
}
@@ -729,10 +760,17 @@ export async function updateSdkMemory(
memoryId: string,
content: string,
traceId?: string,
/** An experience's `content` is its solution; `task` replaces the task. */
edit?: SdkMemoryNamespace & { task?: string },
): Promise<SdkMemoryRecord> {
const body: Record<string, string> = { content };
if (edit?.kind) body.kind = edit.kind;
if (edit?.variant) body.variant = edit.variant;
if (edit?.task !== undefined && edit.kind === "experience")
body.task = edit.task;
const response = await client.put<SdkMemoryRecord>(
`/api/v2/memories/${encodeURIComponent(memoryId)}`,
{ content },
body,
// A rewrite is a write; carry the trace id so the ui.action / ui.result
// pair joins the kernel legs in one chain.
traceId ? { headers: { "X-One-Memory-Trace-Id": traceId } } : undefined,
@@ -743,10 +781,16 @@ export async function updateSdkMemory(
export async function deleteSdkMemory(
memoryId: string,
traceId?: string,
namespace?: SdkMemoryNamespace,
): Promise<void> {
const params = namespaceParams(namespace);
const config = {
...(traceId ? { headers: { "X-One-Memory-Trace-Id": traceId } } : {}),
...(params ? { params } : {}),
};
await client.delete(
`/api/v2/memories/${encodeURIComponent(memoryId)}`,
traceId ? { headers: { "X-One-Memory-Trace-Id": traceId } } : undefined,
Object.keys(config).length ? config : undefined,
);
}
+12
View File
@@ -855,6 +855,18 @@ button.rail-button.active::before {
font-size: 11px;
color: var(--tx3);
}
/* Which part of the library the memory is from: chat, work or coding. */
.map-memory-scene {
grid-column: 2;
justify-self: start;
align-self: flex-start;
padding: 0 6px;
border: 0.5px solid var(--bd);
border-radius: 3px;
color: var(--tx3);
font-size: 10px;
line-height: 1.6;
}
.map-memory-made {
display: block;
opacity: 0.72;
+56 -1
View File
@@ -1,7 +1,13 @@
import type { SdkMemoryRecord } from "@one-memory/contracts";
import { afterEach, beforeEach, expect, it, vi } from "vitest";
import { projectPagedSourceMap } from "./memory-map-model";
import { sdkDetail, sdkLibraryPage } from "./sdk-memory-map";
import {
sdkDetail,
sdkEditableText,
sdkLibraryPage,
sdkMemoryRef,
sdkNamespace,
} from "./sdk-memory-map";
const NOW = Date.UTC(2026, 9, 25) / 1_000;
@@ -85,3 +91,52 @@ it("carries a full search window through to the list", () => {
}).search_window,
).toBe(500);
});
it("carries each memory's scene and namespace, and edits an experience's solution", () => {
const experience = record({
memory_id: "e",
content: "Fix tests\nPin seed",
layer: "experience",
scene: "working",
kind: "experience",
variant: "working",
task: "Fix tests",
solution: "Pin seed",
created_at: NOW - 86_400,
updated_at: NOW - 3_600,
});
const page = sdkLibraryPage({
memories: [record({ memory_id: "c" }), experience],
total: 2,
limit: 20,
offset: 0,
window_id: "w1",
window_reset: true,
});
expect(page.sdk_memory_refs).toEqual({
c: { scene: "chat", kind: "chat", variant: null },
e: { scene: "working", kind: "experience", variant: "working" },
});
expect(page.window_id).toBe("w1");
expect(page.window_reset).toBe(true);
// An experience has no conversation time to show as "imported".
expect(page.source_time_by_memory?.e).toBeUndefined();
expect(page.items[1]).toMatchObject({
kind: "working",
updated_at: new Date((NOW - 3_600) * 1_000).toISOString(),
});
const detail = sdkDetail(experience);
expect(detail.sdk_ref).toEqual({
scene: "working",
kind: "experience",
variant: "working",
});
expect(sdkNamespace(detail.sdk_ref)).toEqual({
kind: "experience",
variant: "working",
});
expect(sdkNamespace(sdkMemoryRef(record({})))).toBeUndefined();
expect(sdkEditableText(experience)).toBe("Pin seed");
expect(sdkEditableText(record({}))).toBe("memory");
});
+56 -5
View File
@@ -5,9 +5,45 @@ import type {
MemoryLibraryPage,
SdkMemoryPage,
SdkMemoryRecord,
SdkMemoryRef,
} from "@one-memory/contracts";
function kindOf(layer: string): MemoryKind {
/** The SDK memory panel's page size (the API still accepts 1..500). */
export const SDK_MEMORY_PAGE_SIZE = 20;
/** Scene, kind and namespace of an SDK memory. Records from a Core before
* the all-scene library carry none of these and were all chat. */
export function sdkMemoryRef(record: SdkMemoryRecord): SdkMemoryRef {
const kind = record.kind ?? "chat";
if (kind === "chat") return { scene: "chat", kind, variant: null };
const variant =
record.variant ?? (record.scene === "coding" ? "coding" : "working");
return { scene: variant, kind, variant };
}
/** What get/update/delete name for a memory: nothing for chat, so a chat
* memory is addressed exactly as before; kind+variant for the rest. */
export function sdkNamespace(
ref: SdkMemoryRef | undefined,
):
| { kind: SdkMemoryRef["kind"]; variant: SdkMemoryRef["variant"] }
| undefined {
return ref && ref.kind !== "chat"
? { kind: ref.kind, variant: ref.variant }
: undefined;
}
/** The text an edit replaces: an experience's solution, otherwise content. */
export function sdkEditableText(record: SdkMemoryRecord): string {
return record.kind === "experience" && typeof record.solution === "string"
? record.solution
: record.content;
}
function kindOf(record: SdkMemoryRecord): MemoryKind {
const scene = sdkMemoryRef(record).scene;
if (scene !== "chat") return scene;
const layer = record.layer;
if (layer === "l6_schema") return "schema";
if (layer === "l7_intention") return "intention";
if (layer.startsWith("coding")) return "coding";
@@ -52,11 +88,14 @@ function sourceOf(record: SdkMemoryRecord): string {
* stay where the design put it instead of sending the reader to another view.
*/
export function sdkMapMemory(record: SdkMemoryRecord): GovernedMemory {
const created = stamp(record.gmt_created ?? record.memory_at);
const created = stamp(
record.gmt_created ?? record.created_at ?? record.memory_at,
);
const updated = stamp(record.updated_at) || created;
return {
memory_id: record.memory_id,
user_id: record.user_id,
kind: kindOf(record.layer),
kind: kindOf(record),
status:
record.status === "active"
? "confirmed"
@@ -75,7 +114,7 @@ export function sdkMapMemory(record: SdkMemoryRecord): GovernedMemory {
// renders this placeholder.
revision: 0,
created_at: created,
updated_at: created,
updated_at: updated,
deleted_at: null,
};
}
@@ -96,7 +135,11 @@ export function sdkDetail(record: SdkMemoryRecord): MemoryDetail {
sdk_status: record.status,
source_id: sourceOf(record),
sdk_memory_at: stamp(plausible(record.memory_at)) || null,
sdk_created_at: stamp(record.gmt_created) || null,
sdk_created_at: stamp(record.gmt_created ?? record.created_at) || null,
sdk_ref: sdkMemoryRef(record),
sdk_task: record.kind === "experience" ? (record.task ?? null) : null,
sdk_solution:
record.kind === "experience" ? (record.solution ?? null) : null,
};
}
@@ -114,8 +157,13 @@ export function sdkLibraryPage(page: SdkMemoryPage): MemoryLibraryPage {
{ at: string; basis: "conversation" | "import" }
> = {};
const sourceIds: Record<string, string[]> = {};
const refs: Record<string, SdkMemoryRef> = {};
for (const record of page.memories) {
sourceIds[record.memory_id] = [sourceOf(record)];
refs[record.memory_id] = sdkMemoryRef(record);
// An experience or fact has no conversation time; the list shows when it
// was last updated instead of calling its creation an import.
if (refs[record.memory_id].kind !== "chat") continue;
const conversation = plausible(record.memory_at);
const at = conversation ?? plausible(record.gmt_created);
if (at === null) continue;
@@ -132,5 +180,8 @@ export function sdkLibraryPage(page: SdkMemoryPage): MemoryLibraryPage {
source_ids_by_memory: sourceIds,
source_time_by_memory: sourceTime,
search_window: page.search_window ?? null,
window_id: page.window_id ?? null,
window_reset: page.window_reset ?? false,
sdk_memory_refs: refs,
};
}
+1 -1
View File
@@ -282,7 +282,7 @@ Support is declared per immutable GitHub Release manifest:
- Linux x64/arm64 Runtime on native GHA runners.
- macOS arm64/x64 App/Runtime: Developer ID, notarization, native acceptance.
- Windows is not supported.
- `hy-memory==2.0.5` is the current stable SDK dependency; the release
- `hy-memory==2.0.6` is the current stable SDK dependency; the release
lock and [`../MEMORY.md`](../MEMORY.md) are authoritative when the pin changes.
See [`INSTALLATION.md`](INSTALLATION.md).
+45
View File
@@ -480,6 +480,14 @@ export interface MemoryLibraryPage {
/** Set on a search whose relevance window was full: matches were looked for
* only among this many most relevant memories, so `total` may undercount. */
search_window?: number | null;
/** SDK keyword search: the server-side result window to page with. */
window_id?: string | null;
/** SDK keyword search: the window passed in had expired, so this is page 1
* of a fresh search. */
window_reset?: boolean;
/** SDK pages: which scene each returned memory is from, and the namespace
* to name when opening, editing or deleting it. */
sdk_memory_refs?: Record<string, SdkMemoryRef>;
}
export interface MemoryLibraryQuery {
@@ -533,6 +541,11 @@ export interface MemoryDetail {
source_id?: string | null;
sdk_memory_at?: string | null;
sdk_created_at?: string | null;
/** SDK memories: the namespace to name on edit/delete. */
sdk_ref?: SdkMemoryRef;
/** SDK experiences: the task and solution, edited apart. */
sdk_task?: string | null;
sdk_solution?: string | null;
// Null for a memory nobody has rewritten: there is no index to be behind,
// and the interface should not explain a state the user never caused.
index_sync: MemoryIndexSync | null;
@@ -546,6 +559,20 @@ export type SdkMemoryStatus =
| "conflicted"
| "archived";
/** Which part of the library a memory lives in. */
export type SdkMemoryScene = "chat" | "working" | "coding";
/** What the memory is: a chat memory, a working/coding experience or fact. */
export type SdkMemoryKind = "chat" | "experience" | "fact";
/** The productivity namespace of an experience or fact. */
export type SdkMemoryVariant = "working" | "coding";
/** Enough to address one memory again: get/update/delete pass kind+variant. */
export interface SdkMemoryRef {
scene: SdkMemoryScene;
kind: SdkMemoryKind;
variant: SdkMemoryVariant | null;
}
export interface SdkMemoryRecord {
memory_id: string;
content: string;
@@ -563,6 +590,16 @@ export interface SdkMemoryRecord {
source: string | null;
memory_at: number | null;
gmt_created: number | null;
/** Absent only from pre-2.0.6 Core responses, which were all chat. */
scene?: SdkMemoryScene;
kind?: SdkMemoryKind;
variant?: SdkMemoryVariant | null;
/** Experiences: the task and the solution; `content` is both, joined. */
task?: string | null;
solution?: string | null;
/** Epoch seconds. */
created_at?: number | null;
updated_at?: number | null;
}
export interface SdkMemoryPage {
@@ -571,6 +608,14 @@ export interface SdkMemoryPage {
limit: number;
offset: number;
search_window?: number | null;
/** Keyword search: pass back with the next offset to page the same
* results. Null on a plain listing. */
window_id?: string | null;
/** Keyword search: the window_id sent had expired or did not match, the
* search ran again and this page is page 1 (offset 0) of the new window. */
window_reset?: boolean;
/** Plain listing: memories per scene under the same filters. */
totals?: Partial<Record<SdkMemoryScene, number>> | null;
}
export interface SdkUserProfileResponse {
@@ -58,6 +58,12 @@ export const currentChineseMessages = {
"sdk.memory.content": "记忆内容",
"sdk.memory.save": "保存修改",
"sdk.memory.delete": "删除记忆",
"sdk.memory.scene.chat": "对话",
"sdk.memory.scene.working": "工作",
"sdk.memory.scene.coding": "编码",
"sdk.memory.task": "任务",
"sdk.memory.solution": "做法",
"sdk.memory.page": "第 {page} / {pages} 页",
"sdk.raw.title": "SDK 原始对话",
"sdk.raw.searchLabel": "搜索原始对话",
@@ -197,6 +203,12 @@ export const currentEnglishMessages: Record<
"sdk.memory.content": "Memory content",
"sdk.memory.save": "Save changes",
"sdk.memory.delete": "Delete memory",
"sdk.memory.scene.chat": "Chat",
"sdk.memory.scene.working": "Work",
"sdk.memory.scene.coding": "Coding",
"sdk.memory.task": "Task",
"sdk.memory.solution": "Solution",
"sdk.memory.page": "Page {page} of {pages}",
"sdk.raw.title": "SDK original conversations",
"sdk.raw.searchLabel": "Search original conversations",
@@ -349,6 +361,12 @@ export const currentSpanishMessages: Record<
"sdk.memory.content": "Contenido de la memoria",
"sdk.memory.save": "Guardar cambios",
"sdk.memory.delete": "Eliminar memoria",
"sdk.memory.scene.chat": "Chat",
"sdk.memory.scene.working": "Trabajo",
"sdk.memory.scene.coding": "Programación",
"sdk.memory.task": "Tarea",
"sdk.memory.solution": "Solución",
"sdk.memory.page": "Página {page} de {pages}",
"sdk.raw.title": "Conversaciones originales del SDK",
"sdk.raw.searchLabel": "Buscar conversaciones originales",
@@ -1680,6 +1680,8 @@ const baseEnglishMessages = {
"memory:重试": "Retry",
"memory:删除": "Delete",
"memory:对话": "Conversation",
"memory:工作": "Work",
"memory:编码": "Coding",
"memory:保存修改": "Save changes",
"memory:对应记忆": "Related memory",
"memory:查看记忆": "View memories",
@@ -251,6 +251,8 @@ export const memoryChineseMessages = {
"memory:重试": "重试",
"memory:删除": "删除",
"memory:对话": "对话",
"memory:工作": "工作",
"memory:编码": "编码",
"memory:保存修改": "保存修改",
"memory:对应记忆": "对应记忆",
"memory:查看记忆": "查看记忆",
@@ -656,6 +658,8 @@ export const memorySpanishMessages: Record<
"memory:重试": "Reintentar",
"memory:删除": "Eliminar",
"memory:对话": "Conversación",
"memory:工作": "Trabajo",
"memory:编码": "Programación",
"memory:保存修改": "Guardar cambios",
"memory:对应记忆": "Memoria correspondiente",
"memory:查看记忆": "Ver memoria",
+1 -1
View File
@@ -36,7 +36,7 @@ MEMMY_LICENSE = (
MEMMY_PROVENANCE = MEMMY_LICENSE.with_name("PROVENANCE.md")
MEMMY_PIN = "25acd5f29fc7df73b943713abe61f1b2f9839887"
HY_MEMORY_NAME = "hy-memory"
HY_MEMORY_VERSION = "2.0.5"
HY_MEMORY_VERSION = "2.0.6"
class SbomError(RuntimeError):
+1 -1
View File
@@ -12,7 +12,7 @@ test("installation docs disclose publication and lifecycle boundaries", async ()
"utf8",
);
assert.match(readme, /`hy-memory==2\.0\.5`/);
assert.match(readme, /`hy-memory==2\.0\.6`/);
assert.match(readme, /`v1\.0\.5` 的发行目标是 Linux x64\/arm64 Runtime/);
assert.match(readme, /Linux 制品由 GitHub Actions/);
assert.match(readme, /macOS.*Developer ID 签名、公证/s);
+3 -3
View File
@@ -13,7 +13,7 @@ spec.loader.exec_module(module)
def test_hy_memory_notice_comes_from_the_installed_dist_info(tmp_path: Path) -> None:
dist_info = tmp_path / "hy_memory-2.0.5.dist-info"
dist_info = tmp_path / "hy_memory-2.0.6.dist-info"
licenses = dist_info / "licenses"
licenses.mkdir(parents=True)
(dist_info / "METADATA").write_text(
@@ -21,7 +21,7 @@ def test_hy_memory_notice_comes_from_the_installed_dist_info(tmp_path: Path) ->
(
"Metadata-Version: 2.4",
"Name: hy-memory",
"Version: 2.0.5",
"Version: 2.0.6",
"License-Expression: Apache-2.0",
"",
)
@@ -38,7 +38,7 @@ def test_hy_memory_notice_comes_from_the_installed_dist_info(tmp_path: Path) ->
notice = module.hy_memory_notice(dist_info)
assert notice["name"] == "hy-memory"
assert notice["version"] == "2.0.5"
assert notice["version"] == "2.0.6"
assert notice["license"] == "Apache-2.0"
assert notice["copyright"] == "Copyright (C) 2026 Tencent. All rights reserved."
assert "Licensed under the Apache License" in notice["text"]