From b864e0bda6eb18b0f4a174a962d4b8e337f39d88 Mon Sep 17 00:00:00 2001 From: alvinfei Date: Mon, 2 Nov 2026 21:03:22 +0800 Subject: [PATCH 1/2] chore(memhub): pin hy-memory 2.0.13; MemHub 1.0.6 changelog Pin the Core dependency, lock, HY_MEMORY_VERSION, SBOM, notices, docs and version guards to hy-memory 2.0.13. Move the MemHub entries from Unreleased into the 1.0.6 section (build 37, on hy-memory 2.0.13) and update the v1.0.6 news lines. Co-Authored-By: Claude Opus 5.5 (1M context) --- CHANGELOG.md | 108 +++++++++--------- README.md | 2 +- README.zh.md | 2 +- memhub/README.md | 4 +- memhub/SBOM.spdx.json | 2 +- memhub/THIRD_PARTY_NOTICES.md | 2 +- memhub/apps/core/pyproject.toml | 2 +- .../core/src/one_memory/hy_runtime_config.py | 2 +- .../core/src/one_memory/hy_sdk_migration.py | 2 +- .../core/tests/test_sdk_migration_bundle.py | 2 +- memhub/apps/core/uv.lock | 8 +- memhub/docs/ARCHITECTURE.md | 2 +- memhub/scripts/generate_sbom.py | 2 +- memhub/scripts/install-docs.test.mjs | 2 +- memhub/scripts/test_generate_sbom.py | 6 +- 15 files changed, 71 insertions(+), 77 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index d97f620..5621c08 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -4,33 +4,6 @@ Hy Memory and MemHub are versioned and released independently. ## Unreleased -- MemHub multi-user hardening (follow-up to #250): - - Read responses name the user they were read for: `user_id` (the one - asked for, `local` by default) is now on `GET /api/v2/memory-library`, - `/memory-library/layers`, `/user-profile`, `/home-summary`, - `/memories/graph`, `/raw`, and `POST /api/v2/context/search`, - `/api/v1/recall`, `/api/v2/memories/ask`. `ask/stream` carries it on its - first (`recall`) event. The Host Bridge context validator accepts the new - field and still accepts a Core without it. - - `DELETE /api/v2/memories/{id}` answers 404 `memory_not_found` for another - user's memory and for an id that does not exist (the same response, so - existence is still not revealed), and also when the SDK finds nothing to - delete; 204 only when the caller's own memory was deleted. The App treats - that 404 as already gone (a double-click delete no longer shows an - error); `memhub memories delete` prints "未找到记忆 (可能已删除)", - exits 0, and its JSON is `state: "committed"` with `already_gone: true`. - - Hermes never reads a `profiles/` directory at any depth (scan, detect and - live sync), so a root granted above `~/.hermes` cannot pick up another - user's profile. Connecting refuses a root that is the `profiles` - container itself (422 `hermes_root_is_profiles_container`) and a root - equal to, containing or inside another user's active Hermes grant (409 - `hermes_root_overlaps_other_user`, symlinks resolved); `local` on - `~/.hermes` with another user on `~/.hermes/profiles/` stays - allowed. The CLI explains both codes. - - `memhub sources connect --user-id` with an invalid id now says the user - id need not equal the Hermes profile name (e.g. `--user-id wife-work - --root ~/.hermes/profiles/wife:work`). - - Hy Memory: a model failure during a write now reports failure and stores nothing. **Behaviour change for direct SDK users:** writes that used to return `success: true` in a degraded state now return `success: false`; @@ -78,30 +51,6 @@ Hy Memory and MemHub are versioned and released independently. superseding anything. Fact reconcile still makes no model call when there are no candidates. -- MemHub's DeepSeek Harness app page can grant recall again (#230). The - "一键插入应用" switch used to be greyed out with `unsupported_host`, and a - profile installed from the CLI stayed `install-only` with no way to approve - it. The one switch now covers every detected profile - (`deepseek-harness:`): on installs each profile's MCP patch and - grants recall, off removes the patch and revokes the grant. The page lists - each profile with its state, and names any profile that failed while - keeping the others. While the switch is on, a profile created later is - installed and granted automatically, without restarting MemHub; the Runtime - also no longer fails every host read when it meets a profile created after - it started. The harness docs no longer describe an unmounted sources page. - -- MemHub's `deepseek-harness` source imports session files whose names carry the - format generation: `session.v3.jsonl.zstd` and `session.v4.jsonl.zstd` are now - discovered alongside the original `session.jsonl[.zstd]`. Discovery accepts - any `session[.vN].jsonl[.zstd]` name and the header's `version` decides - whether the session is parsed (v0/v3/v4) or refused as `unsupported_schema`. - A refused file is reported and keeps no cursor, so the release that learns - its format reads it from the top. - v4 tool results, which are written as plain `text` blocks rather than - `tool-result` blocks, are read through the same clipping and credential - scrubbing path. Previously every versioned session was invisible: the import - job completed with `discovered 0` while the files sat on disk. - ## Hy Memory 2.0.12 — 2026-11-02 - Intention dedup is now configurable per client @@ -124,14 +73,17 @@ Hy Memory and MemHub are versioned and released independently. ## MemHub 1.0.6 — 2026-11-02 -Build 37, on `hy-memory==2.0.11`. Linux x64/arm64 Runtime and macOS arm64 +Build 37, on `hy-memory==2.0.13`. Linux x64/arm64 Runtime and macOS arm64 App and Runtime. - Interfaces in 11 languages: English, Simplified and Traditional Chinese, Japanese, Spanish, German, Portuguese, French, Korean, Russian and - Indonesian. Choosing any language other than Simplified Chinese also sets - the SDK prompt language; Simplified Chinese keeps the automatic zh/en - detection. + Indonesian. Every interface language, Simplified Chinese included, sets the + SDK prompt language (#270). **User-visible:** zh-Hans users now get Chinese + prompts and Chinese memory text for every conversation, English ones + included; the App sends the language it displays, also when it follows the + system. Only an unset language (CLI/API installs, `memhub settings language + system`) keeps the SDK's zh/en detection. - Memory modes: lite (no LLM, keeps raw conversations), pro (LLM extraction) and ultra (pro plus the knowledge graph and a nightly evolve run). Set with `GET`/`PUT /api/v1/settings/memory-mode` or `memhub settings mode`. Raw @@ -144,10 +96,52 @@ App and Runtime. embedding service is unavailable, search returns `embedding_unavailable`. - Multi-user Runtime: memory APIs and the CLI take `user_id` (default `local`); records are isolated per user; Hermes profiles can be connected as - separate users. The App keeps using `local`. + separate users. The App keeps using `local`. Hardening (#280): read + responses echo `user_id`; deleting another user's memory, or one that does + not exist, is 404 `memory_not_found` (the App and `memhub memories delete` + treat it as already gone); Hermes never reads a `profiles/` directory, and + a root that is the `profiles` container or overlaps another user's grant is + refused (`hermes_root_is_profiles_container`, + `hermes_root_overlaps_other_user`). - Configurable SDK features through the CLI and API (`memhub settings features`): graph rerank, episode recall, session episodes, episode - summaries, intention and few-shot. Defaults are unchanged. + summaries, intention and few-shot, and the intention dedup switch with its + threshold (`intention_dedup`, `intention_dedup_threshold`, #277). Defaults + are unchanged. +- Memory writes retry themselves (#278), each time under a new SDK request + id after checking whether the previous one landed; interrupted writes + re-run automatically. A model provider failure (401/403, 404, 408, 429, + 5xx, timeout, unreachable) keeps the job pending in place without spending + a retry, and a per-profile circuit breaker paces the queue (5 s doubling to + 180 s, one probe job at a time). Any other failure is retried after 30, 60 + and 120 s, then `failed`. With `hy-memory==2.0.13` a model failure is no + longer a silent success, and a failed job's `error_code` names the SDK's + cause (`hy_sdk_reconcile_empty_response`, `hy_sdk_llm_error`, ...) rather + than `hy_sdk_write_failed`. **User-visible:** the manual 重新整理 button, + the "整理结果不确定" display and + `POST /api/v1/memory-processing/retry-unknown` are gone; a banner + ("模型配置有问题……记忆暂停处理,已有 N 条在排队") with a button to the model + settings shows while a model problem pauses processing, and clears on + recovery. +- Queue, WAL and recall (#278): the WAL stays bounded (64 MiB journal limit, + idle `TRUNCATE` checkpoints); a disabled or revoked source's jobs are + `suspended` and resume when it is turned back on; SDK writes and recall + reads use separate thread pools, and recall has a total budget (504 + `recall_timeout`, `broker_timeout` in clients) instead of starving behind + stuck writes. +- One Runtime per data dir (#273). App quit asks the service manager at quit + time: an enabled background service is never stopped, a disabled one has + every Runtime of the data dir stopped (only after an authenticated identity + check), and an unknown state stops nothing. At start the App adopts a + Runtime of the same build, replaces an older one, and silently adopts a + newer one. The tray quit label follows the current state. +- DeepSeek Harness: the app page's one switch installs and grants recall for + every detected profile, and keeps new profiles granted without a restart + (#230); versioned session files (`session.v3.jsonl.zstd`, + `session.v4.jsonl.zstd`) are imported, and v4 tool output goes through the + same clipping and scrubbing (#175). +- The packaged Runtime accepts only a full 40-character commit, lowercased; a + malformed `ONE_MEMORY_COMMIT` fails the build (#235, #236). - Search and recall pass through the SDK's search parameters with server-side score floors; the memory library filters by layer; `/api/v2/raw` returns data again; v2 source and import routes; profile version history. diff --git a/README.md b/README.md index 28d4940..eb315f0 100644 --- a/README.md +++ b/README.md @@ -33,7 +33,7 @@ English | [中文](README.zh.md) ## Latest News * *[new]* 🔥🔥🔥 **[2026-10]**: 🚀 **Open-sourced** quantized **FP8** (**[Hy-Memory-30B-A3B-FP8](https://huggingface.co/tencent/Hy-Memory-30B-A3B-FP8)**) and **GGUF** (**[Hy-Memory-30B-A3B-GGUF](https://huggingface.co/tencent/Hy-Memory-30B-A3B-GGUF)**) versions of **Hy-Memory-30B-A3B** on Hugging Face! -* *[new]* 🔥🔥🔥 **[2026-11]**: 📦 **Released [MemHub v1.0.6](https://github.com/Tencent-Hunyuan/Hy-Memory/releases/tag/v1.0.6)** with interfaces in 11 languages, memory modes (lite / pro / ultra with nightly evolve), safe embedding-model switching, multi-user support for the CLI and API, and more reliable upgrades, on **`hy-memory==2.0.11`**. See the release notes for details. +* *[new]* 🔥🔥🔥 **[2026-11]**: 📦 **Released [MemHub v1.0.6](https://github.com/Tencent-Hunyuan/Hy-Memory/releases/tag/v1.0.6)** with interfaces in 11 languages, memory modes (lite / pro / ultra with nightly evolve), safe embedding-model switching, multi-user support for the CLI and API, memory writes that retry themselves and pause while the model provider is down, and more reliable upgrades, on **`hy-memory==2.0.13`**. See the release notes for details. * *[new]* 🔥🔥🔥 **[2026-10]**: 📦 **Released [MemHub v1.0.5](https://github.com/Tencent-Hunyuan/Hy-Memory/releases/tag/v1.0.5)** with Chinese, English and Spanish interfaces, one-time pairing codes for more custom Harnesses, and smoother browsing, search, startup and recovery, on **`hy-memory==2.0.8`**. See the release notes for details. * *[new]* 🔥🔥🔥 **[2026-10]**: 📦 **Released [MemHub v1.0.4](https://github.com/Tencent-Hunyuan/Hy-Memory/releases/tag/v1.0.4)** and **[memhub-cli 1.0.5](https://www.npmjs.com/package/memhub-cli/v/1.0.5)**, with a signed macOS desktop app and verified macOS/Linux Runtimes! * *[new]* 🔥🔥🔥 **[2026-10]**: 🚀 **Released** **Hy Memory 2.0 (`hy-memory==2.0.4`)** on [PyPI](https://pypi.org/project/hy-memory/2.0.4/) and open-sourced the **Hy-Memory** monorepo together with **[MemHub](memhub/README.md)**! diff --git a/README.zh.md b/README.zh.md index 475b75e..6a2951c 100644 --- a/README.zh.md +++ b/README.zh.md @@ -33,7 +33,7 @@ ## 最新动态 * *[new]* 🔥🔥🔥 **[2026-10]**: 🚀 **模型开源** 在 Hugging Face 发布 **Hy-Memory-30B-A3B** 的 **FP8**(**[Hy-Memory-30B-A3B-FP8](https://huggingface.co/tencent/Hy-Memory-30B-A3B-FP8)**)与 **GGUF**(**[Hy-Memory-30B-A3B-GGUF](https://huggingface.co/tencent/Hy-Memory-30B-A3B-GGUF)**)量化版本模型! -* *[new]* 🔥🔥🔥 **[2026-11]**: 📦 **正式发布 [MemHub v1.0.6](https://github.com/Tencent-Hunyuan/Hy-Memory/releases/tag/v1.0.6)**:界面支持 11 种语言,新增记忆模式(lite / pro / ultra,ultra 每晚自动整理),可安全切换嵌入模型,CLI 和 API 支持多用户,升级更可靠;底层使用 **`hy-memory==2.0.11`**。详见发布说明。 +* *[new]* 🔥🔥🔥 **[2026-11]**: 📦 **正式发布 [MemHub v1.0.6](https://github.com/Tencent-Hunyuan/Hy-Memory/releases/tag/v1.0.6)**:界面支持 11 种语言,新增记忆模式(lite / pro / ultra,ultra 每晚自动整理),可安全切换嵌入模型,CLI 和 API 支持多用户,记忆写入自动重试、模型服务异常时暂停排队,升级更可靠;底层使用 **`hy-memory==2.0.13`**。详见发布说明。 * *[new]* 🔥🔥🔥 **[2026-10]**: 📦 **正式发布 [MemHub v1.0.5](https://github.com/Tencent-Hunyuan/Hy-Memory/releases/tag/v1.0.5)**:支持中文、英文和西班牙语界面,可通过一次性配对码连接更多自定义 Harness,并优化了记忆浏览、搜索、启动和异常恢复;底层使用 **`hy-memory==2.0.8`**。详见发布说明。 * *[new]* 🔥🔥🔥 **[2026-10]**: 📦 **正式发布 [MemHub v1.0.4](https://github.com/Tencent-Hunyuan/Hy-Memory/releases/tag/v1.0.4)** 与 **[memhub-cli 1.0.5](https://www.npmjs.com/package/memhub-cli/v/1.0.5)**,提供已签名的 macOS 桌面应用及经过验证的 macOS/Linux Runtime! * *[new]* 🔥🔥🔥 **[2026-10]**: 🚀 **正式发布** **Hy Memory 2.0 (`hy-memory==2.0.4`)**(已上线 [PyPI](https://pypi.org/project/hy-memory/2.0.4/)),并开源 **Hy-Memory** monorepo 与 **[MemHub](memhub/README.md)**! diff --git a/memhub/README.md b/memhub/README.md index 8f15ea6..1bc0aae 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.12`。MemHub 源码版本为 `1.0.6` +源码当前固定使用公开 PyPI 正式版 `hy-memory==2.0.13`。MemHub 源码版本为 `1.0.6` (build 37),GitHub 仓库为 [`Tencent-Hunyuan/Hy-Memory`](https://github.com/Tencent-Hunyuan/Hy-Memory),npm 包名为 `memhub-cli`。当前代码架构见 @@ -118,7 +118,7 @@ MemHub 的 SQLite 数据默认位于 `~/.one-memory`,不在 npm 包或 Runtime ## Hy Memory -SDK 依赖固定为公开 PyPI 正式版 `hy-memory==2.0.12`。SDK 配置、权限边界及 search +SDK 依赖固定为公开 PyPI 正式版 `hy-memory==2.0.13`。SDK 配置、权限边界及 search 返回格式见 [`MEMORY.md`](MEMORY.md)。HY search 返回的 `content` 已包含 SDK 统一生成的 逻辑时间和演化上下文;调用方不应再次转换时间戳或重复拼接 `When`。 diff --git a/memhub/SBOM.spdx.json b/memhub/SBOM.spdx.json index fb3563e..63db50b 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.12", + "versionInfo": "2.0.13", "downloadLocation": "NOASSERTION", "filesAnalyzed": false, "licenseConcluded": "Apache-2.0", diff --git a/memhub/THIRD_PARTY_NOTICES.md b/memhub/THIRD_PARTY_NOTICES.md index 793ea43..5438453 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.12 +## hy-memory 2.0.13 License: Apache-2.0 diff --git a/memhub/apps/core/pyproject.toml b/memhub/apps/core/pyproject.toml index 79cdc92..9f2156e 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.12", + "hy-memory==2.0.13", # 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/hy_runtime_config.py b/memhub/apps/core/src/one_memory/hy_runtime_config.py index 00162f4..7c17804 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.12" +HY_MEMORY_VERSION = "2.0.13" HY_MEMORY_NAMESPACE = "governed-v2" DiagnosticLogLevel = Literal["error", "warning", "info", "debug"] diff --git a/memhub/apps/core/src/one_memory/hy_sdk_migration.py b/memhub/apps/core/src/one_memory/hy_sdk_migration.py index f5ac5ff..9072995 100644 --- a/memhub/apps/core/src/one_memory/hy_sdk_migration.py +++ b/memhub/apps/core/src/one_memory/hy_sdk_migration.py @@ -503,7 +503,7 @@ def _ensure_coding_schema(path: Path) -> None: CodingMemoryStore, ) - # Relies on the pinned SDK's internals (hy-memory==2.0.12 is pinned exactly). + # Relies on the pinned SDK's internals (hy-memory==2.0.13 is pinned exactly). store = CodingMemoryStore.__new__(CodingMemoryStore) store._db_path = str(path) store._conn = None diff --git a/memhub/apps/core/tests/test_sdk_migration_bundle.py b/memhub/apps/core/tests/test_sdk_migration_bundle.py index 8af0ee0..4bdfff9 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.12" + assert manifest["snapshot_sdk_version"] == "2.0.13" 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 18d2730..b926bd5 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.12" +version = "2.0.13" 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/44/19/bcd0de44ae2011ee1ad70579996aad72e18f0ca292df22d1545db05db850/hy_memory-2.0.12.tar.gz", hash = "sha256:c1231596b4cf12595775541f81680d4819461cb69e4aa4dc449f876d4c3b6742", size = 1246969, upload-time = "2026-11-02T06:30:37.448Z" } +sdist = { url = "https://files.pythonhosted.org/packages/88/46/7d5a2d76be135664302d91e15f0d5018cf8e228956e4c80b86583b131cb3/hy_memory-2.0.13.tar.gz", hash = "sha256:f95850d492de74e91891502716da96e8b5297c3fc045dc70ec3474ed240da958", size = 1252802, upload-time = "2026-11-02T09:30:40.331Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/75/9e/b64c7643c1bad1ddca4623a39bb62423bed0c22e7fd0d86be1ff73b2a17a/hy_memory-2.0.12-py3-none-any.whl", hash = "sha256:7a2332288197fafb89d5037eac7fef97df4c80794ead18159d21da84cde431df", size = 1316922, upload-time = "2026-11-02T06:30:35.105Z" }, + { url = "https://files.pythonhosted.org/packages/99/63/2b4d21d555a5dd50ab838fad944c7fdd6cf90d2dc814e2a8e79eca3d4e03/hy_memory-2.0.13-py3-none-any.whl", hash = "sha256:128cf4729e6ea10b9da3c7fdf21005191294621a65378cd9583827904eea064a", size = 1322154, upload-time = "2026-11-02T09:30:37.746Z" }, ] [[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.12" }, + { name = "hy-memory", specifier = "==2.0.13" }, { name = "keyring", specifier = ">=25.7.0" }, { name = "numpy", specifier = "==2.5.2" }, { name = "onnxruntime", specifier = "==1.23.2" }, diff --git a/memhub/docs/ARCHITECTURE.md b/memhub/docs/ARCHITECTURE.md index 26d0405..1ccb684 100644 --- a/memhub/docs/ARCHITECTURE.md +++ b/memhub/docs/ARCHITECTURE.md @@ -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.12` is the current stable SDK dependency; the release +- `hy-memory==2.0.13` 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). diff --git a/memhub/scripts/generate_sbom.py b/memhub/scripts/generate_sbom.py index 63166b3..d71d5a5 100644 --- a/memhub/scripts/generate_sbom.py +++ b/memhub/scripts/generate_sbom.py @@ -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.12" +HY_MEMORY_VERSION = "2.0.13" class SbomError(RuntimeError): diff --git a/memhub/scripts/install-docs.test.mjs b/memhub/scripts/install-docs.test.mjs index 0cc24ef..45722fb 100644 --- a/memhub/scripts/install-docs.test.mjs +++ b/memhub/scripts/install-docs.test.mjs @@ -12,7 +12,7 @@ test("installation docs disclose publication and lifecycle boundaries", async () "utf8", ); - assert.match(readme, /`hy-memory==2\.0\.12`/); + assert.match(readme, /`hy-memory==2\.0\.13`/); assert.match(readme, /`v1\.0\.6` 的发行目标是 Linux x64\/arm64 Runtime/); assert.match(readme, /Linux 制品由 GitHub Actions/); assert.match(readme, /macOS.*Developer ID 签名、公证/s); diff --git a/memhub/scripts/test_generate_sbom.py b/memhub/scripts/test_generate_sbom.py index 3833aae..2529c3f 100644 --- a/memhub/scripts/test_generate_sbom.py +++ b/memhub/scripts/test_generate_sbom.py @@ -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.12.dist-info" + dist_info = tmp_path / "hy_memory-2.0.13.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.12", + "Version: 2.0.13", "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.12" + assert notice["version"] == "2.0.13" 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"] From 76c459e39aca6d4bef2a7543d979b423062529dd Mon Sep 17 00:00:00 2001 From: alvinfei Date: Mon, 2 Nov 2026 21:03:22 +0800 Subject: [PATCH 2/2] fix(memhub): a failed SDK write keeps the SDK's cause as its error code hy-memory 2.0.13 reports a model failure as success: false with agent_error_code / reconcile_error_code. The job's error_code now carries that cause (hy_sdk_reconcile_empty_response, hy_sdk_llm_error, ...) instead of a bare hy_sdk_write_failed; only upper-snake codes are kept, never a message. Regression tests: a reconcile provider failure (401/503/timeout) and a coding-path extractor failure wait for the provider and complete after recovery; an empty or unparseable 200 reply is retried 3 times, then failed with its cause. Co-Authored-By: Claude Opus 5.5 (1M context) --- .../src/one_memory/sdk_write_processor.py | 28 +- memhub/apps/core/tests/test_model_backoff.py | 257 ++++++++++++++++++ 2 files changed, 284 insertions(+), 1 deletion(-) diff --git a/memhub/apps/core/src/one_memory/sdk_write_processor.py b/memhub/apps/core/src/one_memory/sdk_write_processor.py index ee53e8b..3ce09b6 100644 --- a/memhub/apps/core/src/one_memory/sdk_write_processor.py +++ b/memhub/apps/core/src/one_memory/sdk_write_processor.py @@ -3,6 +3,7 @@ from __future__ import annotations import asyncio +import re import threading from collections.abc import AsyncIterator, Awaitable, Callable, Mapping, Sequence from contextlib import asynccontextmanager @@ -568,11 +569,36 @@ def _is_extracted_content(record: EvidenceRecord) -> bool: ) +#: The SDK's stable failure codes are upper snake case (`RECONCILE_FAILED`). +_SDK_FAILURE_CODE = re.compile(r"[A-Z][A-Z0-9_]{0,47}") + + +def sdk_write_failure_code(value: Mapping[object, object]) -> str: + """The job's `error_code` for a failed SDK write: the SDK's own cause if it gave one. + + hy-memory 2.0.13 names why a write failed: `agent_error_code` + (`LLM_ERROR`, `EMPTY_RESPONSE`, `JSON_PARSE_FAILED`, `EMBEDDING_FAILED`, + `RECONCILE_FAILED`) and, for a failed reconcile, `reconcile_error_code` + (`LLM_UNAVAILABLE`, `EMPTY_RESPONSE`, `JSON_PARSE_FAILED`, + `RECONCILE_EXCEPTION`). They become `hy_sdk_reconcile_empty_response`, + `hy_sdk_llm_error`, ... Without a recognisable code it stays + `hy_sdk_write_failed`. Only the code is kept, never the SDK's message. + """ + reconcile = value.get("reconcile_error_code") + if isinstance(reconcile, str) and _SDK_FAILURE_CODE.fullmatch(reconcile): + cause = reconcile.removeprefix("RECONCILE_") or "FAILED" + return f"hy_sdk_reconcile_{cause.lower()}" + agent = value.get("agent_error_code") + if isinstance(agent, str) and _SDK_FAILURE_CODE.fullmatch(agent): + return f"hy_sdk_{agent.lower()}" + return "hy_sdk_write_failed" + + def _receipt_from_result(value: object) -> SdkWriteReceipt: if not isinstance(value, Mapping): raise RuntimeError("hy_sdk_write_receipt_shape_invalid") if value.get("success") is not True: - raise RuntimeError("hy_sdk_write_failed") + raise RuntimeError(sdk_write_failure_code(value)) # Read only documented public receipt fields. ``variant`` identifies the # productivity namespace; IDs from both variants are canonical memory IDs. diff --git a/memhub/apps/core/tests/test_model_backoff.py b/memhub/apps/core/tests/test_model_backoff.py index ea50fd6..019459c 100644 --- a/memhub/apps/core/tests/test_model_backoff.py +++ b/memhub/apps/core/tests/test_model_backoff.py @@ -14,6 +14,13 @@ from pydantic import SecretStr from one_memory.api import create_app from one_memory.config import Settings +from one_memory.imports.models import ( + EvidenceInput, + EvidenceOrigin, + OriginMetadata, + WrittenBy, + materialize_write_input, +) from one_memory.memory_server.contracts import BatchIdentity, WriteRequest from one_memory.memory_server.durable_queue import ( FAILED, @@ -29,6 +36,7 @@ from one_memory.model_backoff import ( ModelBackoffRefusal, backoff_seconds, ) +from one_memory.sdk_write_processor import SdkWriteProcessor, sdk_write_failure_code from one_memory.secrets import InMemorySecretStore CHAT = "https://models.example.com/v1/chat/completions" @@ -605,3 +613,252 @@ def test_the_manual_retry_endpoint_is_gone(tmp_path) -> None: return response.status_code assert asyncio.run(scenario()) in {404, 405} + + +# --- hy-memory 2.0.13: a model failure is `success: false`, never a silent success --- + + +class _Sdk2013Client: + """`HyMemoryClient.add` as hy-memory 2.0.13 answers it. + + Each write makes one model call through MemHub's transport, so the + breaker sees what the provider answered. The SDK turns a failed or + unusable model reply into `success: false` with its stable codes; the + shape of those results is the SDK's own (`_mark_reconcile_failure`, + `CodingWriter`). + """ + + def __init__(self, transport: BackedOffModelTransport) -> None: + self.transport = transport + self.reply = "[]" + self.messages: list[list[dict[str, str]]] = [] + + def add(self, messages, **_kwargs): + self.messages.append(messages) + coding = any(message["role"] == "tool" for message in messages) + try: + response = asyncio.run(self.transport.handle_async_request(httpx.Request("POST", CHAT))) + except httpx.TransportError: + return self._failed(coding, "LLM_UNAVAILABLE", "LLM_ERROR") + if response.status_code >= 400: + return self._failed(coding, "LLM_UNAVAILABLE", "LLM_ERROR") + if not self.reply: + return self._failed(coding, "EMPTY_RESPONSE", "EMPTY_RESPONSE") + if not self.reply.startswith("["): + return self._failed(coding, "JSON_PARSE_FAILED", "JSON_PARSE_FAILED") + return {"success": True, "memory_ids": ["m-1"]} + + @staticmethod + def _failed(coding: bool, reconcile_cause: str, coding_code: str) -> dict[str, object]: + if coding: + return {"success": False, "agent_error_code": coding_code} + return { + "success": False, + "error_code": 502, + "agent_error_code": "RECONCILE_FAILED", + "reconcile_error_code": reconcile_cause, + } + + +class _Sdk2013Handle: + mode = "pro" + + def __init__(self, client: _Sdk2013Client) -> None: + self.sdk = client + + async def client(self) -> _Sdk2013Client: + return self.sdk + + +class _Sdk2013Supervisor: + def __init__(self, client: _Sdk2013Client) -> None: + self.processor = SdkWriteProcessor(_Sdk2013Handle(client)) # type: ignore[arg-type] + + async def process_sdk_job(self, records, *, request_id, before_hy_invocation): + return await self.processor.process_sdk_job( + records, request_id=request_id, before_hy_invocation=before_hy_invocation + ) + + async def inquire_write_outcome(self, _request_id): + return None + + +class _Turns: + """Evidence for one conversation batch: a chat turn, or a coding turn with tools.""" + + def __init__(self, roles: tuple[str, ...]) -> None: + self.records = { + f"e-{index}": materialize_write_input( + EvidenceInput( + user_id="local", + source_id="cursor", + conversation_id="sess-1", + message_id=f"msg-{index}", + role=role, + written_by=WrittenBy.PERSON, + created_at=datetime(2026, 10, 1, tzinfo=UTC), + verbatim=f"{role} says something worth keeping ({index})", + origin_meta={EvidenceOrigin.FILE: OriginMetadata()}, + ) + ) + for index, role in enumerate(roles) + } + + def get(self, evidence_id: str): + return self.records[evidence_id] + + def request(self) -> WriteRequest: + return WriteRequest( + internal_session_id="sess-1", + batch_identity=BatchIdentity(internal_session_id="sess-1", first_round=1, last_round=1), + evidence_ids=list(self.records), + idempotency_key="idem-000", + source_id="cursor", + ) + + +def _outage_then_recovery(tmp_path, roles: tuple[str, ...], failure) -> tuple[list, list]: + clock = _Clock() + backoff = ModelBackoff(clock=clock) + calls: list[float] = [] + + def provider(request: httpx.Request) -> httpx.Response: + calls.append(clock.now) + if clock.now < 60.0: + return failure(request) + return httpx.Response(200) + + client = _Sdk2013Client( + BackedOffModelTransport(backoff, profile_id="p:1", inner=httpx.MockTransport(provider)) + ) + turns = _Turns(roles) + path = tmp_path / "one-memory.sqlite3" + queue = DurableWriteQueue(path) + queue.submit(turns.request()) + drain = DurableWriteDrain( + queue, execute=_never_execute, owner="outage", model_claim_allowance=backoff.allowance + ) + seen: list[tuple[str, int, int, str | None]] = [] + for second in range(120): + clock.now = float(second) + for job_id in drain.claim(limit=1): + asyncio.run( + execute_job( + job_id, + queue=queue, + supervisor=_Sdk2013Supervisor(client), + evidence=turns, + owner="outage", + model_failures=backoff.attempt, + ) + ) + seen.append(_jobs(path)[0][1:]) + assert all(len(sent) == len(roles) for sent in client.messages) + return calls, seen + + +def _timeout(request: httpx.Request) -> httpx.Response: + raise httpx.ReadTimeout("provider timed out", request=request) + + +@pytest.mark.parametrize( + ("failure", "code"), + [ + (lambda _request: httpx.Response(401), "model_auth_failed"), + (lambda _request: httpx.Response(503), "model_server_error"), + (_timeout, "model_timeout"), + ], + ids=["401", "503", "timeout"], +) +def test_a_reconcile_model_failure_waits_for_the_provider_and_completes_after_it( + tmp_path, failure, code +) -> None: + """hy-memory 2.0.13 fails the write when reconcile cannot reach its model. + + The breaker saw the provider fail, so the job waits in place without + spending a retry, is tried once per pause, and lands after recovery. + """ + calls, seen = _outage_then_recovery(tmp_path, ("user", "assistant"), failure) + assert calls == [0.0, 5.0, 15.0, 35.0, 75.0] + assert seen[:-1] == [("pending", attempt, 0, code) for attempt in range(1, 5)] + assert seen[-1] == ("completed", 4, 0, None) + + +def test_a_coding_batch_whose_extractor_fails_at_the_provider_waits_like_chat(tmp_path) -> None: + """Tool turns go down the SDK's coding path; its `LLM_ERROR` waits the same way.""" + calls, seen = _outage_then_recovery( + tmp_path, ("user", "assistant", "tool", "assistant"), lambda _request: httpx.Response(502) + ) + assert calls == [0.0, 5.0, 15.0, 35.0, 75.0] + assert seen[:-1] == [("pending", attempt, 0, "model_server_error") for attempt in range(1, 5)] + assert seen[-1] == ("completed", 4, 0, None) + + +@pytest.mark.parametrize( + ("reply", "code"), + [ + ("", "hy_sdk_reconcile_empty_response"), + ("I am not sure what you mean.", "hy_sdk_reconcile_json_parse_failed"), + ], + ids=["empty", "unparseable"], +) +def test_a_reconcile_reply_the_sdk_cannot_use_is_retried_then_failed_with_its_cause( + tmp_path, reply, code +) -> None: + """HTTP 200 with nothing usable is not a provider failure: 3 retries, then `failed`. + + The job's `error_code` names the SDK's cause instead of a bare + `hy_sdk_write_failed`, and the provider is never paused. + """ + backoff = ModelBackoff(clock=_Clock()) + client = _Sdk2013Client( + BackedOffModelTransport( + backoff, profile_id="p:1", inner=httpx.MockTransport(lambda _r: httpx.Response(200)) + ) + ) + client.reply = reply + turns = _Turns(("user", "assistant")) + path = tmp_path / "one-memory.sqlite3" + queue = DurableWriteQueue(path) + queue.submit(turns.request()) + drain = DurableWriteDrain(queue, execute=_never_execute, owner="bad-reply") + now = datetime.now(UTC) + for offset in (0, 40, 150, 400): + for job_id in drain.claim(limit=1, now=now + timedelta(seconds=offset)): + asyncio.run( + execute_job( + job_id, + queue=queue, + supervisor=_Sdk2013Supervisor(client), + evidence=turns, + owner="bad-reply", + model_failures=backoff.attempt, + ) + ) + assert _jobs(path)[0][1:] == ("failed", 3, 3, code) + assert queue.get("idem-000").error_code == code + assert len(client.messages) == 4 + assert backoff.allowance() is None + + +@pytest.mark.parametrize( + ("result", "code"), + [ + ( + {"agent_error_code": "RECONCILE_FAILED", "reconcile_error_code": "LLM_UNAVAILABLE"}, + "hy_sdk_reconcile_llm_unavailable", + ), + ( + {"agent_error_code": "RECONCILE_FAILED", "reconcile_error_code": "RECONCILE_EXCEPTION"}, + "hy_sdk_reconcile_exception", + ), + ({"agent_error_code": "RECONCILE_FAILED"}, "hy_sdk_reconcile_failed"), + ({"agent_error_code": "EMBEDDING_FAILED"}, "hy_sdk_embedding_failed"), + ({"agent_error_code": "JSON_PARSE_FAILED"}, "hy_sdk_json_parse_failed"), + ({"agent_error_code": "provider said: bad key sk-123"}, "hy_sdk_write_failed"), + ({"agent_error_code": 502}, "hy_sdk_write_failed"), + ({}, "hy_sdk_write_failed"), + ], +) +def test_a_failed_sdk_write_keeps_only_the_sdks_stable_code(result, code) -> None: + assert sdk_write_failure_code({"success": False, **result}) == code