Compare commits

..
Author SHA1 Message Date
c8c06a13d1 feat(memhub): add product memory pipeline diagnostics (#22)
* feat(memhub): add product memory pipeline diagnostics

Co-authored-by: Cursor <cursoragent@cursor.com>

* docs(memhub): clarify debug log privacy

Co-authored-by: Cursor <cursoragent@cursor.com>

---------

Co-authored-by: alvinfei <alvinfei@tencent.com>
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-10-17 17:13:20 +08:00
alvinfeiandCursor 55a89514da refactor(memhub): remove model content permission UX
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-10-17 13:27:19 +08:00
alvinfeiandCursor 6de3c11bbf refactor(memhub): trust embedded SDK native stores
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-10-17 13:27:11 +08:00
52 changed files with 89 additions and 956 deletions

No files matched your search

+7 -13
View File
@@ -122,25 +122,19 @@ jobs:
shell: bash
run: |
set -euo pipefail
shopt -s globstar nullglob
version=$(node -p 'require("./package.json").version')
node scripts/verify-release-version.mjs \
--channel stable \
--version "$version" \
--build-number "$(node -p 'require("./package.json").release.build_number')" \
--tag "$GITHUB_REF_NAME"
mkdir -p publish-assets
for arch in x64 arm64; do
name="memhub-runtime-v${version}-linux-${arch}.tar.gz"
archives=(release-assets/**/"$name")
checksums=(release-assets/**/"$name.sha256")
test "${#archives[@]}" -eq 1
test "${#checksums[@]}" -eq 1
cp -- "${archives[0]}" "publish-assets/$name"
cp -- "${checksums[0]}" "publish-assets/$name.sha256"
archive="release-assets/memhub-runtime-v${version}-linux-${arch}.tar.gz"
test -f "$archive"
test -f "$archive.sha256"
(
cd publish-assets
sha256sum -c "$name.sha256"
cd release-assets
sha256sum -c "$(basename "$archive.sha256")"
)
done
- name: Upload Linux assets to a draft GitHub Release
@@ -166,7 +160,7 @@ jobs:
--title "$GITHUB_REF_NAME"
fi
gh release upload "$GITHUB_REF_NAME" \
publish-assets/memhub-runtime-v*-linux-*.tar.gz \
publish-assets/memhub-runtime-v*-linux-*.tar.gz.sha256 \
release-assets/memhub-runtime-v*-linux-*.tar.gz \
release-assets/memhub-runtime-v*-linux-*.tar.gz.sha256 \
--repo "$GITHUB_REPOSITORY" \
--clobber
+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",
+2 -21
View File
@@ -1049,15 +1049,10 @@ def create_app(
job.job_id,
)
await asyncio.to_thread(
memory_write_queue.retry_first_unknown_after_reconcile,
older_than=datetime.now(UTC) - timedelta(seconds=10),
memory_write_queue.retry_timed_out_unknown_after_reconcile,
older_than=datetime.now(UTC) - timedelta(minutes=5),
limit=32,
)
await asyncio.to_thread(
app.state.memory_task_store.retry_first_unknown_after_reconcile,
older_than=datetime.now(UTC) - timedelta(seconds=10),
limit=128,
)
async def admit_hy_invocation(
source_id: str,
@@ -1886,20 +1881,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,
@@ -306,7 +306,7 @@ LEGACY_MEMORY_WRITE_JOB_COLUMNS = (
("last_error", "TEXT"),
)
_FIFO_BLOCKING_STATES = (PENDING, RUNNING, WAITING_FOR_PERMISSION, WAITING_FOR_MODEL)
_FIFO_BLOCKING_STATES = (PENDING, RUNNING, WAITING_FOR_PERMISSION, WAITING_FOR_MODEL, UNKNOWN)
@dataclass(frozen=True)
@@ -1492,59 +1492,6 @@ class DurableWriteQueue:
)
return len(job_ids)
def retry_first_unknown_after_reconcile(
self,
*,
older_than: datetime,
limit: int = 32,
) -> int:
"""Retry one unresolved unknown outcome once, after reconciliation.
The caller first asks HY and checks linked governed output. Rows still
unknown may have lost the response after the SDK call, so one bounded
retry is useful. `attempts=1` is the guard: the retry claim increments
it to two, and a second unknown is left for explicit user recovery.
"""
if type(limit) is not int or limit < 1:
raise ValueError("limit must be positive")
now = datetime.now(UTC).isoformat()
with (
main_database(self.database_path).write(row_factory=sqlite3.Row) as connection,
connection,
):
rows = connection.execute(
"SELECT job_id FROM memory_write_jobs "
"WHERE state=? AND attempts=1 AND attempts < max_attempts "
"AND updated_at <= ? ORDER BY updated_at, job_id LIMIT ?",
(UNKNOWN, older_than.isoformat(), limit),
).fetchall()
job_ids = tuple(str(row["job_id"]) for row in rows)
if not job_ids:
return 0
placeholders = ",".join("?" for _ in job_ids)
connection.execute(
f"UPDATE memory_write_jobs SET state=?, lease_owner=NULL, "
f"lease_expires_at=NULL, hy_invoked=0, "
f"last_error='model_content_unknown_auto_retry', updated_at=? "
f"WHERE state=? AND attempts=1 AND job_id IN ({placeholders})",
(PENDING, now, UNKNOWN, *job_ids),
)
if connection.execute(
"SELECT 1 FROM sqlite_master WHERE type='table' AND name='memory_tasks'"
).fetchone():
connection.execute(
f"UPDATE memory_tasks SET status=?, runner_token=NULL, "
f"last_error_code='model_content_unknown_auto_retry', updated_at=? "
f"WHERE status=? AND task_id IN ("
f"SELECT inputs.task_id FROM memory_task_inputs AS inputs "
f"JOIN memory_write_job_evidence AS linked ON EXISTS ("
f"SELECT 1 FROM json_each(inputs.refs_json) AS ref "
f"WHERE json_extract(ref.value, '$.evidence_id')=linked.evidence_id) "
f"WHERE linked.job_id IN ({placeholders}))",
(MemoryTaskStatus.PENDING, now, MemoryTaskStatus.UNKNOWN, *job_ids),
)
return len(job_ids)
def resolve_unknown_from_linked_output(self, job_id: str) -> bool:
"""Close an unknown durable row when local governance output proves success."""
with main_database(self.database_path).read(row_factory=sqlite3.Row) as connection:
@@ -1949,13 +1896,6 @@ class DurableWriteDrain:
#: Reaping is a write; it runs at most once per this interval on the
#: idle path so an empty queue does not issue one write per poll.
self._reap_interval = reap_interval
# Unknown recovery is user-visible and lightweight enough to check
# sooner than general database housekeeping. Tests that set the common
# interval to zero keep immediate maintenance.
self._unknown_reconcile_interval = min(
reap_interval,
timedelta(seconds=10),
)
#: The maintenance writes below are recoverability/retention work, not
#: idle housekeeping, so each has its own interval and runs at the start
#: of every drain tick. Sharing one timestamp would let a busy tick
@@ -2368,8 +2308,7 @@ class DurableWriteDrain:
)
if self.reconcile_unknown is not None and (
self._last_unknown_reconcile is None
or tick_started - self._last_unknown_reconcile
>= self._unknown_reconcile_interval
or tick_started - self._last_unknown_reconcile >= self._reap_interval
):
self._last_unknown_reconcile = tick_started
try:
@@ -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
@@ -2161,43 +2161,6 @@ class MemoryTaskStore:
)
return changed.rowcount
def retry_first_unknown_after_reconcile(
self,
*,
older_than: datetime,
limit: int = 128,
) -> int:
"""Automatically retry a task-only unknown exactly once.
Reconciliation runs before this transition. Attempts are preserved, so
the retry claim moves `attempts=1` to two and another unknown is left
for explicit user recovery.
"""
if type(limit) is not int or limit < 1:
raise ValueError("limit must be positive")
now = datetime.now(UTC).isoformat()
with self._connect(writing=True) as connection:
changed = connection.execute(
"""
UPDATE memory_tasks
SET status=?, runner_token=NULL,
last_error_code='model_content_unknown_auto_retry', updated_at=?
WHERE task_id IN (
SELECT task_id FROM memory_tasks
WHERE status=? AND attempts=1 AND updated_at <= ?
ORDER BY updated_at, task_id LIMIT ?
)
""",
(
MemoryTaskStatus.PENDING,
now,
MemoryTaskStatus.UNKNOWN,
older_than.isoformat(),
limit,
),
)
return changed.rowcount
def recover_running(self) -> int:
"""Return tasks orphaned by a previous process to the pending queue."""
return self.recover_orphan_running()
@@ -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
@@ -1097,52 +1097,6 @@ def test_reconciled_absent_timeout_is_requeued_once_without_resetting_attempts(
)
def test_unresolved_unknown_is_automatically_retried_only_once(tmp_path) -> None:
path = tmp_path / "one-memory.sqlite3"
queue = DurableWriteQueue(path)
record = queue.submit(_request())
with sqlite3.connect(path) as connection, connection:
connection.execute(
"UPDATE memory_write_jobs SET state='unknown',attempts=1,hy_invoked=1,"
"last_error='model_content_outcome_unknown',updated_at=? WHERE job_id=?",
("2026-01-01T00:00:00+00:00", record.job_id),
)
cutoff = datetime(2026, 1, 1, 0, 5, tzinfo=UTC)
assert queue.retry_first_unknown_after_reconcile(older_than=cutoff) == 1
first_retry = queue.get(record.request_id)
assert first_retry.state == PENDING
with sqlite3.connect(path) as connection:
assert connection.execute(
"SELECT attempts FROM memory_write_jobs WHERE job_id=?",
(record.job_id,),
).fetchone()[0] == 1
drain = DurableWriteDrain(queue, execute=_never_execute, owner="retry-owner")
assert drain.claim(limit=1) == (record.job_id,)
assert queue.mark_invoked(record.job_id, owner="retry-owner")
queue.settle(
record.job_id,
state=UNKNOWN,
owner="retry-owner",
error_code="model_content_outcome_unknown",
hy_invoked=True,
)
with sqlite3.connect(path) as connection:
assert connection.execute(
"SELECT attempts FROM memory_write_jobs WHERE job_id=?",
(record.job_id,),
).fetchone()[0] == 2
assert (
queue.retry_first_unknown_after_reconcile(
older_than=datetime.now(UTC) + timedelta(minutes=1)
)
== 0
)
assert queue.get(record.request_id).state == UNKNOWN
def test_drain_runs_unknown_reconciliation_during_busy_safe_maintenance(tmp_path) -> None:
queue = DurableWriteQueue(tmp_path / "one-memory.sqlite3")
calls = 0
@@ -1162,20 +1116,6 @@ def test_drain_runs_unknown_reconciliation_during_busy_safe_maintenance(tmp_path
assert calls == 1
def test_unknown_reconciliation_runs_on_a_ten_second_ceiling(tmp_path) -> None:
queue = DurableWriteQueue(tmp_path / "one-memory.sqlite3")
production = DurableWriteDrain(queue, execute=_never_execute)
immediate = DurableWriteDrain(
queue,
execute=_never_execute,
reap_interval=timedelta(0),
)
assert production._unknown_reconcile_interval == timedelta(seconds=10)
assert immediate._unknown_reconcile_interval == timedelta(0)
def test_a_reclaimed_uninvoked_job_with_attempts_left_is_retryable(tmp_path) -> None:
"""Recovery of a never-invoked lease is the retry path, not `unknown`.
@@ -2123,19 +2063,6 @@ def test_sqlite_claim_keeps_fifo_order_when_later_work_is_live(tmp_path) -> None
assert drain.claim(limit=1) == (normal.job_id,)
def test_unknown_source_head_does_not_block_later_pending_work(tmp_path) -> None:
path = tmp_path / "one-memory.sqlite3"
queue = DurableWriteQueue(path)
uncertain = queue.submit(_request(evidence=("e1",), key="unknown"))
later = queue.submit(_request(evidence=("e2",), key="later"))
queue.settle(uncertain.job_id, state=UNKNOWN, hy_invoked=True)
drain = DurableWriteDrain(queue, execute=_never_execute, owner="next-owner")
assert drain.claim(limit=1) == (later.job_id,)
assert queue.get("unknown").state == UNKNOWN
def test_sqlite_claim_orders_different_sources_by_enqueue_sequence(tmp_path) -> None:
path = tmp_path / "one-memory.sqlite3"
queue = DurableWriteQueue(path)
@@ -1070,49 +1070,6 @@ def test_confirmed_retry_requeues_task_only_unknown_without_a_durable_job(tmp_pa
assert store.retry_unknown_confirmed() == 0
def test_task_only_unknown_is_automatically_retried_only_once(tmp_path) -> None:
store, profile = _profile_and_tasks(tmp_path)
task = store.create(
request_id="auto-retry-unknown",
user_id="local",
kind=MemoryTaskKind.EXTRACT,
input_ref="orphan-evidence",
profile=profile,
)
with sqlite3.connect(store.database_path) as connection, connection:
connection.execute(
"UPDATE memory_tasks SET status=?,attempts=1,last_error_code=?,updated_at=? "
"WHERE task_id=?",
(
MemoryTaskStatus.UNKNOWN,
"model_content_outcome_unknown",
"2026-01-01T00:00:00+00:00",
task.task_id,
),
)
cutoff = datetime(2026, 1, 1, 0, 5, tzinfo=UTC)
assert store.retry_first_unknown_after_reconcile(older_than=cutoff) == 1
retried = store.require(task.task_id)
assert retried.status is MemoryTaskStatus.PENDING
assert retried.attempts == 1
running, token = store.claim(task.task_id)
assert running.attempts == 2
store.mark_unknown(
task.task_id,
runner_token=token,
error_code="model_content_outcome_unknown",
)
assert (
store.retry_first_unknown_after_reconcile(
older_than=datetime.now(UTC) + timedelta(minutes=1)
)
== 0
)
assert store.require(task.task_id).status is MemoryTaskStatus.UNKNOWN
def test_orphan_recovery_preserves_active_running_tasks(tmp_path) -> None:
store, profile = _profile_and_tasks(tmp_path)
active = store.create(
@@ -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/);
@@ -117,19 +117,9 @@ describe("Linux runtime scripts", () => {
workflow,
/verify-release-version\.mjs[\s\S]*--channel stable/,
);
assert.match(workflow, /shopt -s globstar nullglob/);
assert.match(workflow, /archives=\(release-assets\/\*\*\/"\$name"\)/);
assert.match(
workflow,
/cp -- "\$\{archives\[0\]\}" "publish-assets\/\$name"/,
);
assert.match(workflow, /sha256sum -c/);
assert.match(workflow, /--json isDraft[\s\S]*--jq \.isDraft/);
assert.match(workflow, /gh release create[\s\S]*--draft/);
assert.match(workflow, /gh release upload[\s\S]*--clobber/);
assert.match(
workflow,
/publish-assets\/memhub-runtime-v\*-linux-\*\.tar\.gz/,
);
});
});
+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"