Compare commits

..
Author SHA1 Message Date
alvinfeiandCursor f0f3d81ca8 fix(memhub): retry unknown writes after ten seconds
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-10-17 18:30:48 +08:00
alvinfeiandCursor 405f49bfb7 fix(memhub): keep unknown writes from blocking queues
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-10-17 17:50:17 +08:00
46 changed files with 78 additions and 710 deletions

No files matched your search

+1 -25
View File
@@ -2,16 +2,6 @@
Hy Memory and MemHub are versioned and released independently.
## Hy Memory 2.0.3 — 2026-10-17
- Added an optional runtime HTTP transport factory so embedded products can
enforce request budgets without wrapping or restricting SDK local-store
access.
- Published
[`hy-memory==2.0.3`](https://pypi.org/project/hy-memory/2.0.3/) with
unchanged memory extraction, reconciliation, storage, and Coding/Working
behavior.
## Unreleased
- Removed MemHub's task-scoped HyPermission store wrappers and global
@@ -36,21 +26,7 @@ Hy Memory and MemHub are versioned and released independently.
Because PyPI release metadata is immutable, the next SDK patch release must
carry the updated license metadata; `2.0.1` retains its published metadata.
## MemHub 1.0.3 — Unreleased
- Updated the source version to `1.0.3` (build 7).
- Pinned the public SDK to `hy-memory==2.0.3`.
- Removed the HyPermission store wrappers and global model-content consent
switch; the trusted embedded SDK now uses native local stores.
- Prevented uncertain writes from blocking later source work and added one
reconciled automatic retry after ten seconds.
- Added product-facing memory pipeline counters and the log troubleshooting
runbook.
- Allowed a verified packaged Desktop Runtime from a different commit to take
over an equal-build install atomically while preserving downgrade and
tamper refusal.
## MemHub 1.0.2 — 2026-10-17
## MemHub 1.0.2 — Unreleased
- Updated the source version to `1.0.2` (build 6).
- Pinned the public SDK to `hy-memory==2.0.2`.
+8 -8
View File
@@ -32,7 +32,7 @@ English | [中文](README.zh.md)
## Latest News
* *<mark>[new]</mark>* 🔥🔥🔥 **[2026-10]**: 🚀 **Released** **Hy Memory 2.0 (`hy-memory==2.0.3`)** on [PyPI](https://pypi.org/project/hy-memory/2.0.3/) and open-sourced the **Hy-Memory** monorepo together with **[MemHub](memhub/README.md)**!
* *<mark>[new]</mark>* 🔥🔥🔥 **[2026-10]**: 🚀 **Released** **Hy Memory 2.0 (`hy-memory==2.0.2`)** on [PyPI](https://pypi.org/project/hy-memory/2.0.2/) and open-sourced the **Hy-Memory** monorepo together with **[MemHub](memhub/README.md)**!
* *<mark>[new]</mark>* 🔥🔥🔥 **[2026-10]**: 🚀 **Open-sourced** **[Hy-Memory-30B-A3B](https://huggingface.co/tencent/Hy-Memory-30B-A3B)** on Hugging Face along with its [technical report](https://arxiv.org/abs/2610.22196) on attributed reinforcement learning for long-term memory writing!
* *<mark>[new]</mark>* 🔥🔥 **[2026-09]**: 📊 **Released** **[CMB: A Coding Memory Benchmark for Long-Horizon and Interleaved Coding Agents](docs/papers/CMB_A_Coding_Memory_Benchmark_for_Long_Horizon_and_Interleaved_Coding_Agents.pdf)** (submitted to **ICLR 2027**).
* *<mark>[new]</mark>* 🔥🔥 **[2026-08]**: 🎉 **Accepted** at **EMNLP 2026**: **[Memory Beyond Recall: A Dual-Process Cognitive Memory System for Self-Evolving LLM Agents](https://arxiv.org/abs/2606.09483)**!
@@ -63,13 +63,13 @@ Welcome to the **Hy Memory Research Family**, covering cognitive chat memory, lo
### 1. Installation
```bash
pip install hy-memory==2.0.3 # core (includes ChromaDB and Kuzu)
pip install "hy-memory[qdrant]==2.0.3" # + Qdrant
pip install "hy-memory[faiss]==2.0.3" # + FAISS
pip install "hy-memory[graph]==2.0.3" # + Neo4j
pip install "hy-memory[mysql]==2.0.3" # + MySQL cache
pip install "hy-memory[rerank]==2.0.3" # + local multilingual cross-encoder rerank
pip install "hy-memory[all]==2.0.3" # everything
pip install hy-memory==2.0.2 # core (includes ChromaDB and Kuzu)
pip install "hy-memory[qdrant]==2.0.2" # + Qdrant
pip install "hy-memory[faiss]==2.0.2" # + FAISS
pip install "hy-memory[graph]==2.0.2" # + Neo4j
pip install "hy-memory[mysql]==2.0.2" # + MySQL cache
pip install "hy-memory[rerank]==2.0.2" # + local multilingual cross-encoder rerank
pip install "hy-memory[all]==2.0.2" # everything
```
### 2. Configuration
+8 -8
View File
@@ -32,7 +32,7 @@
## 最新动态
* *<mark>[new]</mark>* 🔥🔥🔥 **[2026-10]**: 🚀 **正式发布** **Hy Memory 2.0 (`hy-memory==2.0.3`)**(已上线 [PyPI](https://pypi.org/project/hy-memory/2.0.3/)),并开源 **Hy-Memory** monorepo 与 **[MemHub](memhub/README.md)**!
* *<mark>[new]</mark>* 🔥🔥🔥 **[2026-10]**: 🚀 **正式发布** **Hy Memory 2.0 (`hy-memory==2.0.2`)**(已上线 [PyPI](https://pypi.org/project/hy-memory/2.0.2/)),并开源 **Hy-Memory** monorepo 与 **[MemHub](memhub/README.md)**!
* *<mark>[new]</mark>* 🔥🔥🔥 **[2026-10]**: 🚀 **模型开源** 在 Hugging Face 发布 **[Hy-Memory-30B-A3B](https://huggingface.co/tencent/Hy-Memory-30B-A3B)** 长期记忆写入模型及[技术报告](https://arxiv.org/abs/2610.22196)!
* *<mark>[new]</mark>* 🔥🔥 **[2026-09]**: 📊 **基准与方法发布** 推出长周期与交错式编程记忆评测基准 **[CMB: A Coding Memory Benchmark for Long-Horizon and Interleaved Coding Agents](docs/papers/CMB_A_Coding_Memory_Benchmark_for_Long_Horizon_and_Interleaved_Coding_Agents.pdf)**(已投稿 **ICLR 2027**)。
* *<mark>[new]</mark>* 🔥🔥 **[2026-08]**: 🎉 **论文录用** 双系统认知记忆架构论文 **[Memory Beyond Recall: A Dual-Process Cognitive Memory System for Self-Evolving LLM Agents](https://arxiv.org/abs/2606.09483)** 被 **EMNLP 2026** 接收!
@@ -63,13 +63,13 @@
### 1. 安装
```bash
pip install hy-memory==2.0.3 # 核心(含 ChromaDB 与 Kuzu)
pip install "hy-memory[qdrant]==2.0.3" # + Qdrant
pip install "hy-memory[faiss]==2.0.3" # + FAISS
pip install "hy-memory[graph]==2.0.3" # + Neo4j
pip install "hy-memory[mysql]==2.0.3" # + MySQL 缓存
pip install "hy-memory[rerank]==2.0.3" # + 本地多语言 cross-encoder rerank
pip install "hy-memory[all]==2.0.3" # 全部
pip install hy-memory==2.0.2 # 核心(含 ChromaDB 与 Kuzu)
pip install "hy-memory[qdrant]==2.0.2" # + Qdrant
pip install "hy-memory[faiss]==2.0.2" # + FAISS
pip install "hy-memory[graph]==2.0.2" # + Neo4j
pip install "hy-memory[mysql]==2.0.2" # + MySQL 缓存
pip install "hy-memory[rerank]==2.0.2" # + 本地多语言 cross-encoder rerank
pip install "hy-memory[all]==2.0.2" # 全部
```
### 2. 配置
+1 -1
View File
@@ -37,7 +37,7 @@ do not belong in the public source tree.
- Root GitHub workflows run SDK and MemHub checks from their respective working
directories. Native lifecycle workflows produce reviewable Actions artifacts;
tag releases remain fail-closed on public identity, signing and notarization.
- Hy Memory is published as `hy-memory==2.0.3`. MemHub pins that public
- Hy Memory is published as `hy-memory==2.0.2`. MemHub pins that public
distribution; its selected npm package is `memhub-cli`, which is not yet
published.
- Hy-Memory source uses Apache-2.0 at the repository root. Third-party
+1 -1
View File
@@ -5,7 +5,7 @@
## 安装与快速开始
```bash
pip install hy-memory==2.0.3
pip install hy-memory==2.0.2
# 可选后端
pip install 'hy-memory[qdrant]==2.0.1'
+2 -2
View File
@@ -15,12 +15,12 @@ write、search,并把搜索结果格式化为可交给模型的 memory text ch
```text
distribution: hy-memory
version: 2.0.3
version: 2.0.2
mode: 按运行时配置选择
reader: hybrid
```
仓库 pin、lockfile 和运行时版本守卫均固定到公开的 `hy-memory==2.0.3`。
仓库 pin、lockfile 和运行时版本守卫均固定到公开的 `hy-memory==2.0.2`。
MemHub 在本地 Runtime 内嵌入 SDK,但不绕过自身的来源授权、模型授权、Evidence、
Governance 和撤权边界。
+4 -4
View File
@@ -6,13 +6,13 @@ SDK 和记忆引擎。One Memory / `one-memory` 是历史模块、数据目录
## 发行与平台合同
公共 Python SDK 为 `hy-memory==2.0.3`。MemHub 源码版本为 `1.0.3`
(build 7),GitHub 仓库为
公共 Python SDK 为 `hy-memory==2.0.2`。MemHub 源码版本为 `1.0.2`
(build 6),GitHub 仓库为
[`Tencent-Hunyuan/Hy-Memory`](https://github.com/Tencent-Hunyuan/Hy-Memory),npm 包名为
`memhub-cli`。当前代码架构见
[`docs/ARCHITECTURE.md`](docs/ARCHITECTURE.md)。
`v1.0.3` 的发行目标是 Linux x64/arm64 Runtime 与 macOS arm64/x64 App 和 Runtime。
`v1.0.2` 的发行目标是 Linux x64/arm64 Runtime 与 macOS arm64/x64 App 和 Runtime。
Linux 制品由 GitHub Actions 在原生架构 runner 上构建并完成生命周期验收;macOS
制品由发行流水线完成 Developer ID 签名、公证和验收后手动上传。Windows 不支持。
只有同时出现在 GitHub Release 与 `release-manifest.json` 中、且通过公开校验的制品才属于
@@ -101,7 +101,7 @@ MemHub 的 SQLite 数据默认位于 `~/.one-memory`,不在 npm 包或 Runtime
## Hy Memory
当前开发依赖固定为公开 PyPI 包 `hy-memory==2.0.3`。SDK 配置、权限边界及 search
当前开发依赖固定为公开 PyPI 包 `hy-memory==2.0.2`。SDK 配置、权限边界及 search
返回格式见 [`MEMORY.md`](MEMORY.md)。HY search 返回的 `content` 已包含 SDK 统一生成的
逻辑时间和演化上下文;调用方不应再次转换时间戳或重复拼接 `When`。
+3 -3
View File
@@ -3,9 +3,9 @@
"dataLicense": "CC0-1.0",
"SPDXID": "SPDXRef-DOCUMENT",
"name": "one-memory",
"documentNamespace": "https://one-memory.local/spdx/2026-10-17T11:07:57Z",
"documentNamespace": "https://one-memory.local/spdx/2026-10-15T17:26:47Z",
"creationInfo": {
"created": "2026-10-17T11:07:57Z",
"created": "2026-10-15T17:26:47Z",
"creators": [
"Tool: one-memory-generate-sbom"
]
@@ -2335,7 +2335,7 @@
{
"SPDXID": "SPDXRef-Package-hy-memory",
"name": "hy-memory",
"versionInfo": "2.0.3",
"versionInfo": "2.0.2",
"downloadLocation": "NOASSERTION",
"filesAnalyzed": false,
"licenseConcluded": "Apache-2.0",
+2 -2
View File
@@ -2,14 +2,14 @@
MemHub itself is licensed under Apache-2.0 (see `LICENSE`).
It bundles the runtime dependencies listed below.
Generated from the installed dependency tree on 2026-10-17T11:07:57Z
Generated from the installed dependency tree on 2026-10-15T17:26:47Z
by `python3 scripts/generate_sbom.py`. Do not edit by hand.
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.3
## hy-memory 2.0.2
License: Apache-2.0
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "@one-memory/cli",
"version": "1.0.3",
"version": "1.0.2",
"description": "MemHub bootstrap CLI for installing and managing the local-first shared memory runtime",
"license": "Apache-2.0",
"repository": {
-31
View File
@@ -81,7 +81,6 @@ import {
readMemoryWrite,
readScanRoots,
removeScanRoot,
resetOnboarding,
submitBackup,
submitMemoryWrite,
submitScan,
@@ -689,36 +688,6 @@ async function runCommand(
// `path list` and `adapter list` are read-only: they have no plan to confirm,
// so they are answered here rather than falling into the two-phase branch
// (which would reject them for having nothing to apply).
// Back to the cold-start screens on purpose. Deleting the app cannot do this
// (the completion flag lives with the memories, by design), and deleting the
// data to get there would cost the user everything they have stored.
if (named === "onboarding" && arguments_.includes("reset")) {
if (
arguments_.filter((item) => item === "--json").length > 1 ||
arguments_.filter((item) => item !== "--json").join(" ") !==
"onboarding reset"
) {
return usage();
}
const attachment = await attachToRuntime(
resolveRuntimeCliConfig(environment, process.argv[1]).dataDir,
);
if (attachment === null) {
return runtimeUnavailable(descriptor, asJson);
}
const state = await resetOnboarding(attachment);
if (state === null) {
return reject(descriptor, asJson, "引导状态端点不可用。");
}
if (asJson) {
process.stdout.write(`${JSON.stringify(state)}\n`);
return EXIT_OK;
}
process.stdout.write(
`引导流程已回到首次运行(status=${state.status}),记忆数据未改动。\n`,
);
return EXIT_OK;
}
if (named === "path" && arguments_.includes("list")) {
if (
arguments_.filter((item) => item === "--json").length > 1 ||
-8
View File
@@ -651,14 +651,6 @@ export const COMMANDS: readonly CommandDescriptor[] = [
],
exit_codes: [EXIT_OK, EXIT_FAILED, EXIT_USAGE, EXIT_PLAN_BLOCKED],
},
{
name: "onboarding",
summary:
"onboarding reset 回到首次运行的引导流程。只清除引导完成状态:不删记忆数据,也不需要卸载重装。",
usage: "memhub onboarding reset [--json]",
options: [JSON_OPTION],
exit_codes: [EXIT_OK, EXIT_FAILED, EXIT_USAGE],
},
{
name: "help",
summary: "显示总帮助或单个命令的用法。",
@@ -92,7 +92,6 @@ const COMMANDS = [
"uninstall",
"upgrade",
"delete-data",
"onboarding",
"help",
"version",
// A6 product commands. Appended, not inserted: the catalog is a stable
-35
View File
@@ -397,41 +397,6 @@ export async function readMemoryWrite(
}
}
export interface OnboardingState {
status: string;
selected_source_ids: string[];
started_at: string | null;
completed_at: string | null;
}
/** Send onboarding back to its first-run state.
*
* Returns null when the endpoint is not there, so an older runtime degrades to
* "not available" instead of failing the command. Only the completion flag
* moves: memories, sources and settings live in other tables, which is what
* makes this the honest way to see the cold-start screens again.
*/
export async function resetOnboarding(
attachment: RuntimeAttachmentLike,
): Promise<OnboardingState | null> {
try {
const response = await axios.post<OnboardingState>(
`${baseUrlOf(attachment)}/api/v1/onboarding/reset`,
undefined,
{
headers: { Authorization: `Bearer ${attachment.apiToken}` },
timeout: 15_000,
proxy: false,
maxRedirects: 0,
maxContentLength: MAX_RESPONSE_BYTES,
},
);
return response.data;
} catch {
return null;
}
}
export async function readScanRoots(
attachment: RuntimeAttachmentLike,
): Promise<{
+2 -2
View File
@@ -4,7 +4,7 @@ build-backend = "uv_build"
[project]
name = "one-memory"
version = "1.0.3"
version = "1.0.2"
description = "MemHub local-first memory core"
readme = "README.md"
requires-python = ">=3.12"
@@ -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.3",
"hy-memory==2.0.2",
# Retrieval's derived vector index directly consumes these locked versions.
"chromadb==1.5.9",
"numpy==2.5.2",
-14
View File
@@ -1886,20 +1886,6 @@ def create_app(
"""Record UI completion only; finishing or skipping is never model consent."""
return await asyncio.to_thread(_onboarding_store(app).complete)
@app.post(
"/api/v1/onboarding/reset",
response_model=OnboardingState,
dependencies=protected,
)
async def reset_onboarding() -> OnboardingState:
"""Go back to the first-run screens. Only the completion flag moves.
Memories, sources and settings live in other tables and are untouched,
which is the point: someone who wants to see the cold-start flow again
should not have to delete anything to do it.
"""
return await asyncio.to_thread(_onboarding_store(app).reset)
@app.get(
"/api/v1/sources",
response_model=list[SourceResponse],
@@ -14,7 +14,7 @@ from pathlib import Path
from typing import Literal, cast
HY_MEMORY_DISTRIBUTION = "hy-memory"
HY_MEMORY_VERSION = "2.0.3"
HY_MEMORY_VERSION = "2.0.2"
HY_MEMORY_NAMESPACE = "governed-v2"
DiagnosticLogLevel = Literal["error", "warning", "info", "debug"]
@@ -35,7 +35,7 @@ _COLLECTION_LOGGERS = (
# One Memory deliberately stays on ultra because its existing, user-visible
# profile/graph flow calls digest(), which the pinned public SDK exposes only
# for ultra clients. Reader and non-reranker behavior remain pinned for
# compatibility with public SDK 2.0.3.
# compatibility with public SDK 2.0.2.
HY_MEMORY_MODE = "ultra"
# The SDK only recognises `legacy` and `hybrid` (see
# hy_memory/pipelines/_retrieval/config.py: `ALL_READERS = (READER_LEGACY,
@@ -3,7 +3,7 @@
The result decides whether the `unknown` job state can ever be retired, so it
is probed against the installed SDK rather than written down from memory.
What was verified against `hy-memory==2.0.3`:
What was verified against `hy-memory==2.0.2`:
- `HyMemoryClient.add(..., request_id=...)` accepts a caller-supplied id and
echoes it back. It is described as a request/receipt handle, and the SDK
@@ -68,16 +68,6 @@ COMPLETE_INSERT_MYSQL = (
"singleton, status, selected_source_ids_json, started_at, completed_at, updated_at"
") VALUES (1, 'completed', '[]', NULL, %s, %s)"
)
RESET_UPDATE_SQLITE = (
"UPDATE onboarding_state SET status = 'not_started', "
"selected_source_ids_json = '[]', started_at = NULL, completed_at = NULL, "
"updated_at = ? WHERE singleton = 1"
)
RESET_UPDATE_MYSQL = (
"UPDATE onboarding_state SET status = 'not_started', "
"selected_source_ids_json = '[]', started_at = NULL, completed_at = NULL, "
"updated_at = %s WHERE singleton = 1"
)
class OnboardingState(BaseModel):
@@ -150,30 +140,6 @@ class OnboardingStore:
)
return self.get()
def reset(self) -> OnboardingState:
"""Return onboarding to its first-run state, touching nothing else.
The completion flag is stored beside the memories on purpose: a fresh
install of the app must not greet someone who already has memories as a
first-time user. That makes the way back to the cold-start screens an
explicit action rather than a side effect of reinstalling, and it is
the whole reason this exists: QA and long-time users need to see those
screens again without giving up a single memory.
Sources, memories and settings are separate tables and are not read or
written here.
"""
now = datetime.now(UTC).isoformat()
mysql = is_mysql(self._store)
with self._connect(writing=True) as connection:
execute(
connection,
mysql,
RESET_UPDATE_MYSQL if mysql else RESET_UPDATE_SQLITE,
(now,),
)
return self.get()
@contextmanager
def _connect(
self,
@@ -1326,7 +1326,7 @@ def test_hy_runtime_matches_public_sdk_without_reranker_or_service_backends(
config = build_hy_memory_config(profile, api_key=None, data_dir=tmp_path)
assert HY_MEMORY_DISTRIBUTION == "hy-memory"
assert HY_MEMORY_VERSION == "2.0.3"
assert HY_MEMORY_VERSION == "2.0.2"
assert HY_MEMORY_MODE == "ultra"
assert HY_MEMORY_READER == "hybrid"
assert os.environ["MEMORY_MODE"] == "ultra"
@@ -69,7 +69,7 @@ def test_no_idempotency_key_means_unknown_must_remain() -> None:
def test_measured_sdk_verdict_is_recorded() -> None:
"""Pin the measured answer for `hy-memory==2.0.3`.
"""Pin the measured answer for `hy-memory==2.0.2`.
The finding: `add()` takes a `request_id` but nothing de-duplicates on it,
while `MemoryInspector.get_pipeline_logs(request_id=...)` can look the
@@ -13,7 +13,6 @@ basic info 那半边留在 Kuzu 里,本地看不见。
"""
import asyncio
import sqlite3
from httpx import ASGITransport, AsyncClient
from pydantic import SecretStr
@@ -297,69 +296,6 @@ def test_finishing_onboarding_is_not_model_content_consent(tmp_path) -> None:
asyncio.run(scenario())
def test_the_way_back_to_the_first_run_deletes_nothing(tmp_path) -> None:
"""重置只动完成标记,记忆一条不少。
完成态和记忆放在同一个库里是有意的:重装一遍 App 不该把一个已经有记忆的人
当成新用户。代价是想再看一次冷启动,重装不管用。这条盯着那个出口:
状态回到首次运行,别的都不动。
"""
async def scenario() -> None:
app = _app(tmp_path)
async with app.router.lifespan_context(app), _client(app) as client:
database = tmp_path / "data" / "one-memory.sqlite3"
with sqlite3.connect(database) as connection:
connection.execute(
"INSERT INTO governed_memories "
"(memory_id,user_id,kind,status,content,scopes_json,"
"revision,created_at,updated_at) VALUES (?,?,?,?,?,?,?,?,?)",
(
"keep-me",
"local",
"chat",
"confirmed",
"记忆必须保留",
'["all-apps"]',
1,
"2026-10-17T00:00:00+00:00",
"2026-10-17T00:00:00+00:00",
),
)
finished = await client.post("/api/v1/onboarding/complete")
assert finished.json()["status"] == "completed"
reset = await client.post("/api/v1/onboarding/reset")
assert reset.status_code == 200
assert reset.json()["status"] == "not_started"
assert reset.json()["completed_at"] is None
# 落盘了,不是只在响应里改一下:下一次启动读到的也是首次运行。
assert (await client.get("/api/v1/onboarding")).json()["status"] == "not_started"
with sqlite3.connect(database) as connection:
assert connection.execute(
"SELECT content,revision FROM governed_memories "
"WHERE memory_id='keep-me'"
).fetchone() == ("记忆必须保留", 1)
asyncio.run(scenario())
def test_resetting_before_anything_was_completed_is_not_an_error(tmp_path) -> None:
"""绿对照:新库里还没有那一行,重置不能炸。"""
async def scenario() -> None:
app = _app(tmp_path)
async with app.router.lifespan_context(app), _client(app) as client:
reset = await client.post("/api/v1/onboarding/reset")
assert reset.status_code == 200
assert reset.json()["status"] == "not_started"
asyncio.run(scenario())
def test_hy_being_down_does_not_leave_the_first_install_unfinished(tmp_path) -> None:
"""守卫:这一步可跳过,归纳失败不能把引导卡住。
@@ -4,7 +4,7 @@ Dev history: when the model extracted no fact, the coding extractor stored the
raw dialogue as a working fact, and with no words of the person's it took the
first 600 characters of the rendered trace. MemHub showed that as the memory
content, which is where `[0] assistant: ... [1] tool(?): ...` came from
(D87). The SDK fix remains present in 2.0.3: it does not store that fallback by
(D87). The SDK fix is present in 2.0.2: it does not store that fallback by
default.
This pins the default here, so a future bump that turns it back on, or our own
+5 -5
View File
@@ -991,7 +991,7 @@ wheels = [
[[package]]
name = "hy-memory"
version = "2.0.3"
version = "2.0.2"
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/e0/12/10e91468d1331eff8380e6b32c1e8784413660a299005ce07ac3e60e90bd/hy_memory-2.0.3.tar.gz", hash = "sha256:cdebd46b16ebe3cb04c89475f9044d30c6cc9065796be369f5d9607410a5c96b", size = 823596, upload-time = "2026-10-17T11:06:23.093Z" }
sdist = { url = "https://files.pythonhosted.org/packages/b4/45/367b6d319f8f6c4fb99ee84d440e19616e64096fa50c3cc7672f7ef98653/hy_memory-2.0.2.tar.gz", hash = "sha256:1ba3b0d0bb1eb15ce9bf9b055190449614f93b550d02fb1646469dd01162c77e", size = 823220, upload-time = "2026-10-16T16:01:48.002Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/af/ed/bac8c15eb91baeaeb15a9061d7937ffd02ebe3384757addf0ca09660e592/hy_memory-2.0.3-py3-none-any.whl", hash = "sha256:763d6388ea83762e06c51aecd8e5d8e76211000f67355f65a760eb0a17ede4c5", size = 887157, upload-time = "2026-10-17T11:06:20.994Z" },
{ url = "https://files.pythonhosted.org/packages/1f/0e/2a95b967ec29165fe36e3f6d7da3e07baeb6d7404a023d4b816223c8af60/hy_memory-2.0.2-py3-none-any.whl", hash = "sha256:e76419c3313db4fd4850249ec833ce7f98d17049a3e346b06bb1461038021384", size = 886789, upload-time = "2026-10-16T16:01:45.899Z" },
]
[[package]]
@@ -1801,7 +1801,7 @@ wheels = [
[[package]]
name = "one-memory"
version = "1.0.3"
version = "1.0.2"
source = { editable = "." }
dependencies = [
{ name = "aiomysql" },
@@ -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.3" },
{ name = "hy-memory", specifier = "==2.0.2" },
{ name = "keyring", specifier = ">=25.7.0" },
{ name = "numpy", specifier = "==2.5.2" },
{ name = "onnxruntime", specifier = "==1.23.2" },
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "@one-memory/desktop",
"version": "1.0.3",
"version": "1.0.2",
"private": true,
"main": "dist/main.js",
"scripts": {
@@ -3,7 +3,6 @@ import {
diagnosticError,
writeDiagnosticEventSync,
} from "@one-memory/runtime-control";
import { stableRuntimeUserMessage } from "./stable-runtime";
interface EarlyStartupErrorHandlerOptions {
processEvents: Pick<EventEmitter, "on" | "removeListener">;
@@ -53,10 +52,7 @@ export function installEarlyStartupErrorHandler(
);
options.afterDiagnosticWrite?.();
} finally {
// The dialog is the only part of this a person ever reads, so it gets the
// explanation, not the code. The code stays in the diagnostic event,
// where support can still match it against a known failure.
options.showError("MemHub 无法启动", stableRuntimeUserMessage(error));
options.showError("MemHub 无法启动", errorCode);
options.exit(1);
}
};
+1 -8
View File
@@ -1004,9 +1004,6 @@ async function initialize(isQuitting: () => boolean): Promise<void> {
...(packagedCommit === undefined
? {}
: { gitCommit: packagedCommit }),
// Only the packaged Desktop path opts into a valid same-build,
// different-commit takeover. CLI installs remain strict.
replaceSameBuild: true,
serviceEnableMarker: runtimeServiceEnableMarkerPath(dataDir),
});
runtimeChanged = !installed.reused_current;
@@ -1168,9 +1165,6 @@ async function initialize(isQuitting: () => boolean): Promise<void> {
if (process.platform !== "darwin" && process.platform !== "linux") {
throw new Error("packaged_runtime_platform_unsupported");
}
// Captured here, not at the call site: the install callback below is
// a nested function, and TypeScript drops property narrowing there.
const hostPlatform = process.platform;
const installRoot = defaultManagedInstallRoot(
process.platform,
app.getPath("home"),
@@ -1180,7 +1174,7 @@ async function initialize(isQuitting: () => boolean): Promise<void> {
resourcesPath: process.resourcesPath,
executablePath: process.execPath,
installRoot,
platform: hostPlatform,
platform: process.platform,
arch: process.arch,
...(packagedBuildNumber === undefined
? {}
@@ -1188,7 +1182,6 @@ async function initialize(isQuitting: () => boolean): Promise<void> {
...(packagedCommit === undefined
? {}
: { gitCommit: packagedCommit }),
replaceSameBuild: true,
serviceEnableMarker: runtimeServiceEnableMarkerPath(dataDir),
});
await installBundledCliIfNeeded({
@@ -16,7 +16,6 @@ import {
isDisabledServiceInstallError,
isInstalledApplicationsApp,
stableRuntimeErrorCode,
stableRuntimeUserMessage,
startStableRuntimeRepair,
} from "./stable-runtime";
@@ -255,23 +254,3 @@ describe("installServiceWithDisabledFallback", () => {
expect(startTransient).not.toHaveBeenCalled();
});
});
describe("startup install errors", () => {
it("explains the failure to the user instead of showing an error code", () => {
const conflict = stableRuntimeUserMessage(
new Error("installed_version_conflict"),
);
expect(conflict).toContain("记忆数据");
expect(conflict).not.toContain("installed_version_conflict");
expect(
stableRuntimeUserMessage(new Error("distribution_downgrade_refused")),
).toContain("更新");
// An unrecognised failure still gets a sentence, never a raw code.
expect(
stableRuntimeUserMessage(new Error("something_unexpected")),
).toContain("MemHub 启动失败");
expect(stableRuntimeUserMessage("not an error")).toContain(
"MemHub 启动失败",
);
});
});
-25
View File
@@ -178,28 +178,3 @@ export async function installServiceWithDisabledFallback(options: {
return "transient";
}
}
/** What the user is told, instead of an internal error code.
*
* The codes are contractual and stay in the logs; this is the layer that turns
* them into something a person can act on. Every string says what happens to
* their memories, because that is the first thing anyone asks. */
export const STABLE_RUNTIME_USER_MESSAGES: Record<string, string> = {
installed_version_conflict:
"运行环境与这个安装包不一致,无法安全接管。请安装更新版本的 MemHub;你的记忆数据不会受到影响。",
distribution_downgrade_refused:
"已安装的运行环境比这个安装包更新,无法用旧安装包启动。请安装最新版本的 MemHub;你的记忆数据不会受到影响。",
install_current_unmanaged:
"运行环境的安装目录被改动过,无法安全接管。请重新安装 MemHub,你的记忆数据不会受到影响。",
desktop_start_failed:
"MemHub 启动失败。请重新启动;如果仍然打不开,请重新安装,你的记忆数据不会受到影响。",
};
export function stableRuntimeUserMessage(error: unknown): string {
const code = stableRuntimeErrorCode(error);
return (
STABLE_RUNTIME_USER_MESSAGES[code] ??
STABLE_RUNTIME_USER_MESSAGES.desktop_start_failed ??
""
);
}
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "@one-memory/site",
"version": "1.0.3",
"version": "1.0.2",
"private": true,
"type": "module",
"scripts": {
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "@one-memory/web",
"version": "1.0.3",
"version": "1.0.2",
"private": true,
"type": "module",
"scripts": {
+2 -2
View File
@@ -155,7 +155,7 @@ write. Retrieval results are checked against current Governance and access
scope before being returned to the UI or an authorized host.
The currently pinned SDK is the public PyPI distribution
`hy-memory==2.0.3`. Runtime configuration and permission boundaries are
`hy-memory==2.0.2`. Runtime configuration and permission boundaries are
documented in [`../MEMORY.md`](../MEMORY.md).
## Permission Boundaries
@@ -355,7 +355,7 @@ Support is declared per immutable GitHub Release manifest:
- macOS arm64 and x64 App/Runtime artifacts must carry Developer ID signatures,
notarization tickets, and native-platform acceptance receipts.
- Windows is not supported.
- `hy-memory==2.0.3` is the public Python SDK dependency.
- `hy-memory==2.0.2` is the public Python SDK dependency.
See [`INSTALLATION.md`](INSTALLATION.md) for the release matrix. A source test,
fake hosting test, or cross-build does not substitute for a native-platform
+1 -1
View File
@@ -1,6 +1,6 @@
# MemHub 安装与发行
MemHub `v1.0.3`(build 7)的发行目标如下:
MemHub `v1.0.2`(build 6)的发行目标如下:
| 平台 | 发行物 | 构建与验收 |
| --- | --- | --- |
+2 -2
View File
@@ -1,8 +1,8 @@
{
"name": "one-memory",
"version": "1.0.3",
"version": "1.0.2",
"release": {
"build_number": 7,
"build_number": 6,
"macos_min_version": "13.0",
"app_update": "manual",
"runtime_manifest_schema": 1,
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "@one-memory/contracts",
"version": "1.0.3",
"version": "1.0.2",
"private": true,
"type": "module",
"types": "./src/index.ts",
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "@one-memory/host-bridge",
"version": "1.0.3",
"version": "1.0.2",
"private": true,
"type": "module",
"exports": {
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "@one-memory/operations",
"version": "1.0.3",
"version": "1.0.2",
"private": true,
"type": "module",
"types": "./src/index.ts",
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "@one-memory/runtime-control",
"version": "1.0.3",
"version": "1.0.2",
"private": true,
"type": "module",
"bin": {
@@ -61,12 +61,7 @@ function macho(cpuArch: string): Buffer {
async function distribution(
version = "1.0.0",
options: {
buildNumber?: number;
arch?: string;
nativeArch?: string;
gitCommit?: string;
} = {},
options: { buildNumber?: number; arch?: string; nativeArch?: string } = {},
) {
const root = await mkdtemp(path.join(tmpdir(), "one-memory-local-install-"));
roots.push(root);
@@ -111,7 +106,6 @@ async function distribution(
// Derive a monotonic default from the fixture version so an "upgrade"
// to 2.0.0 outranks 1.0.0 unless a test overrides it explicitly.
(Number.parseInt(version.split(".")[0] ?? "1", 10) || 1),
git_commit: options.gitCommit ?? null,
platform,
arch,
files: digests,
@@ -624,187 +618,6 @@ describe("local Runtime installation", () => {
).rejects.toThrow("installed_version_conflict");
});
it("replaces an equal build_number whose bytes differ only when the caller opts in", async () => {
if (process.platform !== "darwin") return;
const first = await distribution("1.0.0", {
buildNumber: 5,
gitCommit: "a".repeat(40),
});
const firstPlan = await planLocalInstall({
source: first.source,
installRoot: first.installRoot,
platform: process.platform,
arch: process.arch,
});
await applyLocalInstall(firstPlan);
// The same release rebuilt from a new commit: identical build number,
// different bytes. This is what a re-cut MemHub package looks like.
const rebuilt = await distribution("1.0.0", {
buildNumber: 5,
gitCommit: "b".repeat(40),
});
await writeFile(path.join(rebuilt.source, "web/index.html"), "rebuilt web");
const manifestPath = path.join(rebuilt.source, "manifest.json");
const manifest = JSON.parse(await readFile(manifestPath, "utf8"));
manifest.files["web/index.html"] = createHash("sha256")
.update("rebuilt web")
.digest("hex");
await writeFile(manifestPath, JSON.stringify(manifest));
const plan = await planLocalInstall({
source: rebuilt.source,
installRoot: first.installRoot,
platform: process.platform,
arch: process.arch,
allowSameBuildReplace: true,
});
// The replacement lands in its own directory, so the superseded tree is
// left intact as a rollback instead of being edited under a running host.
expect(plan.same_build_replace).toBe(true);
expect(plan.version_directory).not.toBe(firstPlan.version_directory);
await applyLocalInstall(plan);
expect(await readlink(path.join(first.installRoot, "current"))).toBe(
path.relative(first.installRoot, plan.version_directory),
);
expect(
await readFile(
path.join(plan.version_directory, "web/index.html"),
"utf8",
),
).toBe("rebuilt web");
expect(
await readFile(
path.join(firstPlan.version_directory, "web/index.html"),
"utf8",
),
).toBe("web");
});
it("uses different directories for different commits with identical payload files", async () => {
if (process.platform !== "darwin") return;
const first = await distribution("1.0.0", {
buildNumber: 5,
gitCommit: "a".repeat(40),
});
const firstPlan = await planLocalInstall({
source: first.source,
installRoot: first.installRoot,
platform: process.platform,
arch: process.arch,
});
await applyLocalInstall(firstPlan);
const rebuilt = await distribution("1.0.0", {
buildNumber: 5,
gitCommit: "b".repeat(40),
});
const replacement = await planLocalInstall({
source: rebuilt.source,
installRoot: first.installRoot,
platform: process.platform,
arch: process.arch,
allowSameBuildReplace: true,
});
expect(replacement.same_build_replace).toBe(true);
expect(replacement.version_directory).not.toBe(firstPlan.version_directory);
});
it("refuses different bytes from the same commit even with the opt-in", async () => {
if (process.platform !== "darwin") return;
const commit = "a".repeat(40);
const first = await distribution("1.0.0", {
buildNumber: 5,
gitCommit: commit,
});
await applyLocalInstall(
await planLocalInstall({
source: first.source,
installRoot: first.installRoot,
platform: process.platform,
arch: process.arch,
}),
);
const rebuilt = await distribution("1.0.0", {
buildNumber: 5,
gitCommit: commit,
});
await writeFile(path.join(rebuilt.source, "web/index.html"), "different");
const manifestPath = path.join(rebuilt.source, "manifest.json");
const manifest = JSON.parse(await readFile(manifestPath, "utf8"));
manifest.files["web/index.html"] = createHash("sha256")
.update("different")
.digest("hex");
await writeFile(manifestPath, JSON.stringify(manifest));
await expect(
planLocalInstall({
source: rebuilt.source,
installRoot: first.installRoot,
platform: process.platform,
arch: process.arch,
allowSameBuildReplace: true,
}),
).rejects.toThrow("installed_version_conflict");
});
it("refuses a tampered selected runtime instead of taking it over", async () => {
if (process.platform !== "darwin") return;
const first = await distribution("1.0.0", {
buildNumber: 5,
gitCommit: "a".repeat(40),
});
const firstPlan = await planLocalInstall({
source: first.source,
installRoot: first.installRoot,
platform: process.platform,
arch: process.arch,
});
await applyLocalInstall(firstPlan);
await writeFile(
path.join(firstPlan.version_directory, "web/index.html"),
"tampered",
);
const rebuilt = await distribution("1.0.0", {
buildNumber: 5,
gitCommit: "b".repeat(40),
});
await expect(
planLocalInstall({
source: rebuilt.source,
installRoot: first.installRoot,
platform: process.platform,
arch: process.arch,
allowSameBuildReplace: true,
}),
).rejects.toThrow("distribution_hash_mismatch");
});
it("does not let the same-build opt-in weaken downgrade refusal", async () => {
if (process.platform !== "darwin") return;
const first = await distribution("1.0.0", { buildNumber: 5 });
await applyLocalInstall(
await planLocalInstall({
source: first.source,
installRoot: first.installRoot,
platform: process.platform,
arch: process.arch,
}),
);
const older = await distribution("0.9.0", { buildNumber: 4 });
await expect(
planLocalInstall({
source: older.source,
installRoot: first.installRoot,
platform: process.platform,
arch: process.arch,
allowSameBuildReplace: true,
}),
).rejects.toThrow("distribution_downgrade_refused");
});
it("accepts an equal build_number when the bytes are identical (idempotent reinstall)", async () => {
if (process.platform !== "darwin") return;
const first = await distribution("1.0.0", { buildNumber: 5 });
@@ -50,10 +50,6 @@ export interface LocalInstallPlan {
/** Recorded so `applyLocalInstall` re-verifies with the same opt-in the plan
* was created under; it does not itself authorize anything. */
allow_downgrade: boolean;
/** True when this plan replaces an equal `build_number` whose bytes differ.
* The incoming build gets its own version directory and the superseded tree
* is left in place, so the transition is still reversible. */
same_build_replace: boolean;
require_protocol_compatibility: boolean;
platform: DistributionManifest["platform"];
arch: string;
@@ -73,10 +69,6 @@ export async function planLocalInstall(options: {
/** Explicit, per-invocation opt-in required to install a distribution whose
* `build_number` is lower than the selected one. Default is refusal. */
allowDowngrade?: boolean;
/** Explicit, per-invocation opt-in required to replace an equal
* `build_number` whose bytes differ. Default is refusal
* (`installed_version_conflict`); see `inspectBuildNumberTransition`. */
allowSameBuildReplace?: boolean;
requireProtocolCompatibility?: boolean;
}): Promise<LocalInstallPlan> {
if (!isPlatform(options.platform))
@@ -94,12 +86,11 @@ export async function planLocalInstall(options: {
if (options.requireProtocolCompatibility === true) {
assertDistributionProtocolCompatibility(manifest);
}
const transition = await inspectBuildNumberTransition({
await assertBuildNumberTransition({
installRoot: options.installRoot,
incoming: manifest,
incomingSource: source,
allowDowngrade: options.allowDowngrade === true,
allowSameBuildReplace: options.allowSameBuildReplace === true,
});
const files = Object.keys(manifest.files).sort();
const installRoot = path.resolve(options.installRoot);
@@ -118,18 +109,11 @@ export async function planLocalInstall(options: {
schema_version: 1,
source,
install_root: installRoot,
version_directory: path.join(
installRoot,
"versions",
transition.sameBuildReplace
? `${id}-r${manifestFingerprint(manifest)}`
: id,
),
version_directory: path.join(installRoot, "versions", id),
current_link: path.join(installRoot, "current"),
version: manifest.version,
build_number: manifest.build_number ?? null,
allow_downgrade: options.allowDowngrade === true,
same_build_replace: transition.sameBuildReplace,
require_protocol_compatibility:
options.requireProtocolCompatibility === true,
platform: manifest.platform,
@@ -153,7 +137,6 @@ export async function applyLocalInstall(
platform: plan.platform,
arch: plan.arch,
allowDowngrade: plan.allow_downgrade,
allowSameBuildReplace: plan.same_build_replace,
requireProtocolCompatibility: plan.require_protocol_compatibility,
});
if (
@@ -162,7 +145,6 @@ export async function applyLocalInstall(
path.resolve(plan.current_link) !== verified.current_link ||
plan.version !== verified.version ||
plan.build_number !== verified.build_number ||
plan.same_build_replace !== verified.same_build_replace ||
plan.require_protocol_compatibility !==
verified.require_protocol_compatibility ||
plan.files !== verified.files
@@ -334,29 +316,18 @@ export async function rollbackLocalInstall(
* selected version directory already contains byte-identical content —
* otherwise the install would silently swap an equal-build package, which the
* contract classifies as `installed_version_conflict`.
*
* `allowSameBuildReplace` is an explicit, per-invocation escape from that last
* rule, and it is off by default: a silent equal-build swap is precisely what
* the refusal protects against. It exists for one caller shape — a runtime
* shipped inside an app bundle that the OS has already signed and notarized.
* Such a payload is trusted by construction, and refusing it leaves an affected
* machine with an app that cannot start at all after a same-build re-release.
* The replacement never edits the selected tree in place: the incoming build
* lands in its own version directory and `current` moves atomically, so the
* superseded tree stays available as a rollback.
*/
async function inspectBuildNumberTransition(options: {
async function assertBuildNumberTransition(options: {
installRoot: string;
incoming: DistributionManifest;
incomingSource: string;
allowDowngrade: boolean;
allowSameBuildReplace: boolean;
}): Promise<{ sameBuildReplace: boolean }> {
}): Promise<void> {
const installRoot = path.resolve(options.installRoot);
const current = await lstat(path.join(installRoot, "current")).catch(
() => null,
);
if (!current) return { sameBuildReplace: false };
if (!current) return;
if (!current.isSymbolicLink()) throw new Error("install_current_unmanaged");
const target = path.resolve(
installRoot,
@@ -367,89 +338,28 @@ async function inspectBuildNumberTransition(options: {
if (relative.startsWith("..") || path.isAbsolute(relative)) {
throw new Error("install_current_unmanaged");
}
const selected = options.allowSameBuildReplace
? await verifyDistribution(
target,
options.incoming.platform,
options.incoming.arch,
)
: await readManifest(target).catch(() => null);
if (selected === null) return { sameBuildReplace: false };
const selected = await readManifest(target).catch(() => null);
if (selected === null) return;
// Pre-contract packages have no `build_number`. Comparing them against an
// invented ordinal would either break the hourly chain or manufacture a
// downgrade; fall back to the prior exact-version behavior until both sides
// are numbered.
if (options.incoming.build_number === undefined)
return { sameBuildReplace: false };
if (selected.build_number === undefined) return { sameBuildReplace: false };
if (options.incoming.build_number > selected.build_number)
return { sameBuildReplace: false };
if (options.incoming.build_number === undefined) return;
if (selected.build_number === undefined) return;
if (options.incoming.build_number > selected.build_number) return;
if (options.incoming.build_number === selected.build_number) {
// Same build: an idempotent reinstall is fine only if the bytes match. The
// incoming source is verified, so compare it against the selected tree.
try {
await verifySameTree(options.incomingSource, target);
} catch {
if (!options.allowSameBuildReplace) {
throw new Error("installed_version_conflict");
}
if (
!isReleaseCommit(selected.git_commit) ||
!isReleaseCommit(options.incoming.git_commit) ||
selected.git_commit === options.incoming.git_commit
) {
// Same commit with different bytes is not a new build: it is a
// non-reproducible or corrupted artifact and must stay rejected.
throw new Error("installed_version_conflict");
}
return { sameBuildReplace: true };
throw new Error("installed_version_conflict");
}
return { sameBuildReplace: false };
return;
}
if (!options.allowDowngrade) {
throw new Error("distribution_downgrade_refused");
}
return { sameBuildReplace: false };
}
/** Short, stable digest of the complete normalized manifest identity.
*
* Two packages of the same `build_number` whose bytes differ must not share a
* version directory: `applyLocalInstall` compares the staged tree against the
* directory it is about to occupy and would refuse the replacement before
* `current` could move. Suffixing the directory with a payload fingerprint
* keeps the equal-build replacement a plain "install then switch" operation. */
function manifestFingerprint(manifest: DistributionManifest): string {
const packageId = (
manifest as DistributionManifest & {
package_id?: unknown;
}
).package_id;
const identity = {
schema_version: manifest.schema_version,
product: manifest.product,
version: manifest.version,
build_number: manifest.build_number ?? null,
git_commit: manifest.git_commit ?? null,
runtime_protocol: manifest.runtime_protocol ?? null,
supported_cli_protocol: manifest.supported_cli_protocol ?? null,
platform: manifest.platform,
arch: manifest.arch,
package_id: typeof packageId === "string" ? packageId : null,
files: Object.fromEntries(
Object.entries(manifest.files).sort(([left], [right]) =>
left.localeCompare(right),
),
),
};
return createHash("sha256")
.update(JSON.stringify(identity))
.digest("hex")
.slice(0, 16);
}
function isReleaseCommit(value: string | null | undefined): value is string {
return typeof value === "string" && /^[0-9a-f]{40}$/.test(value);
}
async function assertPrivateDirectory(target: string): Promise<void> {
@@ -100,7 +100,6 @@ async function installFormalCurrent(options: {
executable: string;
installRoot: string;
buildNumber?: number;
gitCommit?: string;
}): Promise<{ current: string; selected: string }> {
const buildNumber = options.buildNumber ?? 3757;
const appLayout = await installPackagedRuntime({
@@ -110,7 +109,6 @@ async function installFormalCurrent(options: {
platform: "darwin",
arch: "arm64",
buildNumber,
gitCommit: options.gitCommit ?? "a".repeat(40),
});
const formalSource = path.join(options.root, `formal-dist-${buildNumber}`);
await cp(appLayout.version_directory, formalSource, { recursive: true });
@@ -378,72 +376,6 @@ describe("installPackagedRuntime", () => {
expect((await lstat(current)).ino).toBe(inodeBefore);
});
it("takes over from a same-build runtime whose bytes differ when the bundle asks it to", async () => {
const { root, resources, executable, installRoot } = await fixture();
const { current, selected } = await installFormalCurrent({
root,
resources,
executable,
installRoot,
});
// A re-cut package: same build number, manifest still self-consistent,
// but not the bytes this bundle carries.
await writeFile(path.join(selected, "web", "index.html"), "rebuilt-body");
await rewriteManifestDigest(selected, "web/index.html");
const receipt = await installPackagedRuntime({
resourcesPath: resources,
executablePath: executable,
installRoot,
platform: "darwin",
arch: "arm64",
buildNumber: 3757,
gitCommit: "b".repeat(40),
replaceSameBuild: true,
});
expect(receipt.reused_current).toBe(false);
expect(receipt.version_directory).not.toBe(selected);
// `current` serves the payload this bundle brought.
expect(await readlink(current)).toBe(
path.relative(installRoot, receipt.version_directory),
);
expect(
await readFile(
path.join(receipt.version_directory, "web", "index.html"),
"utf8",
),
).toBe("web-body");
// The superseded tree is left on disk, not edited: it is the rollback.
expect(
await readFile(path.join(selected, "web", "index.html"), "utf8"),
).toBe("rebuilt-body");
});
it("refuses same-commit different bytes even when the bundle opts in", async () => {
const { root, resources, executable, installRoot } = await fixture();
const { selected } = await installFormalCurrent({
root,
resources,
executable,
installRoot,
gitCommit: "a".repeat(40),
});
await writeFile(path.join(selected, "web", "index.html"), "unexpected");
await rewriteManifestDigest(selected, "web/index.html");
await expect(
installPackagedRuntime({
resourcesPath: resources,
executablePath: executable,
installRoot,
platform: "darwin",
arch: "arm64",
buildNumber: 3757,
gitCommit: "a".repeat(40),
replaceSameBuild: true,
}),
).rejects.toThrow("installed_version_conflict");
});
it.each(["missing"])(
"refuses a same-build current whose bin/node is %s",
async (state) => {
@@ -51,16 +51,6 @@ export async function installPackagedRuntime(options: {
* CLI-installed distribution. Callers that do know the bundle's build number
* must pass it (see `docs/contracts/c0-anti-downgrade-versioning.md`). */
buildNumber?: number;
/** Allow the bundle to take over from an already-selected runtime that
* reports this same `build_number` but different bytes.
*
* Off by default, which preserves `installed_version_conflict`. Desktop turns
* it on: the bundle it carries has passed notarization, so its payload is
* already trusted, and the same build number is re-released whenever a
* release is rebuilt from a new commit. Refusing that case leaves an affected
* machine with an app that exits on every launch, which is a worse outcome
* than replacing the runtime. The superseded tree is kept, not edited. */
replaceSameBuild?: boolean;
gitCommit?: string;
/** App-owned services use this marker to stop launchd before the App binary
* is invoked after the bundle has been removed. */
@@ -160,7 +150,6 @@ export async function installPackagedRuntime(options: {
platform: options.platform,
arch: options.arch,
buildNumber: options.buildNumber,
replaceSameBuild: options.replaceSameBuild === true,
executablePath: executable,
serviceEnableMarker: options.serviceEnableMarker,
});
@@ -170,7 +159,6 @@ export async function installPackagedRuntime(options: {
installRoot: options.installRoot,
platform: options.platform,
arch: options.arch,
allowSameBuildReplace: options.replaceSameBuild === true,
requireProtocolCompatibility: true,
});
return Object.freeze({
@@ -188,7 +176,6 @@ async function reuseSelectedSameBuild(options: {
platform: "darwin" | "linux";
arch: string;
buildNumber: number | undefined;
replaceSameBuild: boolean;
executablePath: string;
serviceEnableMarker: string | undefined;
}): Promise<PackagedRuntimeInstallReceipt | null> {
@@ -252,15 +239,11 @@ async function reuseSelectedSameBuild(options: {
)
: null;
if (selectedDigest !== digest && selectedDigest !== formalDigest) {
// Same build number, different bytes: not reusable. Hand the install
// back to the normal path so the bundle lands in its own directory.
if (options.replaceSameBuild) return null;
throw new Error("installed_version_conflict");
}
continue;
}
if (selectedManifest.files[relative] !== digest) {
if (options.replaceSameBuild) return null;
throw new Error("installed_version_conflict");
}
}
@@ -293,8 +276,6 @@ async function reuseSelectedSameBuild(options: {
version: selectedManifest.version,
build_number: selectedManifest.build_number ?? null,
allow_downgrade: false,
// Nothing was replaced: the selected tree already matched this bundle.
same_build_replace: false,
require_protocol_compatibility: true,
platform: selectedManifest.platform,
arch: selectedManifest.arch,
+1 -1
View File
@@ -36,7 +36,7 @@ MEMMY_LICENSE = (
MEMMY_PROVENANCE = MEMMY_LICENSE.with_name("PROVENANCE.md")
MEMMY_PIN = "25acd5f29fc7df73b943713abe61f1b2f9839887"
HY_MEMORY_NAME = "hy-memory"
HY_MEMORY_VERSION = "2.0.3"
HY_MEMORY_VERSION = "2.0.2"
class SbomError(RuntimeError):
+2 -2
View File
@@ -12,8 +12,8 @@ test("installation docs disclose publication and lifecycle boundaries", async ()
"utf8",
);
assert.match(readme, /公共 Python SDK 为 `hy-memory==2\.0\.3`/);
assert.match(readme, /`v1\.0\.3` 的发行目标是 Linux x64\/arm64 Runtime/);
assert.match(readme, /公共 Python SDK 为 `hy-memory==2\.0\.2`/);
assert.match(readme, /`v1\.0\.2` 的发行目标是 Linux x64\/arm64 Runtime/);
assert.match(readme, /Linux 制品由 GitHub Actions/);
assert.match(readme, /macOS.*Developer ID 签名、公证/s);
assert.match(readme, /release-manifest\.json/);
+3 -3
View File
@@ -13,7 +13,7 @@ spec.loader.exec_module(module)
def test_hy_memory_notice_comes_from_the_installed_dist_info(tmp_path: Path) -> None:
dist_info = tmp_path / "hy_memory-2.0.3.dist-info"
dist_info = tmp_path / "hy_memory-2.0.2.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.3",
"Version: 2.0.2",
"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.3"
assert notice["version"] == "2.0.2"
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"]
+1 -1
View File
@@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
[project]
name = "hy-memory"
version = "2.0.3"
version = "2.0.2"
description = "HY Memory - Industrial-grade dual-system cognitive memory framework"
readme = "README.md"
requires-python = ">=3.9"