Compare commits

...
Author SHA1 Message Date
alvinfeiandCursor d0d8b4e163 fix(ci): locate nested Linux release artifacts
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-10-17 20:33:24 +08:00
20b92d8a89 release: prepare MemHub 1.0.3 (#33)
Co-authored-by: alvinfei <alvinfei@tencent.com>
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-10-17 19:40:33 +08:00
2a4c5e38e7 release: publish Hy Memory 2.0.3 and update MemHub (#31)
* release: publish Hy Memory 2.0.3 and update MemHub

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

* test(memhub): update SDK release documentation contract

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

---------

Co-authored-by: alvinfei <alvinfei@tencent.com>
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-10-17 19:26:56 +08:00
c891a1fd1d fix(runtime): let a notarized bundle replace a same-build runtime (#21)
* fix(runtime): let a notarized bundle replace a same-build runtime

Re-cutting one release keeps its build_number, so a rebuilt package met an
installed runtime carrying the same number and different bytes and was
refused with installed_version_conflict. The app then exited on every
launch, for anyone upgrading or reinstalling without first clearing the
managed install root.

- runtime-control: planLocalInstall takes allowSameBuildReplace, off by
  default so the contract's refusal still holds for every other caller. When
  it is on and the bytes differ, the incoming build lands in its own version
  directory keyed by a payload fingerprint and current moves atomically, so
  the superseded tree stays available as a rollback. Downgrade refusal and
  tamper detection are unchanged.
- desktop: opts in, because the bundle it carries has passed notarization and
  its payload is trusted by construction. A conflict that still cannot be
  reused is answered once by moving the selected tree into quarantine-<ts>/
  and installing again; memory data lives on a different path and is never
  read or written here.
- desktop: the startup dialog explains the failure and says what happens to
  the user's memories instead of printing an internal error code. The code
  stays in the diagnostic event, where support can still match it.
- core + cli: POST /api/v1/onboarding/reset and `memhub onboarding reset`
  send onboarding back to its first-run state and touch nothing else, so the
  cold-start screens can be seen again without deleting a single memory.

* fix(runtime): tighten trusted same-build takeover

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

---------

Co-authored-by: kristinhe1111 <kristinhe1111@users.noreply.github.com>
Co-authored-by: alvinfei <alvinfei@tencent.com>
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-10-17 18:59:29 +08:00
7741e17f09 fix(memhub): keep unknown writes from blocking queues (#27)
* fix(memhub): keep unknown writes from blocking queues

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

* fix(memhub): retry unknown writes after ten seconds

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

---------

Co-authored-by: alvinfei <alvinfei@tencent.com>
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-10-17 18:42:24 +08:00
2a4dc2ffa2 feat(memhub): add product memory pipeline diagnostics (#25)
* 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:35:41 +08:00
58 changed files with 1256 additions and 89 deletions

No files matched your search

+13 -7
View File
@@ -122,19 +122,25 @@ 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
archive="release-assets/memhub-runtime-v${version}-linux-${arch}.tar.gz"
test -f "$archive"
test -f "$archive.sha256"
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"
(
cd release-assets
sha256sum -c "$(basename "$archive.sha256")"
cd publish-assets
sha256sum -c "$name.sha256"
)
done
- name: Upload Linux assets to a draft GitHub Release
@@ -160,7 +166,7 @@ jobs:
--title "$GITHUB_REF_NAME"
fi
gh release upload "$GITHUB_REF_NAME" \
release-assets/memhub-runtime-v*-linux-*.tar.gz \
release-assets/memhub-runtime-v*-linux-*.tar.gz.sha256 \
publish-assets/memhub-runtime-v*-linux-*.tar.gz \
publish-assets/memhub-runtime-v*-linux-*.tar.gz.sha256 \
--repo "$GITHUB_REPOSITORY" \
--clobber
+25 -1
View File
@@ -2,6 +2,16 @@
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
@@ -26,7 +36,21 @@ 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.2 — Unreleased
## 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
- 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.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]**: 🚀 **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]**: 🚀 **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.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
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
```
### 2. Configuration
+8 -8
View File
@@ -32,7 +32,7 @@
## 最新动态
* *<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]**: 🚀 **正式发布** **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]**: 🚀 **模型开源** 在 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.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" # 全部
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" # 全部
```
### 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.2`. MemHub pins that public
- Hy Memory is published as `hy-memory==2.0.3`. 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.2
pip install hy-memory==2.0.3
# 可选后端
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.2
version: 2.0.3
mode: 按运行时配置选择
reader: hybrid
```
仓库 pin、lockfile 和运行时版本守卫均固定到公开的 `hy-memory==2.0.2`。
仓库 pin、lockfile 和运行时版本守卫均固定到公开的 `hy-memory==2.0.3`。
MemHub 在本地 Runtime 内嵌入 SDK,但不绕过自身的来源授权、模型授权、Evidence、
Governance 和撤权边界。
+6 -4
View File
@@ -6,13 +6,13 @@ SDK 和记忆引擎。One Memory / `one-memory` 是历史模块、数据目录
## 发行与平台合同
公共 Python SDK 为 `hy-memory==2.0.2`。MemHub 源码版本为 `1.0.2`
(build 6),GitHub 仓库为
公共 Python SDK 为 `hy-memory==2.0.3`。MemHub 源码版本为 `1.0.3`
(build 7),GitHub 仓库为
[`Tencent-Hunyuan/Hy-Memory`](https://github.com/Tencent-Hunyuan/Hy-Memory),npm 包名为
`memhub-cli`。当前代码架构见
[`docs/ARCHITECTURE.md`](docs/ARCHITECTURE.md)。
`v1.0.2` 的发行目标是 Linux x64/arm64 Runtime 与 macOS arm64/x64 App 和 Runtime。
`v1.0.3` 的发行目标是 Linux x64/arm64 Runtime 与 macOS arm64/x64 App 和 Runtime。
Linux 制品由 GitHub Actions 在原生架构 runner 上构建并完成生命周期验收;macOS
制品由发行流水线完成 Developer ID 签名、公证和验收后手动上传。Windows 不支持。
只有同时出现在 GitHub Release 与 `release-manifest.json` 中、且通过公开校验的制品才属于
@@ -42,6 +42,8 @@ Linux 制品由 GitHub Actions 在原生架构 runner 上构建并完成生命
架构详情见 [`docs/ARCHITECTURE.md`](docs/ARCHITECTURE.md) 和
[`docs/contracts/runtime-unification.md`](docs/contracts/runtime-unification.md)。
产品、测试和一线支持排查导入与记忆沉淀时,使用
[`docs/PRODUCT_LOG_TROUBLESHOOTING.md`](docs/PRODUCT_LOG_TROUBLESHOOTING.md)。
## 开发环境
@@ -99,7 +101,7 @@ MemHub 的 SQLite 数据默认位于 `~/.one-memory`,不在 npm 包或 Runtime
## Hy Memory
当前开发依赖固定为公开 PyPI 包 `hy-memory==2.0.2`。SDK 配置、权限边界及 search
当前开发依赖固定为公开 PyPI 包 `hy-memory==2.0.3`。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-15T17:26:47Z",
"documentNamespace": "https://one-memory.local/spdx/2026-10-17T11:07:57Z",
"creationInfo": {
"created": "2026-10-15T17:26:47Z",
"created": "2026-10-17T11:07:57Z",
"creators": [
"Tool: one-memory-generate-sbom"
]
@@ -2335,7 +2335,7 @@
{
"SPDXID": "SPDXRef-Package-hy-memory",
"name": "hy-memory",
"versionInfo": "2.0.2",
"versionInfo": "2.0.3",
"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-15T17:26:47Z
Generated from the installed dependency tree on 2026-10-17T11:07:57Z
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.2
## hy-memory 2.0.3
License: Apache-2.0
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "@one-memory/cli",
"version": "1.0.2",
"version": "1.0.3",
"description": "MemHub bootstrap CLI for installing and managing the local-first shared memory runtime",
"license": "Apache-2.0",
"repository": {
+31
View File
@@ -81,6 +81,7 @@ import {
readMemoryWrite,
readScanRoots,
removeScanRoot,
resetOnboarding,
submitBackup,
submitMemoryWrite,
submitScan,
@@ -688,6 +689,36 @@ 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,6 +651,14 @@ 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,6 +92,7 @@ const COMMANDS = [
"uninstall",
"upgrade",
"delete-data",
"onboarding",
"help",
"version",
// A6 product commands. Appended, not inserted: the catalog is a stable
+35
View File
@@ -397,6 +397,41 @@ 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.2"
version = "1.0.3"
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.2",
"hy-memory==2.0.3",
# Retrieval's derived vector index directly consumes these locked versions.
"chromadb==1.5.9",
"numpy==2.5.2",
+21 -2
View File
@@ -1049,10 +1049,15 @@ def create_app(
job.job_id,
)
await asyncio.to_thread(
memory_write_queue.retry_timed_out_unknown_after_reconcile,
older_than=datetime.now(UTC) - timedelta(minutes=5),
memory_write_queue.retry_first_unknown_after_reconcile,
older_than=datetime.now(UTC) - timedelta(seconds=10),
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,
@@ -1881,6 +1886,20 @@ 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.2"
HY_MEMORY_VERSION = "2.0.3"
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.2.
# compatibility with public SDK 2.0.3.
HY_MEMORY_MODE = "ultra"
# The SDK only recognises `legacy` and `hybrid` (see
# hy_memory/pipelines/_retrieval/config.py: `ALL_READERS = (READER_LEGACY,
@@ -316,6 +316,10 @@ class ImportSupervisor:
"source_id": current.source_id,
"mode": current.operation,
"count": current.stored,
"stored_count": current.stored,
"revised_count": current.revised,
"skipped_count": current.skipped,
"error_count": current.errors,
},
)
@@ -334,6 +334,19 @@ class MemoryTaskRunner:
**diagnostic_exception(error),
)
raise
outputs, counts = await asyncio.gather(
in_the_background(self.tasks.outputs, task_id),
in_the_background(self.tasks.counts),
)
outstanding = sum(
counts.get(status, 0)
for status in (
MemoryTaskStatus.PENDING,
MemoryTaskStatus.WAITING_FOR_PERMISSION,
MemoryTaskStatus.WAITING_FOR_MODEL,
MemoryTaskStatus.FAILED,
)
)
diagnostic_event(
level="error" if result.status is MemoryTaskStatus.FAILED else "info",
component="memory.task",
@@ -343,6 +356,11 @@ class MemoryTaskRunner:
duration_ms=elapsed_ms(started),
state_after=str(result.status),
error_code=result.last_error_code,
details={
"result_count": len(outputs),
"pending_count": outstanding,
"attempt": result.attempts,
},
)
return result
@@ -306,7 +306,7 @@ LEGACY_MEMORY_WRITE_JOB_COLUMNS = (
("last_error", "TEXT"),
)
_FIFO_BLOCKING_STATES = (PENDING, RUNNING, WAITING_FOR_PERMISSION, WAITING_FOR_MODEL, UNKNOWN)
_FIFO_BLOCKING_STATES = (PENDING, RUNNING, WAITING_FOR_PERMISSION, WAITING_FOR_MODEL)
@dataclass(frozen=True)
@@ -1492,6 +1492,59 @@ 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:
@@ -1896,6 +1949,13 @@ 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
@@ -2308,7 +2368,8 @@ class DurableWriteDrain:
)
if self.reconcile_unknown is not None and (
self._last_unknown_reconcile is None
or tick_started - self._last_unknown_reconcile >= self._reap_interval
or tick_started - self._last_unknown_reconcile
>= self._unknown_reconcile_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.2`:
What was verified against `hy-memory==2.0.3`:
- `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,6 +2161,43 @@ 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,6 +68,16 @@ 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):
@@ -140,6 +150,30 @@ 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,
@@ -165,6 +165,13 @@ def test_import_request_trace_reaches_the_background_job_without_original_text(t
assert "import.queued" in names
assert "import.started" in names
assert "import.finished" in names
finished = next(event for event in events if event["event"] == "import.finished")
assert {
"stored_count",
"revised_count",
"skipped_count",
"error_count",
} <= finished["details"].keys()
assert secret not in written
@@ -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.2"
assert HY_MEMORY_VERSION == "2.0.3"
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.2`.
"""Pin the measured answer for `hy-memory==2.0.3`.
The finding: `add()` takes a `request_id` but nothing de-duplicates on it,
while `MemoryInspector.get_pipeline_logs(request_id=...)` can look the
@@ -315,6 +315,12 @@ def test_memory_task_logs_six_step_durations_without_content(tmp_path) -> None:
events = [json.loads(line) for line in written.splitlines()]
steps = [event for event in events if event["event"] == "memory_task.step_completed"]
assert [event["details"]["phase"] for event in steps] == ["1", "2", "3", "4", "5", "6"]
finished = next(event for event in events if event["event"] == "memory_task.finished")
assert finished["details"] == {
"attempt": 1,
"pending_count": 0,
"result_count": 1,
}
assert tasks.processing_metrics()["hy_calls"] == 1
assert all(event["duration_ms"] >= 0 for event in steps)
assert marker not in written
@@ -1097,6 +1097,52 @@ 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
@@ -1116,6 +1162,20 @@ 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`.
@@ -2063,6 +2123,19 @@ 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,6 +1070,49 @@ 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,6 +13,7 @@ basic info 那半边留在 Kuzu 里,本地看不见。
"""
import asyncio
import sqlite3
from httpx import ASGITransport, AsyncClient
from pydantic import SecretStr
@@ -296,6 +297,69 @@ 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 is present in 2.0.2: it does not store that fallback by
(D87). The SDK fix remains present in 2.0.3: 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.2"
version = "2.0.3"
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/b4/45/367b6d319f8f6c4fb99ee84d440e19616e64096fa50c3cc7672f7ef98653/hy_memory-2.0.2.tar.gz", hash = "sha256:1ba3b0d0bb1eb15ce9bf9b055190449614f93b550d02fb1646469dd01162c77e", size = 823220, upload-time = "2026-10-16T16:01:48.002Z" }
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" }
wheels = [
{ 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" },
{ 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" },
]
[[package]]
@@ -1801,7 +1801,7 @@ wheels = [
[[package]]
name = "one-memory"
version = "1.0.2"
version = "1.0.3"
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.2" },
{ name = "hy-memory", specifier = "==2.0.3" },
{ 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.2",
"version": "1.0.3",
"private": true,
"main": "dist/main.js",
"scripts": {
@@ -3,6 +3,7 @@ import {
diagnosticError,
writeDiagnosticEventSync,
} from "@one-memory/runtime-control";
import { stableRuntimeUserMessage } from "./stable-runtime";
interface EarlyStartupErrorHandlerOptions {
processEvents: Pick<EventEmitter, "on" | "removeListener">;
@@ -52,7 +53,10 @@ export function installEarlyStartupErrorHandler(
);
options.afterDiagnosticWrite?.();
} finally {
options.showError("MemHub 无法启动", errorCode);
// 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.exit(1);
}
};
+8 -1
View File
@@ -1004,6 +1004,9 @@ 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;
@@ -1165,6 +1168,9 @@ 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"),
@@ -1174,7 +1180,7 @@ async function initialize(isQuitting: () => boolean): Promise<void> {
resourcesPath: process.resourcesPath,
executablePath: process.execPath,
installRoot,
platform: process.platform,
platform: hostPlatform,
arch: process.arch,
...(packagedBuildNumber === undefined
? {}
@@ -1182,6 +1188,7 @@ async function initialize(isQuitting: () => boolean): Promise<void> {
...(packagedCommit === undefined
? {}
: { gitCommit: packagedCommit }),
replaceSameBuild: true,
serviceEnableMarker: runtimeServiceEnableMarkerPath(dataDir),
});
await installBundledCliIfNeeded({
@@ -16,6 +16,7 @@ import {
isDisabledServiceInstallError,
isInstalledApplicationsApp,
stableRuntimeErrorCode,
stableRuntimeUserMessage,
startStableRuntimeRepair,
} from "./stable-runtime";
@@ -254,3 +255,23 @@ 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,3 +178,28 @@ 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.2",
"version": "1.0.3",
"private": true,
"type": "module",
"scripts": {
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "@one-memory/web",
"version": "1.0.2",
"version": "1.0.3",
"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.2`. Runtime configuration and permission boundaries are
`hy-memory==2.0.3`. 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.2` is the public Python SDK dependency.
- `hy-memory==2.0.3` 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.2`(build 6)的发行目标如下:
MemHub `v1.0.3`(build 7)的发行目标如下:
| 平台 | 发行物 | 构建与验收 |
| --- | --- | --- |
+253
View File
@@ -0,0 +1,253 @@
# MemHub 产品日志排查手册
本文给产品、测试和一线支持同学使用。目标不是解释底层实现,而是回答三个问题:
1. 数据有没有成功导入;
2. 记忆沉淀有没有持续运行、产出了多少条;
3. 如果停住,停在导入、模型调用还是最终写入。
默认 `info` 下的结构化诊断日志不包含会话正文、模型输入输出或 API Key。`debug`
会额外启用 HY pipeline trace,其中可能包含完整 prompt/response 和获授权的对话正文。
排查时优先通过 App 的“导出日志”提供文件,不要直接发送整个 `~/.one-memory` 数据目录;
debug 期间产生的 pipeline 日志在分享前必须由开发人员确认并脱敏。
## 1. Log level 设什么
### 日常观察:`info`
`info` 是默认级别,已经包含导入数量、任务排队、模型沉淀结果、待处理数量和失败终态。
持续观察产品是否正常工作时,不需要打开 debug。
### 复现具体问题:`debug`
只有开发人员明确要求跟踪一次点击、一次召回或一个具体模型任务时,才打开:
> 设置 → 运行诊断 → 详细日志(排查用)
开关打开后等同于 `debug`。复现完成后:
1. 点击“记录问题时间点”;
2. 点击“导出日志(发给开发排查)”;
3. 关闭“详细日志(排查用)”。
debug 可能把完整模型 prompt/response 写入本机调试日志,不得长期开启。切回 `info`
只会停止后续正文记录,不会自动删除此前已经产生的 debug 文件。
不要把日志级别设置成仅 `warning` 或 `error` 来判断系统是否健康,因为正常进度事件是
`info`。
## 2. 日志在哪里
App 内推荐直接使用“运行诊断 → 导出日志”。开发人员远程指导时,优先使用
`memhub logs`,它会再次过滤不安全字段。需要现场查看原始结构化日志时,Core 日志默认位于:
```text
~/.one-memory/logs/diagnostic-core.jsonl
```
每行都是一条 JSON。macOS 终端中可先设置:
```bash
LOG="$HOME/.one-memory/logs/diagnostic-core.jsonl"
```
## 3. 用什么 key 过滤
产品排查只需要认识以下字段:
- `timestamp`:发生时间;
- `level`:`info`、`warn`、`error`;
- `event`:事件名称,是最重要的过滤 key;
- `outcome`:`started`、`succeeded`、`completed`、`failed` 等结果;
- `error_code`:稳定错误码,可直接发给开发;
- `job_id`:一次导入任务;
- `task_id`:一次记忆沉淀任务;
- `request_id` / `trace_id`:串联一次模型或界面请求;
- `details.result_count`:本次沉淀最终关联了多少条记忆;
- `details.pending_count`:完成本次任务后还有多少条待处理;
- `details.stored_count`:本次导入新增了多少条原始消息;
- `duration_ms`:耗时。
不要按用户原话、会话内容或 API Key 搜索;这些内容按设计不会进入诊断日志。
## 4. 系统正常工作时应该看到什么
### A. Core 已启动
```text
event = core.ready
outcome = succeeded
```
没有 `core.ready` 时,后面的导入和沉淀都不会运行。
### B. 原始消息正在导入
```text
event = import.finished
outcome = completed
details.stored_count >= 0
details.error_count = 0
```
`stored_count=0` 可能只是没有新消息;只要状态不是 failed,就不能单凭 0 判故障。
### C. 记忆任务进入队列
```text
event = memory_scheduler.tasks_queued
outcome = succeeded
details.prepared_count > 0
```
这表示导入的数据已经进入记忆沉淀队列。
### D. SDK 正在沉淀
```text
event = hy.ingest_started
outcome = started
```
随后应出现:
```text
event = hy.ingest_completed
outcome = succeeded
details.result_count = 本次 SDK 返回的记忆数
```
如果有 reconcile,还会出现:
```text
event = hy.reconcile.completed
details.created_count
details.updated_count
details.superseded_count
details.deleted_count
```
### E. MemHub 已完成本次沉淀
最适合产品观察的是:
```text
event = memory_task.finished
outcome = completed
details.result_count = 本任务最终写入或关联的记忆数
details.pending_count = 剩余待处理任务数
details.attempt = 第几次尝试
```
健康表现是:
- `memory_task.finished` 持续出现;
- `pending_count` 总体下降,或在持续导入时保持动态变化;
- `result_count` 间歇性大于 0;
- 没有持续重复的同一 `error_code`。
`result_count=0` 并不一定是故障。某段对话没有值得长期保存的信息时,0 是正常结果。只有大量
任务连续为 0,同时存在 `hy.ingest_failed`、`memory_task.failed` 或积压只增不减,才需要升级
排查。
## 5. 可复制的过滤命令
优先使用以下安全读取命令:
```bash
memhub logs --since 2h --event memory_task.finished --json
memhub logs --since 2h --event hy.reconcile.completed --json
memhub logs --since 24h --errors-only --tail 500 --json
memhub status --json
```
以下 `jq` 命令仅用于 `diagnostic-core.jsonl`,不要对 `core.log`、`service.log` 或 debug
pipeline 日志直接运行后把完整输出发给他人。
### 查看最近的失败
```bash
jq -r '
select(.level == "error" or .outcome == "failed") |
[.timestamp, .service, .event, (.error_code // "-"), (.task_id // .job_id // "-")] |
@tsv
' "$LOG"
```
### 查看每次沉淀数量和剩余积压
```bash
jq -r '
select(.event == "memory_task.finished") |
[.timestamp, .outcome, (.details.result_count // 0),
(.details.pending_count // 0), (.details.attempt // 0), (.error_code // "-")] |
@tsv
' "$LOG"
```
输出列依次是:
```text
时间 结果 本次记忆数 剩余待处理数 尝试次数 错误码
```
### 统计当前日志里累计完成和累计记忆数
```bash
jq -s '
[.[] | select(.event == "memory_task.finished" and .outcome == "completed")] |
{
completed_tasks: length,
memory_results: (map(.details.result_count // 0) | add // 0),
latest_pending: (last.details.pending_count // 0)
}
' "$LOG"
```
这里的 `memory_results` 是任务结果数量之和,用于判断流水线是否持续产出;它不是当前记忆库
去重后的总行数。
### 查看导入数量
```bash
jq -r '
select(.event == "import.finished") |
[.timestamp, .details.source_id, .outcome,
(.details.stored_count // 0), (.details.revised_count // 0),
(.details.skipped_count // 0), (.details.error_count // 0)] |
@tsv
' "$LOG"
```
### 跟踪一个具体任务
先从失败或完成日志复制 `task_id`:
```bash
TASK_ID="粘贴-task_id"
jq --arg id "$TASK_ID" 'select(.task_id == $id)' "$LOG"
```
## 6. 常见停点怎么判断
- 有 `core.ready`,没有 `import.finished`:来源未授权、未开启同步,或来源读取失败。
- `import.finished` 正常,没有 `memory_scheduler.tasks_queued`:导入内容尚未形成完整会话,或未进入处理窗口。
- 有 `memory_task.started`,没有 `hy.ingest_completed`:重点查模型配置、余额、网络、超时和 `hy.ingest_failed`。
- 有 `hy.ingest_completed`,但 `memory_task.finished` 失败:重点查最终治理写入和对应 `error_code`。
- `pending_count` 长时间完全不下降:查看是否反复出现同一个错误码,或任务都在
`waiting_for_model`。
- 导入数量持续增加、沉淀任务完全没有出现:这是异常,不应解释为“对话没有记忆价值”。
## 7. 产品反馈给开发时提供什么
请提供:
1. 问题发生的大致时间;
2. App 版本和平台;
3. 是否开启了详细日志;
4. 点击“记录问题时间点”后显示的结果;
5. 通过“导出日志”得到的诊断文件;
6. 最近重复出现的 `error_code`;
7. `memory_task.finished` 最近几行中的 `result_count` 和 `pending_count`。
不要提供 API Key,也不要打包发送整个记忆数据库。
@@ -44,6 +44,16 @@ host-bridge、desktop 和 cli。单文件 4 MiB,保留 3 份轮转;额外清
权限与安装操作、关键 IPC、失败分类、重试、耗时和终态。不记录无变化的高频轮询或逐条原文,
导入使用阶段和计数汇总,避免日志反过来造成性能问题。
产品进度判断使用以下稳定事件:
- `import.finished`:`stored_count`、`revised_count`、`skipped_count`、`error_count`;
- `hy.ingest.completed`:SDK 本次返回的 `result_count`;
- `hy.reconcile.completed`:新增、更新、取代和删除数量;
- `memory_task.finished`:最终 `result_count`、剩余 `pending_count` 和 `attempt`。
其中 `memory_task.finished` 是产品侧判断沉淀是否持续推进的最终事件;不能只用
`hy.ingest.completed` 代替,因为后者早于 Governance 写入。
## 验收
隐私测试必须先植入一条可检测的秘密作为正向对照,再证明真实链路日志和导出均不含该秘密。
+2 -2
View File
@@ -1,8 +1,8 @@
{
"name": "one-memory",
"version": "1.0.2",
"version": "1.0.3",
"release": {
"build_number": 6,
"build_number": 7,
"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.2",
"version": "1.0.3",
"private": true,
"type": "module",
"types": "./src/index.ts",
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "@one-memory/host-bridge",
"version": "1.0.2",
"version": "1.0.3",
"private": true,
"type": "module",
"exports": {
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "@one-memory/operations",
"version": "1.0.2",
"version": "1.0.3",
"private": true,
"type": "module",
"types": "./src/index.ts",
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "@one-memory/runtime-control",
"version": "1.0.2",
"version": "1.0.3",
"private": true,
"type": "module",
"bin": {
@@ -61,7 +61,12 @@ function macho(cpuArch: string): Buffer {
async function distribution(
version = "1.0.0",
options: { buildNumber?: number; arch?: string; nativeArch?: string } = {},
options: {
buildNumber?: number;
arch?: string;
nativeArch?: string;
gitCommit?: string;
} = {},
) {
const root = await mkdtemp(path.join(tmpdir(), "one-memory-local-install-"));
roots.push(root);
@@ -106,6 +111,7 @@ 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,
@@ -618,6 +624,187 @@ 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,6 +50,10 @@ 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;
@@ -69,6 +73,10 @@ 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))
@@ -86,11 +94,12 @@ export async function planLocalInstall(options: {
if (options.requireProtocolCompatibility === true) {
assertDistributionProtocolCompatibility(manifest);
}
await assertBuildNumberTransition({
const transition = await inspectBuildNumberTransition({
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);
@@ -109,11 +118,18 @@ export async function planLocalInstall(options: {
schema_version: 1,
source,
install_root: installRoot,
version_directory: path.join(installRoot, "versions", id),
version_directory: path.join(
installRoot,
"versions",
transition.sameBuildReplace
? `${id}-r${manifestFingerprint(manifest)}`
: 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,
@@ -137,6 +153,7 @@ 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 (
@@ -145,6 +162,7 @@ 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
@@ -316,18 +334,29 @@ 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 assertBuildNumberTransition(options: {
async function inspectBuildNumberTransition(options: {
installRoot: string;
incoming: DistributionManifest;
incomingSource: string;
allowDowngrade: boolean;
}): Promise<void> {
allowSameBuildReplace: boolean;
}): Promise<{ sameBuildReplace: boolean }> {
const installRoot = path.resolve(options.installRoot);
const current = await lstat(path.join(installRoot, "current")).catch(
() => null,
);
if (!current) return;
if (!current) return { sameBuildReplace: false };
if (!current.isSymbolicLink()) throw new Error("install_current_unmanaged");
const target = path.resolve(
installRoot,
@@ -338,28 +367,89 @@ async function assertBuildNumberTransition(options: {
if (relative.startsWith("..") || path.isAbsolute(relative)) {
throw new Error("install_current_unmanaged");
}
const selected = await readManifest(target).catch(() => null);
if (selected === null) return;
const selected = options.allowSameBuildReplace
? await verifyDistribution(
target,
options.incoming.platform,
options.incoming.arch,
)
: await readManifest(target).catch(() => null);
if (selected === null) return { sameBuildReplace: false };
// 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;
if (selected.build_number === undefined) return;
if (options.incoming.build_number > selected.build_number) return;
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 === 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 {
throw new Error("installed_version_conflict");
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 };
}
return;
return { sameBuildReplace: false };
}
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,6 +100,7 @@ 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({
@@ -109,6 +110,7 @@ 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 });
@@ -376,6 +378,72 @@ 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,6 +51,16 @@ 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. */
@@ -150,6 +160,7 @@ export async function installPackagedRuntime(options: {
platform: options.platform,
arch: options.arch,
buildNumber: options.buildNumber,
replaceSameBuild: options.replaceSameBuild === true,
executablePath: executable,
serviceEnableMarker: options.serviceEnableMarker,
});
@@ -159,6 +170,7 @@ export async function installPackagedRuntime(options: {
installRoot: options.installRoot,
platform: options.platform,
arch: options.arch,
allowSameBuildReplace: options.replaceSameBuild === true,
requireProtocolCompatibility: true,
});
return Object.freeze({
@@ -176,6 +188,7 @@ async function reuseSelectedSameBuild(options: {
platform: "darwin" | "linux";
arch: string;
buildNumber: number | undefined;
replaceSameBuild: boolean;
executablePath: string;
serviceEnableMarker: string | undefined;
}): Promise<PackagedRuntimeInstallReceipt | null> {
@@ -239,11 +252,15 @@ 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");
}
}
@@ -276,6 +293,8 @@ 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.2"
HY_MEMORY_VERSION = "2.0.3"
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\.2`/);
assert.match(readme, /`v1\.0\.2` 的发行目标是 Linux x64\/arm64 Runtime/);
assert.match(readme, /公共 Python SDK 为 `hy-memory==2\.0\.3`/);
assert.match(readme, /`v1\.0\.3` 的发行目标是 Linux x64\/arm64 Runtime/);
assert.match(readme, /Linux 制品由 GitHub Actions/);
assert.match(readme, /macOS.*Developer ID 签名、公证/s);
assert.match(readme, /release-manifest\.json/);
@@ -117,9 +117,19 @@ 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.2.dist-info"
dist_info = tmp_path / "hy_memory-2.0.3.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.2",
"Version: 2.0.3",
"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.2"
assert notice["version"] == "2.0.3"
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.2"
version = "2.0.3"
description = "HY Memory - Industrial-grade dual-system cognitive memory framework"
readme = "README.md"
requires-python = ">=3.9"