diff --git a/memhub/README.md b/memhub/README.md index 4dbba9e..077d02e 100644 --- a/memhub/README.md +++ b/memhub/README.md @@ -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`。 diff --git a/memhub/SBOM.spdx.json b/memhub/SBOM.spdx.json index a819e21..f5f91d3 100644 --- a/memhub/SBOM.spdx.json +++ b/memhub/SBOM.spdx.json @@ -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", diff --git a/memhub/THIRD_PARTY_NOTICES.md b/memhub/THIRD_PARTY_NOTICES.md index cb32e85..58c3ab6 100644 --- a/memhub/THIRD_PARTY_NOTICES.md +++ b/memhub/THIRD_PARTY_NOTICES.md @@ -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 diff --git a/memhub/apps/cli/src/memory-report.ts b/memhub/apps/cli/src/memory-report.ts index b49bce3..e9e5627 100644 --- a/memhub/apps/cli/src/memory-report.ts +++ b/memhub/apps/cli/src/memory-report.ts @@ -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 { 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; diff --git a/memhub/apps/cli/src/read-only-queries.test.ts b/memhub/apps/cli/src/read-only-queries.test.ts index d406f1b..2079dbe 100644 --- a/memhub/apps/cli/src/read-only-queries.test.ts +++ b/memhub/apps/cli/src/read-only-queries.test.ts @@ -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( [ diff --git a/memhub/apps/core/pyproject.toml b/memhub/apps/core/pyproject.toml index 3e99cc7..ea2dce7 100644 --- a/memhub/apps/core/pyproject.toml +++ b/memhub/apps/core/pyproject.toml @@ -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", diff --git a/memhub/apps/core/src/one_memory/api.py b/memhub/apps/core/src/one_memory/api.py index 7a481c9..5e7a630 100644 --- a/memhub/apps/core/src/one_memory/api.py +++ b/memhub/apps/core/src/one_memory/api.py @@ -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, diff --git a/memhub/apps/core/src/one_memory/hy_runtime_config.py b/memhub/apps/core/src/one_memory/hy_runtime_config.py index afe834b..097e450 100644 --- a/memhub/apps/core/src/one_memory/hy_runtime_config.py +++ b/memhub/apps/core/src/one_memory/hy_runtime_config.py @@ -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"] diff --git a/memhub/apps/core/src/one_memory/hy_sdk_runtime.py b/memhub/apps/core/src/one_memory/hy_sdk_runtime.py index a510dcc..6ffb339 100644 --- a/memhub/apps/core/src/one_memory/hy_sdk_runtime.py +++ b/memhub/apps/core/src/one_memory/hy_sdk_runtime.py @@ -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, diff --git a/memhub/apps/core/src/one_memory/hy_sdk_types.py b/memhub/apps/core/src/one_memory/hy_sdk_types.py index b862e06..aef9897 100644 --- a/memhub/apps/core/src/one_memory/hy_sdk_types.py +++ b/memhub/apps/core/src/one_memory/hy_sdk_types.py @@ -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 () diff --git a/memhub/apps/core/src/one_memory/library_search_window.py b/memhub/apps/core/src/one_memory/library_search_window.py new file mode 100644 index 0000000..5f56496 --- /dev/null +++ b/memhub/apps/core/src/one_memory/library_search_window.py @@ -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] diff --git a/memhub/apps/core/tests/TEST_INDEX.md b/memhub/apps/core/tests/TEST_INDEX.md index b1212db..a53b4a9 100644 --- a/memhub/apps/core/tests/TEST_INDEX.md +++ b/memhub/apps/core/tests/TEST_INDEX.md @@ -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 个测试文件。 diff --git a/memhub/apps/core/tests/core_test_classification.py b/memhub/apps/core/tests/core_test_classification.py index 813cd05..4c6d499 100644 --- a/memhub/apps/core/tests/core_test_classification.py +++ b/memhub/apps/core/tests/core_test_classification.py @@ -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", diff --git a/memhub/apps/core/tests/test_library_search_window.py b/memhub/apps/core/tests/test_library_search_window.py new file mode 100644 index 0000000..ce08784 --- /dev/null +++ b/memhub/apps/core/tests/test_library_search_window.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"), + ] diff --git a/memhub/apps/core/tests/test_memory_library_all_scenes.py b/memhub/apps/core/tests/test_memory_library_all_scenes.py new file mode 100644 index 0000000..e72c599 --- /dev/null +++ b/memhub/apps/core/tests/test_memory_library_all_scenes.py @@ -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) diff --git a/memhub/apps/core/tests/test_memory_notes.py b/memhub/apps/core/tests/test_memory_notes.py index 9ecb1a0..e8abd4b 100644 --- a/memhub/apps/core/tests/test_memory_notes.py +++ b/memhub/apps/core/tests/test_memory_notes.py @@ -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") diff --git a/memhub/apps/core/tests/test_sdk_migration_bundle.py b/memhub/apps/core/tests/test_sdk_migration_bundle.py index f4a6960..19381b4 100644 --- a/memhub/apps/core/tests/test_sdk_migration_bundle.py +++ b/memhub/apps/core/tests/test_sdk_migration_bundle.py @@ -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", diff --git a/memhub/apps/core/uv.lock b/memhub/apps/core/uv.lock index 9039ae7..83e11dd 100644 --- a/memhub/apps/core/uv.lock +++ b/memhub/apps/core/uv.lock @@ -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" }, diff --git a/memhub/apps/web/src/MemoryMap.test.tsx b/memhub/apps/web/src/MemoryMap.test.tsx index 7389486..6878869 100644 --- a/memhub/apps/web/src/MemoryMap.test.tsx +++ b/memhub/apps/web/src/MemoryMap.test.tsx @@ -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(); + 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( = { + chat: "对话", + working: "工作", + coding: "编码", +}; + export function MemoryMap(props: Props) { const { locale, text, count, plain } = useMemoryLanguage(); const { @@ -787,6 +795,18 @@ export function MemoryMap(props: Props) { >