Compare commits
35
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
db6fd1b8e3 | ||
|
|
5176683937 | ||
|
|
6150ceb9cc | ||
|
|
6054f9921a | ||
|
|
ba70b84b00 | ||
|
|
fb71eafa35 | ||
|
|
cdd549fdd1 | ||
|
|
0df84c086c | ||
|
|
dc0ba49fbe | ||
|
|
ce705e9e4a | ||
|
|
334b14472b | ||
|
|
38baa9eebf | ||
|
|
0083a355a5 | ||
|
|
c049facb70 | ||
|
|
c681fdcf24 | ||
|
|
d0a52c9863 | ||
|
|
01259d01f5 | ||
|
|
0e436e9b70 | ||
|
|
ec99618e1f | ||
|
|
c53c874c21 | ||
|
|
80f21d29a7 | ||
|
|
2cc2f02450 | ||
|
|
5be27fd87f | ||
|
|
7aaf0f8557 | ||
|
|
887583b858 | ||
|
|
fcdb2307e2 | ||
|
|
9ed744b455 | ||
|
|
1b90f04a50 | ||
|
|
0822e7137e | ||
|
|
bcc0751060 | ||
|
|
183b61e736 | ||
|
|
e038006c7f | ||
|
|
16953ed49e | ||
|
|
4ff948c7e1 | ||
|
|
346f2653f2 |
No files matched your search
@@ -14,11 +14,6 @@ Hy Memory and MemHub are versioned and released independently.
|
||||
|
||||
## Unreleased
|
||||
|
||||
- Clarified that `add(metadata=...)` carries legacy SDK processing options,
|
||||
while persistent caller business context belongs in `meta`.
|
||||
- Added an opt-in `get(..., include_raw=True)` path for reading `L1_RAW`
|
||||
memories, including memories written in lite mode. The default remains
|
||||
hidden for backward compatibility.
|
||||
- Removed MemHub's task-scoped HyPermission store wrappers and global
|
||||
model-content consent switch. The trusted embedded Hy Memory SDK now uses
|
||||
its native local stores directly.
|
||||
|
||||
@@ -32,8 +32,7 @@ English | [中文](README.zh.md)
|
||||
|
||||
## Latest News
|
||||
|
||||
* *<mark>[new]</mark>* 🔥🔥🔥 **[2026-10]**: 📦 **Released [MemHub v1.0.4](https://github.com/Tencent-Hunyuan/Hy-Memory/releases/tag/v1.0.4)** and **[memhub-cli 1.0.5](https://www.npmjs.com/package/memhub-cli/v/1.0.5)**, with a signed macOS desktop app and verified macOS/Linux Runtimes!
|
||||
* *<mark>[new]</mark>* 🔥🔥🔥 **[2026-10]**: 🚀 **Released** **Hy Memory 2.0 (`hy-memory==2.0.4`)** on [PyPI](https://pypi.org/project/hy-memory/2.0.4/) and open-sourced the **Hy-Memory** monorepo together with **[MemHub](memhub/README.md)**!
|
||||
* *<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)**!
|
||||
|
||||
+1
-2
@@ -32,8 +32,7 @@
|
||||
|
||||
## 最新动态
|
||||
|
||||
* *<mark>[new]</mark>* 🔥🔥🔥 **[2026-10]**: 📦 **正式发布 [MemHub v1.0.4](https://github.com/Tencent-Hunyuan/Hy-Memory/releases/tag/v1.0.4)** 与 **[memhub-cli 1.0.5](https://www.npmjs.com/package/memhub-cli/v/1.0.5)**,提供已签名的 macOS 桌面应用及经过验证的 macOS/Linux Runtime!
|
||||
* *<mark>[new]</mark>* 🔥🔥🔥 **[2026-10]**: 🚀 **正式发布** **Hy Memory 2.0 (`hy-memory==2.0.4`)**(已上线 [PyPI](https://pypi.org/project/hy-memory/2.0.4/)),并开源 **Hy-Memory** monorepo 与 **[MemHub](memhub/README.md)**!
|
||||
* *<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** 接收!
|
||||
|
||||
@@ -106,15 +106,6 @@ result = client.add(
|
||||
)
|
||||
```
|
||||
|
||||
`metadata` 与 `meta` 不是同一字段:
|
||||
|
||||
- `metadata` 是兼容旧调用方保留的 SDK 处理选项,会进入内部
|
||||
`WriteRequest.extra`,不保证持久化或随查询结果返回。
|
||||
- `meta` 是调用方的业务上下文,会持久化并在 `search()`、`get()` 等结果中返回。
|
||||
同一记忆被多次合并时,各次 `meta` 会按写入顺序保存在列表中。
|
||||
|
||||
需要以后读回的数据必须放在 `meta`,不要放在 `metadata`。
|
||||
|
||||
`scene` 的公开取值:
|
||||
|
||||
- `None`:普通文本直接走 chat;messages 含 tool 时执行快速场景判断。
|
||||
@@ -272,8 +263,6 @@ catalog 也可以在没有基础 L0 时形成只读的合成画像。
|
||||
|
||||
```python
|
||||
memory = client.get("memory_id")
|
||||
# lite 模式写入的是 L1_RAW;按 ID 读取时必须显式选择返回原文。
|
||||
raw_memory = client.get("memory_id", include_raw=True)
|
||||
result = client.update("memory_id", "更新后的内容")
|
||||
result = client.delete("memory_id")
|
||||
result = client.delete_all(user_id="user_001")
|
||||
@@ -293,10 +282,6 @@ result = client.clone_user("user_001", "user_002", agent_id=None)
|
||||
对应异步方法为 `async_get()`、`async_update()`、`async_delete()`、
|
||||
`async_delete_all()`、`async_list_memories()`、`async_clone_user()`。
|
||||
|
||||
`get()` / `async_get()` 默认继续隐藏 `L1_RAW`,因此不会改变现有调用方的原文访问
|
||||
边界。只有显式传入 `include_raw=True` 才返回该层;这也是 lite 模式按 ID 读取
|
||||
写入内容及其 `meta` 的方式。MemHub 的 HTTP 读取接口不开放此参数。
|
||||
|
||||
普通 `list_memories()` 返回 `vdb` 桶,并在 Graph 有结果时附带 `graph`;传入
|
||||
`workspace_id` 时返回生产力记忆的 `scene="productivity"` 和 `experiences`。
|
||||
|
||||
|
||||
+12
-39
@@ -1597,9 +1597,7 @@ class HyMemoryClient:
|
||||
session_id: Session ID(三级隔离 key)
|
||||
source: 调用来源,例如 openclaw / cursor / codex;与 agent_id 正交。
|
||||
写入前执行 strip().lower(),默认 "default"。
|
||||
metadata: 旧版 SDK 处理选项,会并入内部 WriteRequest.extra。
|
||||
不保证持久化或随查询返回;调用方需要保存并读回的业务字段
|
||||
请使用 meta。
|
||||
metadata: 自定义元数据
|
||||
memory_at: 记忆时间戳(不传则用当前时间),用于导入历史记忆
|
||||
scene: 显式指定写入路由,跳过 LLM 自动判定。
|
||||
- None(默认):含 tool 时 LLM 判 chat/working/coding;
|
||||
@@ -1625,8 +1623,8 @@ class HyMemoryClient:
|
||||
branch: Coding memory 路径专用。分支名。同 workspace_id。chat 路径忽略。
|
||||
meta: 调用方自定义业务 meta,随本次产出的记忆存储、查询时原样返回。
|
||||
不 embed、不参与召回、不作过滤条件。
|
||||
与 metadata 不同:metadata 是旧版内部处理选项,
|
||||
meta 独立持久化,只做业务透传。
|
||||
与 metadata 不同:metadata 会并入 extra(含 SDK 内部控制键),
|
||||
meta 独立存放,只做透传。
|
||||
语义为「该次 write 时刻的上下文」,故存为 list——同一节点被
|
||||
多次写入合并时会追加而非覆盖,详见 get() 返回的 meta 字段。
|
||||
|
||||
@@ -1793,26 +1791,17 @@ class HyMemoryClient:
|
||||
query_embedding=query_embedding,
|
||||
))
|
||||
|
||||
def get(
|
||||
self,
|
||||
memory_id: str,
|
||||
*,
|
||||
include_raw: bool = False,
|
||||
) -> Optional[Dict[str, Any]]:
|
||||
def get(self, memory_id: str) -> Optional[Dict[str, Any]]:
|
||||
"""
|
||||
获取单条记忆。
|
||||
|
||||
Args:
|
||||
memory_id: 记忆 ID
|
||||
include_raw: 是否允许返回 L1_RAW 原始记忆。默认 False,保持原有隐藏行为;
|
||||
lite 模式的记忆均为 L1_RAW,需显式设为 True 才能按 ID 读取。
|
||||
|
||||
Returns:
|
||||
{"memory_id", "content", "layer", "request_id", "elapsed_ms"} 或 None(不存在时)
|
||||
"""
|
||||
return self._loop_thread.run(
|
||||
self.async_get(memory_id, include_raw=include_raw)
|
||||
)
|
||||
return self._loop_thread.run(self.async_get(memory_id))
|
||||
|
||||
def update(
|
||||
self,
|
||||
@@ -3661,21 +3650,12 @@ class HyMemoryClient:
|
||||
# ================================================================
|
||||
|
||||
@_ensure_internal_loop
|
||||
async def async_get(
|
||||
self,
|
||||
memory_id: str,
|
||||
*,
|
||||
include_raw: bool = False,
|
||||
) -> Optional[Dict[str, Any]]:
|
||||
async def async_get(self, memory_id: str) -> Optional[Dict[str, Any]]:
|
||||
"""
|
||||
获取单条记忆(异步)。
|
||||
|
||||
VDB + Graph 双路并行查询,哪个有结果返回哪个。
|
||||
|
||||
Args:
|
||||
memory_id: 记忆 ID
|
||||
include_raw: 是否允许返回 L1_RAW 原始记忆,默认 False。
|
||||
|
||||
Returns:
|
||||
{"memory_id", "content", "layer", "source", "request_id", "elapsed_ms"} 或 None
|
||||
"""
|
||||
@@ -3703,24 +3683,17 @@ class HyMemoryClient:
|
||||
|
||||
vdb_node, graph_node = await _asyncio_get.gather(_get_vdb(), _get_graph())
|
||||
|
||||
# L1_RAW 默认不对外可见:按 ID 直取走主键查询、绕过向量库的 layer
|
||||
# filter,故在此统一挡掉。SDK 调用方可通过 include_raw=True 显式读取;
|
||||
# HTTP 接口不传该参数,继续保持隐藏。内部写链路不经过此方法。
|
||||
# L1_RAW 不对外可见:按 ID 直取走主键查询、绕过向量库的 layer filter,
|
||||
# 故在此统一挡掉。async_get 是 GET /v1/memories/{id} 与
|
||||
# /v1/memories/batch 两个对外接口的共同必经处,而内部写链路不经过它
|
||||
# (writer 写后校验直接调 vector_store.get_by_id),挡在此处不影响内部。
|
||||
#
|
||||
# 置于 gather 之后、埋点之前:命中 raw 仍照常落 GET 埋点(保留可观测性,
|
||||
# 便于发现有调用方拿 raw id 试探),只是不把内容返回给调用方。
|
||||
# 对调用方的表现与"该 id 不存在"完全一致,不额外暴露"存在但被挡"的信息。
|
||||
if (
|
||||
not include_raw
|
||||
and vdb_node is not None
|
||||
and vdb_node.layer == MemoryLayer.L1_RAW
|
||||
):
|
||||
if vdb_node is not None and vdb_node.layer == MemoryLayer.L1_RAW:
|
||||
vdb_node = None
|
||||
if (
|
||||
not include_raw
|
||||
and graph_node is not None
|
||||
and getattr(graph_node, "layer", None) == MemoryLayer.L1_RAW
|
||||
):
|
||||
if graph_node is not None and getattr(graph_node, "layer", None) == MemoryLayer.L1_RAW:
|
||||
graph_node = None
|
||||
|
||||
elapsed = round((time.perf_counter() - t0) * 1000, 2)
|
||||
|
||||
@@ -12,6 +12,7 @@
|
||||
* It is generated rather than hand-written so the two cannot drift: this reads
|
||||
* the real package.json and only strips what is provably not needed at runtime.
|
||||
*/
|
||||
import { execFileSync } from "node:child_process";
|
||||
import { mkdirSync, readFileSync, writeFileSync } from "node:fs";
|
||||
import { createRequire } from "node:module";
|
||||
import { dirname, resolve } from "node:path";
|
||||
@@ -19,14 +20,12 @@ import { fileURLToPath } from "node:url";
|
||||
|
||||
const here = dirname(fileURLToPath(import.meta.url));
|
||||
const root = resolve(here, "..");
|
||||
const repositoryRoot = resolve(root, "../..");
|
||||
const require = createRequire(import.meta.url);
|
||||
const { assertSemver } = require("../../../scripts/release-version.cjs");
|
||||
const { resolveBuildVersion } = require("../../../scripts/release-version.cjs");
|
||||
const releaseVersion = resolveBuildVersion(process.env, repositoryRoot);
|
||||
const pkgPath = resolve(root, "package.json");
|
||||
const pkg = JSON.parse(readFileSync(pkgPath, "utf8"));
|
||||
const publishVersion = assertSemver(
|
||||
process.env.MEMHUB_NPM_VERSION?.trim() || pkg.version,
|
||||
"MEMHUB_NPM_VERSION",
|
||||
);
|
||||
const publishName = process.env.MEMHUB_NPM_PACKAGE?.trim();
|
||||
if (!publishName || !/^(?:@[a-z0-9._-]+\/)?[a-z0-9._-]+$/.test(publishName)) {
|
||||
throw new Error(
|
||||
@@ -54,7 +53,7 @@ mkdirSync(outDir, { recursive: true });
|
||||
|
||||
const published = { ...pkg };
|
||||
published.name = publishName;
|
||||
published.version = publishVersion;
|
||||
published.version = releaseVersion;
|
||||
for (const section of ["dependencies", "devDependencies", "peerDependencies"]) {
|
||||
if (!published[section]) continue;
|
||||
published[section] = Object.fromEntries(
|
||||
|
||||
@@ -331,7 +331,7 @@ import {
|
||||
renderVersion,
|
||||
renderVersionDocument,
|
||||
} from "./usage.js";
|
||||
import { CLI_VERSION, PRODUCT, RUNTIME_RELEASE_VERSION } from "./version.js";
|
||||
import { CLI_VERSION, PRODUCT } from "./version.js";
|
||||
import { runWatchLoop, type WatchEvent, type WatchRead } from "./watch-loop.js";
|
||||
|
||||
export interface RunControls {
|
||||
@@ -417,8 +417,7 @@ async function selectInstallSource(
|
||||
throw new Error("release_manifest_url_unavailable");
|
||||
}
|
||||
const manifestUrl =
|
||||
explicitManifestUrl ??
|
||||
template.replaceAll("{version}", RUNTIME_RELEASE_VERSION);
|
||||
explicitManifestUrl ?? template.replaceAll("{version}", CLI_VERSION);
|
||||
const allowedHosts = parseAllowedReleaseHosts(
|
||||
environment.MEMHUB_RELEASE_ALLOWED_HOSTS,
|
||||
);
|
||||
@@ -463,7 +462,7 @@ async function resolveRemoteInstallPreview(
|
||||
): Promise<ResolvedRuntimeRelease> {
|
||||
return (controls.resolveRuntimeRelease ?? resolveRuntimeRelease)({
|
||||
manifestUrl: selection.manifestUrl,
|
||||
expectedVersion: RUNTIME_RELEASE_VERSION,
|
||||
expectedVersion: CLI_VERSION,
|
||||
channel: selection.channel,
|
||||
platform: process.platform,
|
||||
arch: process.arch,
|
||||
@@ -477,7 +476,7 @@ async function acquireRemoteInstallSource(
|
||||
): Promise<AcquiredRuntimeDistribution> {
|
||||
return (controls.acquireRuntimeDistribution ?? acquireRuntimeDistribution)({
|
||||
manifestUrl: selection.manifestUrl,
|
||||
expectedVersion: RUNTIME_RELEASE_VERSION,
|
||||
expectedVersion: CLI_VERSION,
|
||||
channel: selection.channel,
|
||||
platform: process.platform,
|
||||
arch: process.arch,
|
||||
@@ -6521,7 +6520,7 @@ function parseInstallOptions(
|
||||
...(releaseManifestUrl ? { releaseManifestUrl } : {}),
|
||||
releaseChannel:
|
||||
releaseChannel ??
|
||||
(RUNTIME_RELEASE_VERSION.includes("-") ? "prerelease" : "stable"),
|
||||
(CLI_VERSION.includes("-") ? "prerelease" : "stable"),
|
||||
installRoot: resolvedInstallRoot,
|
||||
home: resolvedHome,
|
||||
uid: uid ?? process.getuid?.(),
|
||||
|
||||
@@ -48,11 +48,7 @@ describe("publish manifest is production-safe", () => {
|
||||
execFileSync("node", ["scripts/prepare-publish-manifest.mjs"], {
|
||||
cwd: root,
|
||||
stdio: "pipe",
|
||||
env: {
|
||||
...process.env,
|
||||
MEMHUB_NPM_PACKAGE: "@example/memhub-cli",
|
||||
MEMHUB_NPM_VERSION: "1.0.5",
|
||||
},
|
||||
env: { ...process.env, MEMHUB_NPM_PACKAGE: "@example/memhub-cli" },
|
||||
});
|
||||
const published = JSON.parse(
|
||||
readFileSync(path.join(root, "dist-publish", "package.json"), "utf8"),
|
||||
@@ -60,7 +56,6 @@ describe("publish manifest is production-safe", () => {
|
||||
expect(published.dependencies).toBeUndefined();
|
||||
expect(published.private).toBe(false);
|
||||
expect(published.name).toBe("@example/memhub-cli");
|
||||
expect(published.version).toBe("1.0.5");
|
||||
expect(published.bin).toMatchObject({
|
||||
memhub: "dist/one-memory.cjs",
|
||||
"memhub-cli": "dist/one-memory.cjs",
|
||||
|
||||
@@ -1,9 +1,5 @@
|
||||
declare const __ONE_MEMORY_VERSION__: string;
|
||||
declare const __ONE_MEMORY_BUILD_NUMBER__: string;
|
||||
declare const __MEMHUB_DEFAULT_RUNTIME_VERSION__: string;
|
||||
|
||||
const SEMVER =
|
||||
/^(0|[1-9]\d*)\.(0|[1-9]\d*)\.(0|[1-9]\d*)(?:-[0-9A-Za-z-]+(?:\.[0-9A-Za-z-]+)*)?(?:\+[0-9A-Za-z-]+(?:\.[0-9A-Za-z-]+)*)?$/;
|
||||
|
||||
const embeddedVersion =
|
||||
typeof __ONE_MEMORY_VERSION__ === "string"
|
||||
@@ -13,18 +9,13 @@ const embeddedBuildNumber =
|
||||
typeof __ONE_MEMORY_BUILD_NUMBER__ === "string"
|
||||
? __ONE_MEMORY_BUILD_NUMBER__.trim()
|
||||
: "";
|
||||
const embeddedRuntimeVersion =
|
||||
typeof __MEMHUB_DEFAULT_RUNTIME_VERSION__ === "string"
|
||||
? __MEMHUB_DEFAULT_RUNTIME_VERSION__.trim()
|
||||
: "";
|
||||
|
||||
export const CLI_VERSION = SEMVER.test(embeddedVersion)
|
||||
? embeddedVersion
|
||||
: "development";
|
||||
|
||||
export const RUNTIME_RELEASE_VERSION = SEMVER.test(embeddedRuntimeVersion)
|
||||
? embeddedRuntimeVersion
|
||||
: CLI_VERSION;
|
||||
export const CLI_VERSION =
|
||||
/^(0|[1-9]\d*)\.(0|[1-9]\d*)\.(0|[1-9]\d*)(?:-[0-9A-Za-z-]+(?:\.[0-9A-Za-z-]+)*)?(?:\+[0-9A-Za-z-]+(?:\.[0-9A-Za-z-]+)*)?$/.test(
|
||||
embeddedVersion,
|
||||
)
|
||||
? embeddedVersion
|
||||
: "development";
|
||||
|
||||
export const CLI_BUILD_NUMBER = /^\d+$/.test(embeddedBuildNumber)
|
||||
? Number(embeddedBuildNumber)
|
||||
|
||||
@@ -2,22 +2,11 @@ import { createRequire } from "node:module";
|
||||
import { defineConfig } from "tsup";
|
||||
|
||||
const require = createRequire(import.meta.url);
|
||||
const { assertSemver, resolveBuildVersion } =
|
||||
const { resolveBuildVersion } =
|
||||
require("../../scripts/release-version.cjs") as {
|
||||
assertSemver(value: string, label?: string): string;
|
||||
resolveBuildVersion(environment?: NodeJS.ProcessEnv, root?: string): string;
|
||||
};
|
||||
const releaseVersion = resolveBuildVersion(process.env);
|
||||
const packageVersion = (require("./package.json") as { version: string })
|
||||
.version;
|
||||
const cliVersion = assertSemver(
|
||||
process.env.MEMHUB_NPM_VERSION?.trim() || packageVersion,
|
||||
"MEMHUB_NPM_VERSION",
|
||||
);
|
||||
const runtimeReleaseVersion = assertSemver(
|
||||
process.env.MEMHUB_DEFAULT_RUNTIME_VERSION?.trim() || releaseVersion,
|
||||
"MEMHUB_DEFAULT_RUNTIME_VERSION",
|
||||
);
|
||||
|
||||
export default defineConfig({
|
||||
entry: { "one-memory": "src/entry.ts" },
|
||||
@@ -26,7 +15,7 @@ export default defineConfig({
|
||||
outDir: "dist",
|
||||
clean: true,
|
||||
define: {
|
||||
__ONE_MEMORY_VERSION__: JSON.stringify(cliVersion),
|
||||
__ONE_MEMORY_VERSION__: JSON.stringify(releaseVersion),
|
||||
__ONE_MEMORY_BUILD_NUMBER__: JSON.stringify(
|
||||
process.env.ONE_MEMORY_BUILD_NUMBER ?? "unknown",
|
||||
),
|
||||
@@ -36,7 +25,6 @@ export default defineConfig({
|
||||
__MEMHUB_DEFAULT_RELEASE_MANIFEST_URL_TEMPLATE__: JSON.stringify(
|
||||
process.env.MEMHUB_DEFAULT_RELEASE_MANIFEST_URL_TEMPLATE ?? "",
|
||||
),
|
||||
__MEMHUB_DEFAULT_RUNTIME_VERSION__: JSON.stringify(runtimeReleaseVersion),
|
||||
},
|
||||
// The shared operations package ships TypeScript sources, so it must be
|
||||
// inlined into the CJS bundle the same way runtime-control is; otherwise the
|
||||
|
||||
@@ -11,7 +11,6 @@ export default defineConfig({
|
||||
__ONE_MEMORY_BUILD_NUMBER__: JSON.stringify("unknown"),
|
||||
__ONE_MEMORY_COMMIT__: JSON.stringify("unknown"),
|
||||
__MEMHUB_DEFAULT_RELEASE_MANIFEST_URL_TEMPLATE__: JSON.stringify(""),
|
||||
__MEMHUB_DEFAULT_RUNTIME_VERSION__: JSON.stringify(version),
|
||||
},
|
||||
test: {
|
||||
testTimeout: 120_000,
|
||||
|
||||
@@ -2793,12 +2793,6 @@ def create_app(
|
||||
# D75: work queued for the previous model continues with this one.
|
||||
# Only tasks that never reached the model are re-bound.
|
||||
await asyncio.to_thread(app.state.memory_task_store.rebind_unstarted_profile, profile)
|
||||
# A missing profile is an event-driven wait, not a provider outage.
|
||||
# Release those durable rows only now that activation has changed the
|
||||
# condition; the ordinary drain poll will claim them without a restart.
|
||||
await asyncio.to_thread(
|
||||
app.state.memory_write_queue.readmit_model_profile_waiters
|
||||
)
|
||||
# Not awaited: the catch-up reads every conversation ever imported, and
|
||||
# the answer to "which profile is active" does not depend on it.
|
||||
_start_backfill(app)
|
||||
@@ -5068,11 +5062,10 @@ def _source_writes_on(app: FastAPI, source_id: str) -> bool:
|
||||
|
||||
|
||||
def _sedimentation_claim_enabled(app: FastAPI, source_id: str) -> bool:
|
||||
"""Claim queued model work only when policy, source and model all admit it."""
|
||||
"""Claim queued model work only while both global and source gates allow it."""
|
||||
|
||||
return (
|
||||
_config_store(app).sedimentation_enabled()
|
||||
and _model_profile_store(app).active_identity() is not None
|
||||
and _history_import_gate_released(app)
|
||||
and _source_writes_on(app, source_id)
|
||||
)
|
||||
|
||||
@@ -282,24 +282,11 @@ class PollingLiveSyncSupervisor:
|
||||
and job.status
|
||||
in {ImportStatus.PENDING, ImportStatus.RUNNING, ImportStatus.INTERRUPTED}
|
||||
]
|
||||
bounded_history_incomplete = any(
|
||||
job.source_id == source_id
|
||||
and job.operation == "history"
|
||||
and job.history_after is not None
|
||||
and job.status is not ImportStatus.COMPLETED
|
||||
for job in source_jobs
|
||||
)
|
||||
# Establish the realtime EOF independently even while history owns
|
||||
# this source. Otherwise the P1 job starts from the P2 checkpoint
|
||||
# (or from zero) and becomes a second full-history scan.
|
||||
adapter = self.import_supervisor.adapters[source_id]
|
||||
live_cursors = await self._live_cursors(source_id, adapter)
|
||||
# A bounded onboarding history is the authority for everything up
|
||||
# to its EOF. An adapter without a native live baseline has no safe
|
||||
# realtime cursor until that history finishes; starting a sync from
|
||||
# zero here would bypass `history_after` and import the old corpus.
|
||||
if bounded_history_incomplete and not live_cursors:
|
||||
return None
|
||||
await self._live_cursors(source_id, adapter)
|
||||
# Only one scan of a source may be in flight: they share the
|
||||
# cursor, and two readers means every file read twice. With a
|
||||
# catch-up still holding the source there is no free moment to ask
|
||||
|
||||
@@ -1404,12 +1404,11 @@ class DurableWriteQueue:
|
||||
) -> int:
|
||||
"""Re-admit `waiting_for_model` jobs whose wait has elapsed.
|
||||
|
||||
A model outage clears on its own, so transient waiters retry on a timer
|
||||
A model outage clears on its own, so this state is retried on a timer
|
||||
rather than waiting for a grant event like `waiting_for_permission`.
|
||||
`model_profile_not_active` is excluded: only explicit activation can
|
||||
change that condition. Keyed on `updated_at` so repeated calls while a
|
||||
job is still parked do not reset its own clock; the attempt consumed by
|
||||
the parking claim is returned, the same way `readmit_parked` does.
|
||||
Keyed on `updated_at` so repeated calls while a job is still parked do
|
||||
not reset its own clock; the attempt consumed by the parking claim is
|
||||
returned, the same way `readmit_parked` does.
|
||||
"""
|
||||
now = now or datetime.now(UTC)
|
||||
threshold = (now - after).isoformat()
|
||||
@@ -1418,33 +1417,8 @@ class DurableWriteQueue:
|
||||
"UPDATE memory_write_jobs SET state=?, lease_owner=NULL, "
|
||||
"lease_expires_at=NULL, attempts=CASE WHEN attempts>0 "
|
||||
"THEN attempts-1 ELSE 0 END, last_error=NULL, updated_at=? "
|
||||
"WHERE state=? AND updated_at <= ? AND hy_invoked=0 "
|
||||
"AND (last_error IS NULL OR last_error<>?)",
|
||||
(
|
||||
PENDING,
|
||||
now.isoformat(),
|
||||
WAITING_FOR_MODEL,
|
||||
threshold,
|
||||
"model_profile_not_active",
|
||||
),
|
||||
)
|
||||
return changed.rowcount
|
||||
|
||||
def readmit_model_profile_waiters(self) -> int:
|
||||
"""Re-admit no-profile work only after model activation changes the condition."""
|
||||
now = datetime.now(UTC).isoformat()
|
||||
with main_database(self.database_path).write() as connection, connection:
|
||||
changed = connection.execute(
|
||||
"UPDATE memory_write_jobs SET state=?, lease_owner=NULL, "
|
||||
"lease_expires_at=NULL, attempts=CASE WHEN attempts>0 "
|
||||
"THEN attempts-1 ELSE 0 END, last_error=NULL, updated_at=? "
|
||||
"WHERE state=? AND last_error=? AND hy_invoked=0",
|
||||
(
|
||||
PENDING,
|
||||
now,
|
||||
WAITING_FOR_MODEL,
|
||||
"model_profile_not_active",
|
||||
),
|
||||
"WHERE state=? AND updated_at <= ? AND hy_invoked=0",
|
||||
(PENDING, now.isoformat(), WAITING_FOR_MODEL, threshold),
|
||||
)
|
||||
return changed.rowcount
|
||||
|
||||
@@ -2097,12 +2071,7 @@ async def execute_job(
|
||||
# Never `logger.exception` here: it prints the exception's message,
|
||||
# which may be a provider response or the user's own text. Log the
|
||||
# already-sanitised code instead, matching `hy-failure-classification`.
|
||||
if code == "model_profile_not_active":
|
||||
logger.debug("durable_write_job_parked job_id=%s error_code=%s", job_id, code)
|
||||
elif state in PARKED_STATES:
|
||||
logger.info("durable_write_job_parked job_id=%s error_code=%s", job_id, code)
|
||||
else:
|
||||
logger.error("durable_write_job_failed job_id=%s error_code=%s", job_id, code)
|
||||
logger.error("durable_write_job_failed job_id=%s error_code=%s", job_id, code)
|
||||
return
|
||||
await in_the_background(queue.settle, job_id, state=COMPLETED, owner=owner)
|
||||
|
||||
|
||||
@@ -94,67 +94,6 @@ class SettledFixtureAdapter(IncrementalFixtureAdapter):
|
||||
}
|
||||
|
||||
|
||||
class BoundedOnboardingAdapter:
|
||||
"""No native baseline: bounded history must establish the live cursor."""
|
||||
|
||||
descriptor = IncrementalFixtureAdapter.descriptor
|
||||
|
||||
def __init__(self) -> None:
|
||||
self.include_new = False
|
||||
|
||||
async def detect(self) -> bool:
|
||||
return True
|
||||
|
||||
async def scan(self, options: ScanOptions):
|
||||
current = options.cursors.get("e" * 64)
|
||||
checkpoint = current.checkpoint if current is not None else None
|
||||
if checkpoint not in {"byte:10", "byte:20"}:
|
||||
yield ScannedEvidence(
|
||||
evidence=EvidenceInput(
|
||||
source_id="fixture",
|
||||
conversation_id="old-conversation",
|
||||
message_id="old-message",
|
||||
role="user",
|
||||
written_by=WrittenBy.PERSON,
|
||||
created_at=datetime(2020, 1, 1, tzinfo=UTC),
|
||||
verbatim="old history",
|
||||
origin_meta={EvidenceOrigin.FILE: OriginMetadata()},
|
||||
),
|
||||
cursor=SourceCursor(
|
||||
file_key="e" * 64,
|
||||
file_identity="bounded",
|
||||
checkpoint="byte:10",
|
||||
checkpoint_anchor="f" * 64,
|
||||
observed_size=20,
|
||||
observed_modified_ns=1,
|
||||
),
|
||||
)
|
||||
checkpoint = "byte:10"
|
||||
if self.include_new and checkpoint != "byte:20":
|
||||
yield ScannedEvidence(
|
||||
evidence=EvidenceInput(
|
||||
source_id="fixture",
|
||||
conversation_id="new-conversation",
|
||||
message_id="new-message",
|
||||
role="user",
|
||||
written_by=WrittenBy.PERSON,
|
||||
created_at=datetime(2026, 10, 18, tzinfo=UTC),
|
||||
verbatim="new append",
|
||||
origin_meta={EvidenceOrigin.FILE: OriginMetadata()},
|
||||
),
|
||||
cursor=SourceCursor(
|
||||
file_key="e" * 64,
|
||||
file_identity="bounded",
|
||||
checkpoint="byte:20",
|
||||
checkpoint_anchor="1" * 64,
|
||||
observed_size=20,
|
||||
observed_modified_ns=2,
|
||||
read_to_end=True,
|
||||
),
|
||||
)
|
||||
yield ScanProgressEvent(phase=ScanPhase.DONE)
|
||||
|
||||
|
||||
class HoldingHistoryAdapter:
|
||||
"""First scan blocks until cancelled; later scans yield one new record."""
|
||||
|
||||
@@ -389,56 +328,6 @@ def test_live_sync_does_not_resume_a_history_import_the_user_paused(tmp_path) ->
|
||||
asyncio.run(scenario())
|
||||
|
||||
|
||||
def test_bounded_onboarding_history_establishes_cursor_before_first_sync(tmp_path) -> None:
|
||||
async def scenario() -> None:
|
||||
database = tmp_path / "one-memory.sqlite3"
|
||||
evidence = EvidenceStore(database)
|
||||
jobs = ImportJobStore(database)
|
||||
adapter = BoundedOnboardingAdapter()
|
||||
imports = ImportSupervisor(
|
||||
adapters={"fixture": adapter},
|
||||
evidence_store=evidence,
|
||||
job_store=jobs,
|
||||
)
|
||||
live_sync = PollingLiveSyncSupervisor(
|
||||
source_ids=("fixture",),
|
||||
import_supervisor=imports,
|
||||
job_store=jobs,
|
||||
)
|
||||
history = imports.create_job("fixture", operation="history")
|
||||
with sqlite3.connect(database) as connection, connection:
|
||||
connection.execute(
|
||||
"UPDATE import_jobs SET history_after=? WHERE job_id=?",
|
||||
(datetime(2026, 10, 15, tzinfo=UTC).isoformat(), history.job_id),
|
||||
)
|
||||
|
||||
# With no native baseline, sync must wait instead of scanning from zero
|
||||
# and bypassing the onboarding history window.
|
||||
assert await live_sync.trigger("fixture") is None
|
||||
assert not [job for job in jobs.list_jobs() if job.operation == "sync"]
|
||||
|
||||
imports.enqueue(history.job_id)
|
||||
completed = await imports.wait(history.job_id)
|
||||
assert completed.status is ImportStatus.COMPLETED
|
||||
assert completed.stored == 0
|
||||
assert completed.skipped == 1
|
||||
assert evidence.count() == 0
|
||||
|
||||
# Once bounded history has advanced the cursor to EOF, normal realtime
|
||||
# appends are admitted without retaining the three-day cutoff forever.
|
||||
adapter.include_new = True
|
||||
synced = await live_sync.trigger("fixture")
|
||||
assert synced is not None
|
||||
assert synced.status is ImportStatus.COMPLETED
|
||||
assert synced.stored == 1
|
||||
records = evidence.list_extractable()
|
||||
assert [record.verbatim for record in records] == ["new append"]
|
||||
await live_sync.shutdown()
|
||||
await imports.shutdown()
|
||||
|
||||
asyncio.run(scenario())
|
||||
|
||||
|
||||
def test_a_sync_left_running_after_its_worker_died_is_started_again(tmp_path) -> None:
|
||||
"""A running row with no live worker used to be returned as 'already syncing'.
|
||||
|
||||
|
||||
@@ -5,7 +5,6 @@ from __future__ import annotations
|
||||
import asyncio
|
||||
import contextlib
|
||||
import json
|
||||
import logging
|
||||
import sqlite3
|
||||
import threading
|
||||
import time
|
||||
@@ -1370,30 +1369,24 @@ def test_a_machine_error_code_is_preserved(tmp_path) -> None:
|
||||
assert safe_error_code(ValueError("some sentence with spaces")) == "ValueError"
|
||||
|
||||
|
||||
def test_an_inactive_profile_parks_without_error_logging(tmp_path, caplog) -> None:
|
||||
def test_an_inactive_profile_parks_for_model_before_any_send(tmp_path) -> None:
|
||||
"""A pre-bridge model-not-ready code parks and keeps the intent."""
|
||||
path = tmp_path / "one-memory.sqlite3"
|
||||
queue = DurableWriteQueue(path)
|
||||
record = queue.submit(_request())
|
||||
_claim_through_drain(queue, record.job_id)
|
||||
|
||||
with caplog.at_level(
|
||||
logging.DEBUG,
|
||||
logger="one_memory.memory_server.durable_queue",
|
||||
):
|
||||
asyncio.run(
|
||||
execute_job(
|
||||
record.job_id,
|
||||
queue=queue,
|
||||
supervisor=_Recorder(error=RuntimeError("model_profile_not_active")),
|
||||
evidence=_EvidenceStore({"e1"}),
|
||||
)
|
||||
asyncio.run(
|
||||
execute_job(
|
||||
record.job_id,
|
||||
queue=queue,
|
||||
supervisor=_Recorder(error=RuntimeError("model_profile_not_active")),
|
||||
evidence=_EvidenceStore({"e1"}),
|
||||
)
|
||||
)
|
||||
|
||||
assert _state(path, record.job_id) == WAITING_FOR_MODEL
|
||||
assert _rows(path)[0]["hy_invoked"] == 0
|
||||
assert "durable_write_job_failed" not in caplog.text
|
||||
assert "durable_write_job_parked" in caplog.text
|
||||
|
||||
|
||||
def test_an_authorization_stop_cancels_instead_of_failing(tmp_path) -> None:
|
||||
@@ -1553,7 +1546,7 @@ def test_parking_does_not_burn_the_retry_budget(tmp_path) -> None:
|
||||
assert queue.get("idem-1").state == PENDING
|
||||
|
||||
|
||||
def test_a_transient_model_waiter_is_readmitted_once_its_wait_elapses(tmp_path) -> None:
|
||||
def test_a_model_waiter_is_readmitted_once_its_wait_elapses(tmp_path) -> None:
|
||||
"""A model outage clears on its own; nothing re-admits it otherwise.
|
||||
|
||||
`waiting_for_permission` needs a grant event, but `waiting_for_model` is
|
||||
@@ -1571,7 +1564,7 @@ def test_a_transient_model_waiter_is_readmitted_once_its_wait_elapses(tmp_path)
|
||||
queue.settle(
|
||||
record.job_id,
|
||||
state=WAITING_FOR_MODEL,
|
||||
error_code="model_secret_unavailable",
|
||||
error_code="model_profile_not_active",
|
||||
hy_invoked=False,
|
||||
)
|
||||
|
||||
@@ -1588,8 +1581,8 @@ def test_a_transient_model_waiter_is_readmitted_once_its_wait_elapses(tmp_path)
|
||||
assert _rows(path)[0]["attempts"] == 0
|
||||
|
||||
|
||||
def test_no_profile_waiter_is_readmitted_only_after_activation(tmp_path) -> None:
|
||||
"""No timer can create a model profile; activation owns this transition."""
|
||||
def test_readmitting_a_model_waiter_clears_its_stale_detail(tmp_path) -> None:
|
||||
"""The timer readmission path must clear the old blocking reason too."""
|
||||
path = tmp_path / "one-memory.sqlite3"
|
||||
queue = DurableWriteQueue(path)
|
||||
record = queue.submit(_request())
|
||||
@@ -1601,50 +1594,13 @@ def test_no_profile_waiter_is_readmitted_only_after_activation(tmp_path) -> None
|
||||
)
|
||||
now = datetime.now(UTC)
|
||||
|
||||
assert queue.readmit_model_waiters(now=now + timedelta(minutes=2)) == 0
|
||||
parked = queue.get("idem-1")
|
||||
assert parked.state == WAITING_FOR_MODEL
|
||||
assert parked.detail == "model_profile_not_active"
|
||||
assert queue.readmit_model_waiters(now=now + timedelta(minutes=2)) == 1
|
||||
|
||||
assert queue.readmit_model_profile_waiters() == 1
|
||||
readmitted = queue.get("idem-1")
|
||||
assert readmitted.state == PENDING
|
||||
assert readmitted.detail == ""
|
||||
|
||||
|
||||
def test_no_profile_backlog_stays_parked_across_maintenance_cycles(tmp_path) -> None:
|
||||
path = tmp_path / "one-memory.sqlite3"
|
||||
queue = DurableWriteQueue(path)
|
||||
for index in range(100):
|
||||
record = queue.submit(_request(key=f"no-profile-{index}"))
|
||||
queue.settle(
|
||||
record.job_id,
|
||||
state=WAITING_FOR_MODEL,
|
||||
error_code="model_profile_not_active",
|
||||
hy_invoked=False,
|
||||
)
|
||||
with sqlite3.connect(path) as connection, connection:
|
||||
connection.execute(
|
||||
"UPDATE memory_write_jobs SET attempts=1,"
|
||||
"updated_at='2000-01-01T00:00:00+00:00'"
|
||||
)
|
||||
|
||||
first = datetime.now(UTC)
|
||||
assert queue.readmit_model_waiters(now=first, after=timedelta(0)) == 0
|
||||
assert (
|
||||
queue.readmit_model_waiters(
|
||||
now=first + timedelta(minutes=2),
|
||||
after=timedelta(0),
|
||||
)
|
||||
== 0
|
||||
)
|
||||
with sqlite3.connect(path) as connection:
|
||||
assert connection.execute(
|
||||
"SELECT state,attempts,COUNT(*) FROM memory_write_jobs "
|
||||
"GROUP BY state,attempts"
|
||||
).fetchall() == [(WAITING_FOR_MODEL, 1, 100)]
|
||||
|
||||
|
||||
def test_readmit_parked_cannot_resurrect_a_terminal_row(tmp_path) -> None:
|
||||
"""Only parked states may be re-admitted, never a finished write.
|
||||
|
||||
|
||||
@@ -259,17 +259,12 @@ def test_activation_survives_a_corpus_already_imported(tmp_path) -> None:
|
||||
),
|
||||
secrets=secrets,
|
||||
)
|
||||
readmissions: list[bool] = []
|
||||
app.state.memory_write_queue.readmit_model_profile_waiters = (
|
||||
lambda: readmissions.append(True) or 0
|
||||
)
|
||||
|
||||
activated = await client.post(f"/api/v1/model-profiles/{profile_id}/activate")
|
||||
|
||||
assert activated.status_code == 200
|
||||
assert activated.json()["is_active"] is True
|
||||
assert API_KEY not in activated.text
|
||||
assert readmissions == [True]
|
||||
# Started, not waited for: the walk reads every conversation.
|
||||
assert not app.state.memory_backfill.done()
|
||||
|
||||
|
||||
@@ -111,9 +111,6 @@ def test_the_manual_source_is_admitted_by_grant_without_a_toggle(tmp_path) -> No
|
||||
app = SimpleNamespace(
|
||||
state=SimpleNamespace(
|
||||
config_store=config,
|
||||
model_profile_store=SimpleNamespace(
|
||||
active_identity=lambda: ("profile", 1)
|
||||
),
|
||||
source_grant_store=SimpleNamespace(
|
||||
receipt=lambda **_kwargs: SimpleNamespace(revoked_at=None)
|
||||
),
|
||||
@@ -128,21 +125,6 @@ def test_the_manual_source_is_admitted_by_grant_without_a_toggle(tmp_path) -> No
|
||||
assert _sedimentation_claim_enabled(app, "one-memory-manual") is False
|
||||
|
||||
|
||||
def test_claim_gate_waits_without_polling_when_no_model_is_active(tmp_path) -> None:
|
||||
config = ConfigStore(tmp_path)
|
||||
app = SimpleNamespace(
|
||||
state=SimpleNamespace(
|
||||
config_store=config,
|
||||
model_profile_store=SimpleNamespace(active_identity=lambda: None),
|
||||
source_grant_store=SimpleNamespace(
|
||||
receipt=lambda **_kwargs: SimpleNamespace(revoked_at=None)
|
||||
),
|
||||
)
|
||||
)
|
||||
|
||||
assert _sedimentation_claim_enabled(app, "one-memory-manual") is False
|
||||
|
||||
|
||||
def test_first_import_gate_waits_for_every_selected_history_job() -> None:
|
||||
state = SimpleNamespace(
|
||||
status="importing",
|
||||
|
||||
@@ -174,20 +174,20 @@ it("continues to step3 while sample GET is pending without calling completion AP
|
||||
expect(screen.queryByText(/^已生成.*记忆/)).toBeNull();
|
||||
});
|
||||
it.each([
|
||||
["pending", "已扫描 100 条,正在筛选"],
|
||||
["paused", "已扫描 100 条,尚未保存"],
|
||||
["interrupted", "已扫描 100 条,尚未保存"],
|
||||
["failed", "已扫描 100 条,尚未保存"],
|
||||
["cancelled", "已扫描 100 条,尚未保存"],
|
||||
["completed", "已扫描 100 条,本次没有可保存的对话"],
|
||||
"pending",
|
||||
"paused",
|
||||
"interrupted",
|
||||
"failed",
|
||||
"cancelled",
|
||||
"completed",
|
||||
] as const)(
|
||||
"shows %s without mutating jobs or fetching missing content",
|
||||
(status, expected) => {
|
||||
(status) => {
|
||||
setup({
|
||||
jobs: [{ ...job, status, stored: 0 }],
|
||||
sources: [{ ...source, imported_messages: 0 }],
|
||||
});
|
||||
expect(screen.getByText(expected)).toBeTruthy();
|
||||
expect(screen.getByText("已扫描 100 条,正在保存")).toBeTruthy();
|
||||
expect(api.receipt).not.toHaveBeenCalled();
|
||||
},
|
||||
);
|
||||
|
||||
@@ -38,71 +38,6 @@ it("uses the selected job's message count, never the source total or fake percen
|
||||
expect(container.textContent).not.toContain("%");
|
||||
expect(container.textContent).not.toContain("持续导入中");
|
||||
});
|
||||
|
||||
it("does not describe scanned-but-skipped history as saved", () => {
|
||||
const { container } = render(
|
||||
<R6ImportProgress
|
||||
sourceIds={["cursor"]}
|
||||
sources={[source]}
|
||||
jobs={[
|
||||
{
|
||||
...job,
|
||||
status: "completed",
|
||||
discovered: 12_375,
|
||||
stored: 0,
|
||||
skipped: 12_375,
|
||||
history_after: "2026-10-15T00:00:00Z",
|
||||
},
|
||||
]}
|
||||
/>,
|
||||
);
|
||||
expect(
|
||||
screen.getByText("已扫描 12,375 条,本次没有符合时间范围的对话"),
|
||||
).toBeTruthy();
|
||||
expect(screen.getByText("导入完成")).toBeTruthy();
|
||||
expect(container.textContent).not.toContain("正在保存");
|
||||
expect(container.textContent).not.toContain("已保存 12,375");
|
||||
});
|
||||
|
||||
it("calls an active bounded scan filtering rather than saving", () => {
|
||||
render(
|
||||
<R6ImportProgress
|
||||
sourceIds={["cursor"]}
|
||||
sources={[source]}
|
||||
jobs={[
|
||||
{
|
||||
...job,
|
||||
discovered: 3_522,
|
||||
stored: 0,
|
||||
history_after: "2026-10-15T00:00:00Z",
|
||||
},
|
||||
]}
|
||||
/>,
|
||||
);
|
||||
expect(screen.getByText("已扫描 3,522 条,正在筛选最近 3 天")).toBeTruthy();
|
||||
});
|
||||
|
||||
it("reports only stored rows as saved after a completed partial import", () => {
|
||||
const { container } = render(
|
||||
<R6ImportProgress
|
||||
sourceIds={["cursor"]}
|
||||
sources={[source]}
|
||||
jobs={[
|
||||
{
|
||||
...job,
|
||||
status: "completed",
|
||||
discovered: 25_177,
|
||||
stored: 20_826,
|
||||
skipped: 4_351,
|
||||
},
|
||||
]}
|
||||
/>,
|
||||
);
|
||||
expect(screen.getByText("已保存 20,826 条消息")).toBeTruthy();
|
||||
expect(screen.getByText("导入完成")).toBeTruthy();
|
||||
expect(container.textContent).not.toContain("25,177");
|
||||
});
|
||||
|
||||
it.each(["paused", "interrupted", "failed", "completed", "cancelled"] as const)(
|
||||
"does not animate %s as active work",
|
||||
(status) => {
|
||||
|
||||
@@ -3,21 +3,6 @@ import { useEffect, useLayoutEffect, useRef } from "react";
|
||||
import { AppMark } from "./app-mark";
|
||||
import "./r6-import-progress.css";
|
||||
|
||||
function progressText(job: ImportJob): string | null {
|
||||
if (job.stored > 0) return `已保存 ${job.stored.toLocaleString()} 条消息`;
|
||||
if (job.discovered <= 0) return null;
|
||||
const scanned = `已扫描 ${job.discovered.toLocaleString()} 条`;
|
||||
if (job.status === "completed")
|
||||
return job.history_after
|
||||
? `${scanned},本次没有符合时间范围的对话`
|
||||
: `${scanned},本次没有可保存的对话`;
|
||||
if (job.status === "pending" || job.status === "running")
|
||||
return job.history_after
|
||||
? `${scanned},正在筛选最近 3 天`
|
||||
: `${scanned},正在筛选`;
|
||||
return `${scanned},尚未保存`;
|
||||
}
|
||||
|
||||
export function R6ImportProgress({
|
||||
sourceIds,
|
||||
sources,
|
||||
@@ -101,9 +86,8 @@ export function R6ImportProgress({
|
||||
interrupted: "已中断",
|
||||
failed: "导入遇到问题",
|
||||
cancelled: "已取消",
|
||||
completed: "导入完成",
|
||||
completed: "已保存",
|
||||
};
|
||||
const progress = job ? progressText(job) : null;
|
||||
return (
|
||||
<article
|
||||
className="r6-import-progress-card"
|
||||
@@ -118,8 +102,10 @@ export function R6ImportProgress({
|
||||
</div>
|
||||
</div>
|
||||
<h2>{source?.display_name ?? id}</h2>
|
||||
{progress ? (
|
||||
<p>{progress}</p>
|
||||
{job && job.stored > 0 ? (
|
||||
<p>{`已保存 ${job.stored.toLocaleString()} 条消息`}</p>
|
||||
) : job && job.discovered > 0 ? (
|
||||
<p>{`已扫描 ${job.discovered.toLocaleString()} 条,正在保存`}</p>
|
||||
) : noImportedHistory ? (
|
||||
<p>没有可保存的历史对话</p>
|
||||
) : null}
|
||||
|
||||
@@ -312,48 +312,6 @@ describe("remote Runtime acquisition", () => {
|
||||
});
|
||||
});
|
||||
|
||||
it("allows the exact GitHub Release asset redirect hosts by default", async () => {
|
||||
const value = await fixture();
|
||||
const manifestUrl =
|
||||
"https://github.com/example/project/releases/download/v1.2.3/release-manifest.json";
|
||||
const artifactUrl =
|
||||
"https://github.com/example/project/releases/download/v1.2.3/runtime.tar.gz";
|
||||
const manifestAssetUrl =
|
||||
"https://release-assets.githubusercontent.com/manifest-download";
|
||||
const runtimeAssetUrl =
|
||||
"https://release-assets.githubusercontent.com/runtime-download";
|
||||
const release = {
|
||||
...value.release,
|
||||
artifacts: [{ ...value.artifact, url: artifactUrl }],
|
||||
};
|
||||
const temporaryParent = await mkdtemp(
|
||||
path.join(os.tmpdir(), "memhub-github-release-parent-"),
|
||||
);
|
||||
roots.push(temporaryParent);
|
||||
|
||||
const acquired = await acquireRuntimeDistribution({
|
||||
manifestUrl,
|
||||
expectedVersion: "1.2.3",
|
||||
channel: "stable",
|
||||
platform: "linux",
|
||||
arch: "x64",
|
||||
temporaryParent,
|
||||
httpGet: httpFixture({
|
||||
[manifestUrl]: response(new Uint8Array(), 302, {
|
||||
location: manifestAssetUrl,
|
||||
}),
|
||||
[manifestAssetUrl]: response(Buffer.from(JSON.stringify(release))),
|
||||
[artifactUrl]: response(new Uint8Array(), 302, {
|
||||
location: runtimeAssetUrl,
|
||||
}),
|
||||
[runtimeAssetUrl]: response(value.archiveBytes),
|
||||
}),
|
||||
});
|
||||
|
||||
expect(acquired.release.version).toBe("1.2.3");
|
||||
await acquired.cleanup();
|
||||
});
|
||||
|
||||
it("rejects an archive whose internal commit drifts from the Release", async () => {
|
||||
const value = await fixture();
|
||||
const release = { ...value.release, git_commit: "b".repeat(40) };
|
||||
@@ -410,6 +368,7 @@ describe("remote Runtime acquisition", () => {
|
||||
);
|
||||
|
||||
it("rejects a redirect to an untrusted host", async () => {
|
||||
const value = await fixture();
|
||||
await expect(
|
||||
acquireRuntimeDistribution({
|
||||
manifestUrl: "https://downloads.example.test/release-manifest.json",
|
||||
|
||||
@@ -38,10 +38,6 @@ const SHA256 = /^[a-f0-9]{64}$/;
|
||||
const COMMIT = /^[0-9a-f]{40}$/;
|
||||
const SAFE_FILE_NAME = /^[0-9A-Za-z][0-9A-Za-z._-]{0,255}$/;
|
||||
const REDIRECTS = new Set([301, 302, 303, 307, 308]);
|
||||
const GITHUB_RELEASE_REDIRECT_HOSTS = [
|
||||
"release-assets.githubusercontent.com",
|
||||
"objects.githubusercontent.com",
|
||||
] as const;
|
||||
|
||||
export type ReleaseChannel = "stable" | "prerelease";
|
||||
export type RuntimeReleasePlatform = "darwin" | "linux";
|
||||
@@ -334,12 +330,8 @@ export async function resolveRuntimeRelease(options: {
|
||||
maxRedirects?: number;
|
||||
}): Promise<ResolvedRuntimeRelease> {
|
||||
const manifestUrl = trustedUrl(options.manifestUrl);
|
||||
const defaultHosts =
|
||||
normalizedHost(manifestUrl.hostname) === "github.com"
|
||||
? [manifestUrl.hostname, ...GITHUB_RELEASE_REDIRECT_HOSTS]
|
||||
: [manifestUrl.hostname];
|
||||
const allowedHosts = new Set(
|
||||
(options.allowedHosts ?? defaultHosts).map(normalizedHost),
|
||||
(options.allowedHosts ?? [manifestUrl.hostname]).map(normalizedHost),
|
||||
);
|
||||
if (!allowedHosts.has(normalizedHost(manifestUrl.hostname))) {
|
||||
throw new Error("release_url_untrusted");
|
||||
|
||||
@@ -8,7 +8,6 @@ import {
|
||||
mkdir,
|
||||
readdir,
|
||||
readFile,
|
||||
readlink,
|
||||
realpath,
|
||||
rm,
|
||||
stat,
|
||||
@@ -321,12 +320,6 @@ async function distributionBytes(source) {
|
||||
|
||||
async function sha256File(file) {
|
||||
const digest = createHash("sha256");
|
||||
const info = await lstat(file);
|
||||
if (info.isSymbolicLink()) {
|
||||
digest.update("symlink\0");
|
||||
digest.update(await readlink(file));
|
||||
return digest.digest("hex");
|
||||
}
|
||||
for await (const chunk of createReadStream(file)) digest.update(chunk);
|
||||
return digest.digest("hex");
|
||||
}
|
||||
|
||||
@@ -6,7 +6,6 @@ import {
|
||||
readdir,
|
||||
readFile,
|
||||
rm,
|
||||
symlink,
|
||||
writeFile,
|
||||
} from "node:fs/promises";
|
||||
import os from "node:os";
|
||||
@@ -122,35 +121,6 @@ test("builds versioned Runtime archives, checksums and a downloader-compatible m
|
||||
}
|
||||
});
|
||||
|
||||
test("verifies Runtime symlinks with the same identity rule as the installer", async (t) => {
|
||||
const root = await mkdtemp(path.join(os.tmpdir(), "memhub-release-assets-"));
|
||||
t.after(() => rm(root, { recursive: true, force: true }));
|
||||
const source = await distribution(root, "linux", "x64");
|
||||
const link = "memhub";
|
||||
await symlink(link, path.join(source, "bin", "node"));
|
||||
const manifestPath = path.join(source, "manifest.json");
|
||||
const manifest = JSON.parse(await readFile(manifestPath, "utf8"));
|
||||
manifest.files["bin/node"] = sha256(`symlink\0${link}`);
|
||||
await writeFile(manifestPath, `${JSON.stringify(manifest, null, 2)}\n`);
|
||||
|
||||
const release = await buildReleaseAssets({
|
||||
output: path.join(root, "release"),
|
||||
version: "0.1.0-internal.1",
|
||||
channel: "prerelease",
|
||||
assetBaseUrl: "https://git.example.test/release/downloads/",
|
||||
distributions: [source],
|
||||
});
|
||||
|
||||
assert.ok(
|
||||
release.artifacts.some(
|
||||
(artifact) =>
|
||||
artifact.kind === "runtime" &&
|
||||
artifact.platform === "linux" &&
|
||||
artifact.arch === "x64",
|
||||
),
|
||||
);
|
||||
});
|
||||
|
||||
test("refuses version, build and duplicate target drift", async (t) => {
|
||||
const root = await mkdtemp(path.join(os.tmpdir(), "memhub-release-assets-"));
|
||||
t.after(() => rm(root, { recursive: true, force: true }));
|
||||
|
||||
@@ -23,8 +23,7 @@ export async function publishCliAfterRelease(options, dependencies = {}) {
|
||||
const statImpl = dependencies.statImpl ?? stat;
|
||||
const buildEnvironment = {
|
||||
...environment,
|
||||
MEMHUB_NPM_VERSION: options.version,
|
||||
MEMHUB_DEFAULT_RUNTIME_VERSION: options.runtimeVersion,
|
||||
ONE_MEMORY_VERSION: options.version,
|
||||
ONE_MEMORY_BUILD_NUMBER: String(options.buildNumber),
|
||||
ONE_MEMORY_COMMIT: options.gitCommit,
|
||||
};
|
||||
@@ -32,7 +31,7 @@ export async function publishCliAfterRelease(options, dependencies = {}) {
|
||||
const versionReceipt = await verifyVersion(
|
||||
[
|
||||
"--version",
|
||||
options.runtimeVersion,
|
||||
options.version,
|
||||
"--channel",
|
||||
options.channel,
|
||||
"--tag",
|
||||
@@ -44,7 +43,7 @@ export async function publishCliAfterRelease(options, dependencies = {}) {
|
||||
);
|
||||
const releaseReceipt = await verifyRelease({
|
||||
manifestUrl: options.manifestUrl,
|
||||
version: options.runtimeVersion,
|
||||
version: options.version,
|
||||
gitCommit: options.gitCommit,
|
||||
channel: options.channel,
|
||||
platform: options.platform,
|
||||
@@ -107,8 +106,7 @@ export async function publishCliAfterRelease(options, dependencies = {}) {
|
||||
return {
|
||||
schema_version: 1,
|
||||
product: "memhub-cli-publication",
|
||||
version: options.version,
|
||||
runtime_version: releaseReceipt.version,
|
||||
version: versionReceipt.version,
|
||||
build_number: versionReceipt.build_number,
|
||||
tag: versionReceipt.tag,
|
||||
release: releaseReceipt,
|
||||
@@ -198,11 +196,6 @@ function parseArgs(arguments_) {
|
||||
options.manifestUrl = value;
|
||||
else if (flag === "--version" && options.version === undefined)
|
||||
options.version = value;
|
||||
else if (
|
||||
flag === "--runtime-version" &&
|
||||
options.runtimeVersion === undefined
|
||||
)
|
||||
options.runtimeVersion = value;
|
||||
else if (flag === "--git-commit" && options.gitCommit === undefined)
|
||||
options.gitCommit = value;
|
||||
else if (flag === "--channel" && options.channel === undefined)
|
||||
@@ -226,7 +219,6 @@ function parseArgs(arguments_) {
|
||||
if (
|
||||
!options.manifestUrl ||
|
||||
!options.version ||
|
||||
!options.runtimeVersion ||
|
||||
!options.gitCommit ||
|
||||
!options.channel ||
|
||||
!options.tag ||
|
||||
@@ -238,7 +230,7 @@ function parseArgs(arguments_) {
|
||||
!options.npmTag
|
||||
) {
|
||||
throw new Error(
|
||||
"usage: publish-cli-after-release.mjs --manifest-url <https-url> --version <CLI SemVer> --runtime-version <Runtime SemVer> --git-commit <sha> --channel <stable|prerelease> --tag <vSemVer> --build-number <integer> --platform <darwin|linux> --arch <x64|arm64> --registry <https-url> --npm-tag <tag> [--access <public|restricted>] [--provenance] [--allowed-host <hostname>] [--dry-run]",
|
||||
"usage: publish-cli-after-release.mjs --manifest-url <https-url> --version <SemVer> --git-commit <sha> --channel <stable|prerelease> --tag <vSemVer> --build-number <integer> --platform <darwin|linux> --arch <x64|arm64> --registry <https-url> --npm-tag <tag> [--access <public|restricted>] [--provenance] [--allowed-host <hostname>] [--dry-run]",
|
||||
);
|
||||
}
|
||||
return options;
|
||||
|
||||
@@ -6,9 +6,6 @@ import test from "node:test";
|
||||
|
||||
import { publishCliAfterRelease } from "./publish-cli-after-release.mjs";
|
||||
|
||||
const CLI_VERSION = "0.1.1";
|
||||
const RUNTIME_VERSION = "0.1.0-internal.1";
|
||||
|
||||
async function fixture(t) {
|
||||
const root = await mkdtemp(path.join(os.tmpdir(), "memhub-publish-gate-"));
|
||||
t.after(() => rm(root, { recursive: true, force: true }));
|
||||
@@ -18,10 +15,10 @@ async function fixture(t) {
|
||||
await mkdir(output, { recursive: true });
|
||||
await writeFile(
|
||||
path.join(publish, "package.json"),
|
||||
`${JSON.stringify({ name: "@example/memhub-cli", version: CLI_VERSION, private: false })}\n`,
|
||||
`${JSON.stringify({ name: "@example/memhub-cli", version: "0.1.0-internal.1", private: false })}\n`,
|
||||
);
|
||||
await writeFile(
|
||||
path.join(output, `example-memhub-cli-${CLI_VERSION}.tgz`),
|
||||
path.join(output, "example-memhub-cli-0.1.0-internal.1.tgz"),
|
||||
"tarball",
|
||||
);
|
||||
return root;
|
||||
@@ -29,11 +26,10 @@ async function fixture(t) {
|
||||
|
||||
const options = {
|
||||
manifestUrl: "https://releases.example.test/release-manifest.json",
|
||||
version: CLI_VERSION,
|
||||
runtimeVersion: RUNTIME_VERSION,
|
||||
version: "0.1.0-internal.1",
|
||||
gitCommit: "a".repeat(40),
|
||||
channel: "prerelease",
|
||||
tag: `v${RUNTIME_VERSION}`,
|
||||
tag: "v0.1.0-internal.1",
|
||||
buildNumber: 1,
|
||||
platform: "linux",
|
||||
arch: "x64",
|
||||
@@ -53,24 +49,15 @@ test("verifies the Runtime before building and publishing the CLI", async (t) =>
|
||||
{
|
||||
verifyVersion: async () => {
|
||||
events.push("version");
|
||||
return {
|
||||
version: options.runtimeVersion,
|
||||
build_number: 1,
|
||||
tag: options.tag,
|
||||
};
|
||||
return { version: options.version, build_number: 1, tag: options.tag };
|
||||
},
|
||||
verifyRelease: async (verifyOptions) => {
|
||||
events.push("release");
|
||||
assert.equal(verifyOptions.gitCommit, options.gitCommit);
|
||||
assert.equal(verifyOptions.version, options.runtimeVersion);
|
||||
return { version: options.runtimeVersion, sha256: "a".repeat(64) };
|
||||
return { version: options.version, sha256: "a".repeat(64) };
|
||||
},
|
||||
runCommand: (command, arguments_, commandOptions) => {
|
||||
assert.equal(commandOptions.env.MEMHUB_NPM_VERSION, options.version);
|
||||
assert.equal(
|
||||
commandOptions.env.MEMHUB_DEFAULT_RUNTIME_VERSION,
|
||||
options.runtimeVersion,
|
||||
);
|
||||
assert.equal(commandOptions.env.ONE_MEMORY_VERSION, options.version);
|
||||
assert.equal(
|
||||
commandOptions.env.ONE_MEMORY_BUILD_NUMBER,
|
||||
String(options.buildNumber),
|
||||
@@ -87,8 +74,6 @@ test("verifies the Runtime before building and publishing the CLI", async (t) =>
|
||||
assert.match(events[3], /--access public/);
|
||||
assert.match(events[3], /--provenance/);
|
||||
assert.equal(receipt.npm_package, "@example/memhub-cli");
|
||||
assert.equal(receipt.version, CLI_VERSION);
|
||||
assert.equal(receipt.runtime_version, RUNTIME_VERSION);
|
||||
assert.equal(receipt.dry_run, true);
|
||||
assert.equal(receipt.npm_access, "public");
|
||||
assert.equal(receipt.provenance, true);
|
||||
@@ -102,7 +87,7 @@ test("never builds or invokes npm when the Runtime release is unavailable", asyn
|
||||
{ ...options, root },
|
||||
{
|
||||
verifyVersion: async () => ({
|
||||
version: options.runtimeVersion,
|
||||
version: options.version,
|
||||
build_number: 1,
|
||||
tag: options.tag,
|
||||
}),
|
||||
@@ -130,11 +115,11 @@ test("rejects a package identity that differs from the configured publish name",
|
||||
},
|
||||
{
|
||||
verifyVersion: async () => ({
|
||||
version: options.runtimeVersion,
|
||||
version: options.version,
|
||||
build_number: 1,
|
||||
tag: options.tag,
|
||||
}),
|
||||
verifyRelease: async () => ({ version: options.runtimeVersion }),
|
||||
verifyRelease: async () => ({ version: options.version }),
|
||||
runCommand: () => undefined,
|
||||
},
|
||||
),
|
||||
|
||||
@@ -12,9 +12,8 @@ L1_RAW 对外读路径不可见:闸门焊在向量库唯一查询入口。
|
||||
hybrid_search_native / list_by_user 四个查询方法的共同必经处,
|
||||
上层无法绕开,新增接口自动受保护。
|
||||
|
||||
按 ID 直取走主键查询、不经过 filter,故在 `client.async_get` 处默认挡住。
|
||||
Python SDK 调用方可显式传 `include_raw=True` 读取;HTTP 接口不传该参数,
|
||||
继续保持原有隐藏行为。
|
||||
按 ID 直取走主键查询、不经过 filter,故在 `client.async_get` 处单独挡
|
||||
(GET /v1/memories/{id} 与 /v1/memories/batch 的共同必经处)。
|
||||
|
||||
内部管线需读原始对话时显式 allow_raw_layer=True 放行:
|
||||
- `writer._get_recent_dialogue_history`:提取前拼多轮对话历史喂 LLM
|
||||
@@ -125,63 +124,28 @@ class TestListNoLongerFiltersInProcess:
|
||||
"list 又出现进程内过滤 raw 的写法,应交由 vdb 侧 filter 完成"
|
||||
|
||||
|
||||
class TestGetByIdRawOptIn:
|
||||
"""按 ID 直取默认隐藏 raw,只有 SDK 显式选择时才返回。"""
|
||||
class TestGetByIdPathBlocksRaw:
|
||||
"""按 ID 直取:走主键查询不经 filter,须在 async_get 处挡住。"""
|
||||
|
||||
@staticmethod
|
||||
def _client(node):
|
||||
from hy_memory.client import HyMemoryClient
|
||||
|
||||
class Store:
|
||||
async def get_by_id(self, memory_id):
|
||||
return node if memory_id == node.node_id else None
|
||||
|
||||
async def no_op_log(**kwargs):
|
||||
return None
|
||||
|
||||
client = HyMemoryClient.__new__(HyMemoryClient)
|
||||
client._vector_store = Store()
|
||||
client._graph_store = None
|
||||
client._backend_identity = lambda: {}
|
||||
client._maybe_store_op_log = no_op_log
|
||||
return client
|
||||
|
||||
@staticmethod
|
||||
def _raw_node():
|
||||
return MemoryNode(
|
||||
node_id="raw-1",
|
||||
user_id="u1",
|
||||
agent_id="a1",
|
||||
session_id="s1",
|
||||
content="原始对话",
|
||||
layer=MemoryLayer.L1_RAW,
|
||||
status=MemoryStatus.ACTIVE,
|
||||
custom={"meta": [{"source": "test"}]},
|
||||
)
|
||||
|
||||
def test_default_still_hides_raw(self):
|
||||
from hy_memory.client import HyMemoryClient
|
||||
|
||||
client = self._client(self._raw_node())
|
||||
raw_get = HyMemoryClient.async_get.__wrapped__
|
||||
assert run(raw_get(client, "raw-1")) is None
|
||||
|
||||
def test_explicit_include_raw_returns_lite_memory(self):
|
||||
from hy_memory.client import HyMemoryClient
|
||||
|
||||
client = self._client(self._raw_node())
|
||||
raw_get = HyMemoryClient.async_get.__wrapped__
|
||||
result = run(raw_get(client, "raw-1", include_raw=True))
|
||||
|
||||
assert result["memory_id"] == "raw-1"
|
||||
assert result["content"] == "原始对话"
|
||||
assert result["layer"] == MemoryLayer.L1_RAW.value
|
||||
assert result["meta"] == [{"source": "test"}]
|
||||
|
||||
def test_public_get_exposes_keyword_only_opt_in(self):
|
||||
def test_async_get_drops_raw_node(self):
|
||||
import inspect
|
||||
from hy_memory.client import HyMemoryClient
|
||||
src = inspect.getsource(HyMemoryClient.async_get)
|
||||
assert "MemoryLayer.L1_RAW" in src, \
|
||||
"async_get 未挡 raw,batch 与按 ID 单取会漏出原始对话"
|
||||
|
||||
param = inspect.signature(HyMemoryClient.get).parameters["include_raw"]
|
||||
assert param.default is False
|
||||
assert param.kind is inspect.Parameter.KEYWORD_ONLY
|
||||
def test_block_happens_before_return(self):
|
||||
"""挡的位置必须在两处 return 之前,否则等于没挡。"""
|
||||
import inspect
|
||||
from hy_memory.client import HyMemoryClient
|
||||
lines = inspect.getsource(HyMemoryClient.async_get).splitlines()
|
||||
block_at = next(
|
||||
i for i, l in enumerate(lines)
|
||||
if "MemoryLayer.L1_RAW" in l and "vdb_node" in l
|
||||
)
|
||||
first_return_at = next(
|
||||
i for i, l in enumerate(lines)
|
||||
if l.strip().startswith("return {")
|
||||
)
|
||||
assert block_at < first_return_at, \
|
||||
f"挡 raw 在第 {block_at} 行、首个 return 在第 {first_return_at} 行,顺序错了"
|
||||
Reference in new issue
Block a user