Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
c8c06a13d1 | ||
|
|
55a89514da | ||
|
|
6de3c11bbf |
No files matched your search
@@ -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
@@ -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`.
|
||||
|
||||
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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,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,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,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": {
|
||||
|
||||
@@ -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 ||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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<{
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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
|
||||
|
||||
Generated
+5
-5
@@ -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,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);
|
||||
}
|
||||
};
|
||||
|
||||
@@ -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 启动失败",
|
||||
);
|
||||
});
|
||||
});
|
||||
@@ -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,6 +1,6 @@
|
||||
{
|
||||
"name": "@one-memory/site",
|
||||
"version": "1.0.3",
|
||||
"version": "1.0.2",
|
||||
"private": true,
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@one-memory/web",
|
||||
"version": "1.0.3",
|
||||
"version": "1.0.2",
|
||||
"private": true,
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
|
||||
@@ -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,6 +1,6 @@
|
||||
# MemHub 安装与发行
|
||||
|
||||
MemHub `v1.0.3`(build 7)的发行目标如下:
|
||||
MemHub `v1.0.2`(build 6)的发行目标如下:
|
||||
|
||||
| 平台 | 发行物 | 构建与验收 |
|
||||
| --- | --- | --- |
|
||||
|
||||
+2
-2
@@ -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,6 +1,6 @@
|
||||
{
|
||||
"name": "@one-memory/contracts",
|
||||
"version": "1.0.3",
|
||||
"version": "1.0.2",
|
||||
"private": true,
|
||||
"type": "module",
|
||||
"types": "./src/index.ts",
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@one-memory/host-bridge",
|
||||
"version": "1.0.3",
|
||||
"version": "1.0.2",
|
||||
"private": true,
|
||||
"type": "module",
|
||||
"exports": {
|
||||
|
||||
@@ -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,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,
|
||||
|
||||
@@ -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):
|
||||
|
||||
@@ -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/,
|
||||
);
|
||||
});
|
||||
});
|
||||
@@ -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
@@ -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"
|
||||
|
||||
Reference in new issue
Block a user