Compare commits

..
Author SHA1 Message Date
feitianxiang 988667cd87 Merge pull request #224 from Tencent-Hunyuan/chore/build-36-docs
chore(release): bump MemHub build number to 36 for v1.0.5
2026-10-28 22:07:22 +08:00
alvinfeiandClaude Opus 5.5 72968f831f chore(release): bump MemHub build number to 36
The macOS v1.0.5 artifacts were built from release/memhub-build-36-main
(dbba403): main's code at a621262 with build 36. Main moves to the same
build so the Linux Runtimes built from the v1.0.5 tag match them. The
release docs name build 36.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-10-28 22:02:27 +08:00
feitianxiang af25f51f9e Merge pull request #223 from Tencent-Hunyuan/docs/release-1.0.5
docs: prepare MemHub v1.0.5 (news, build 35, macOS arm64 only)
2026-10-28 21:59:59 +08:00
alvinfeiandClaude Opus 5.5 b3f13e130f docs: MemHub 1.0.5 news, build 35, macOS arm64 only
The README news line announces v1.0.5 briefly; details live in the release
notes. The release facts said build 14 and promised a macOS Intel build that
this version does not ship. CHANGELOG's Unreleased section becomes MemHub
1.0.5.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-10-28 21:58:28 +08:00
kristinhe1111 a621262fac Merge pull request #222 from Tencent-Hunyuan/fix/memory-detail-structure-and-stability
fix(memhub): stabilize memory detail navigation
2026-10-28 20:44:13 +08:00
kristinhe e027635d3f Merge remote-tracking branch 'origin/main' into fix/memory-detail-structure-and-stability
Co-authored-by: Cursor <cursoragent@cursor.com>

# Conflicts:
#	memhub/apps/web/src/MemoryMap.test.tsx
2026-10-28 20:36:06 +08:00
feitianxiang 4cb33b7108 Merge pull request #217 from Tencent-Hunyuan/fix/memory-source-filter-sync
fix(memhub): 同步记忆筛选范围并优化首次启动状态
2026-10-28 20:32:24 +08:00
feitianxiang 5f78d67363 Merge pull request #221 from Tencent-Hunyuan/fix/linux-lifecycle-queue-check
fix(memhub): Linux lifecycle checks the write queue, not recall without a model
2026-10-28 20:17:45 +08:00
kristinheandCursor 68a4b546f9 fix(memhub): stabilize memory detail navigation
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-10-28 20:11:12 +08:00
alvinfeiandClaude Opus 5.5 528d2c9fd7 fix(memhub): Linux lifecycle checks the write queue, not recall without a model
The lifecycle verification imported a conversation on a runner with no
generation model and then expected recall to find it. Under SDK authority
nothing reaches the SDK, not even its raw layer, until a model is active,
so the check could never pass and blocked the Linux release archive.

It now asserts what the runner can prove: the imported and appended turns
reach Core's durable write queue, and survive an idempotent upgrade.
docs/TODO-raw-recall-without-model.md records the product gap.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-10-28 20:05:44 +08:00
17 changed files with 556 additions and 70 deletions

No files matched your search

+6
View File
@@ -4,6 +4,12 @@ Hy Memory and MemHub are versioned and released independently.
## Unreleased
## MemHub 1.0.5 — 2026-10-28
Build 36, on `hy-memory==2.0.8`. Linux x64/arm64 Runtime and macOS arm64
App and Runtime. Upgrading from 1.0.4 migrates the existing library to SDK
authority on first launch.
- MemHub turns off the HY pipeline log and trace at every diagnostic level
(hy-memory 2.0.8 `MEMORY_PIPELINE_LOG_ENABLED=false`,
`MEMORY_PIPELINE_TRACE_ENABLED=false`), so the per-step JSONL files under
+1
View File
@@ -33,6 +33,7 @@ English | [中文](README.zh.md)
## Latest News
* *<mark>[new]</mark>* 🔥🔥🔥 **[2026-10]**: 🚀 **Open-sourced** quantized **FP8** (**[Hy-Memory-30B-A3B-FP8](https://huggingface.co/tencent/Hy-Memory-30B-A3B-FP8)**) and **GGUF** (**[Hy-Memory-30B-A3B-GGUF](https://huggingface.co/tencent/Hy-Memory-30B-A3B-GGUF)**) versions of **Hy-Memory-30B-A3B** on Hugging Face!
* *<mark>[new]</mark>* 🔥🔥🔥 **[2026-10]**: 📦 **Released [MemHub v1.0.5](https://github.com/Tencent-Hunyuan/Hy-Memory/releases/tag/v1.0.5)** with Chinese, English and Spanish interfaces, one-time pairing codes for more custom Harnesses, and smoother browsing, search, startup and recovery, on **`hy-memory==2.0.8`**. See the release notes for details.
* *<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]**: 🚀 **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!
+1
View File
@@ -33,6 +33,7 @@
## 最新动态
* *<mark>[new]</mark>* 🔥🔥🔥 **[2026-10]**: 🚀 **模型开源** 在 Hugging Face 发布 **Hy-Memory-30B-A3B** 的 **FP8**(**[Hy-Memory-30B-A3B-FP8](https://huggingface.co/tencent/Hy-Memory-30B-A3B-FP8)**)与 **GGUF**(**[Hy-Memory-30B-A3B-GGUF](https://huggingface.co/tencent/Hy-Memory-30B-A3B-GGUF)**)量化版本模型!
* *<mark>[new]</mark>* 🔥🔥🔥 **[2026-10]**: 📦 **正式发布 [MemHub v1.0.5](https://github.com/Tencent-Hunyuan/Hy-Memory/releases/tag/v1.0.5)**:支持中文、英文和西班牙语界面,可通过一次性配对码连接更多自定义 Harness,并优化了记忆浏览、搜索、启动和异常恢复;底层使用 **`hy-memory==2.0.8`**。详见发布说明。
* *<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]**: 🚀 **模型开源** 在 Hugging Face 发布 **[Hy-Memory-30B-A3B](https://huggingface.co/tencent/Hy-Memory-30B-A3B)** 长期记忆写入模型及[技术报告](https://arxiv.org/abs/2610.22196)!
+2 -2
View File
@@ -19,12 +19,12 @@ If you run into a problem, please open an Issue with the version, platform,repro
## 发行与平台合同
源码当前固定使用公开 PyPI 正式版 `hy-memory==2.0.8`。MemHub 源码版本为 `1.0.5`
(build 14),GitHub 仓库为
(build 36),GitHub 仓库为
[`Tencent-Hunyuan/Hy-Memory`](https://github.com/Tencent-Hunyuan/Hy-Memory),npm 包名为
`memhub-cli`。当前代码架构见
[`docs/ARCHITECTURE.md`](docs/ARCHITECTURE.md)。
`v1.0.5` 的发行目标是 Linux x64/arm64 Runtime 与 macOS arm64/x64 App 和 Runtime。
`v1.0.5` 的发行目标是 Linux x64/arm64 Runtime 与 macOS arm64 App 和 Runtime。
Linux 制品由 GitHub Actions 在原生架构 runner 上构建并完成生命周期验收;macOS
制品由发行流水线完成 Developer ID 签名、公证和验收后手动上传。Windows 不支持。
只有同时出现在 GitHub Release 与 `release-manifest.json` 中、且通过公开校验的制品才属于
+195 -7
View File
@@ -13,7 +13,6 @@ import {
fireEvent,
render,
screen,
waitFor,
within,
} from "@testing-library/react";
import userEvent from "@testing-library/user-event";
@@ -259,6 +258,40 @@ it("OBS-15: switching memory shows a read in progress instead of the previous me
expect(screen.getByRole("status").textContent).toContain("正在读取这条记忆");
});
it("shows a stable loading surface when the selected memory is read again", async () => {
const view = render(<Host simplified />);
await act(async () => {});
expect(screen.getByRole("heading", { name: "第一条" })).toBeTruthy();
view.rerender(<Host simplified deferMemoryId="m0" />);
await userEvent.click(
within(screen.getByRole("list", { name: "地图记忆列表" })).getByRole(
"button",
{ name: /第一条/ },
),
);
expect(screen.queryByRole("heading", { name: "第一条" })).toBeNull();
expect(screen.getByRole("status").textContent).toContain("正在读取这条记忆");
expect(document.querySelector(".map-detail-loading")).toBeTruthy();
});
it("shows retry instead of stale content when the selected memory reread fails", async () => {
const view = render(<Host simplified />);
await act(async () => {});
expect(screen.getByRole("heading", { name: "第一条" })).toBeTruthy();
view.rerender(<Host simplified failMemoryId="m0" />);
await userEvent.click(
within(screen.getByRole("list", { name: "地图记忆列表" })).getByRole(
"button",
{ name: /第一条/ },
),
);
expect(screen.queryByRole("heading", { name: "第一条" })).toBeNull();
expect(screen.getByRole("alert").textContent).toContain(
"这条记忆的原文暂时无法读取",
);
expect(screen.getByRole("button", { name: "重试读取原文" })).toBeTruthy();
});
it("keeps the detail header outside the scrolling body and resets detail scroll on selection", async () => {
const { container } = render(
<Host simplified sources={[importedSources[0]]} />,
@@ -269,6 +302,14 @@ it("keeps the detail header outside the scrolling body and resets detail scroll
const body = container.querySelector<HTMLElement>(".map-inspector-body");
expect(heading?.nextElementSibling).toBe(body);
if (!body) throw new Error("Memory detail scroll body is missing");
body.scrollTop = 90;
await userEvent.click(
within(screen.getByRole("list", { name: "地图记忆列表" })).getByRole(
"button",
{ name: /第一条/ },
),
);
expect(body.scrollTop).toBe(0);
body.scrollTop = 180;
await userEvent.click(
within(screen.getByRole("list", { name: "地图记忆列表" })).getByRole(
@@ -348,6 +389,7 @@ function Host({
processing = null,
loaded = true,
deferMemoryId,
failMemoryId,
graphValue = graph,
onViewHistory,
onConfigureModel = () => {},
@@ -364,12 +406,17 @@ function Host({
processing?: MemoryProcessingSummary | null;
loaded?: boolean;
deferMemoryId?: string;
failMemoryId?: string;
graphValue?: MemoryGraphResponse;
onViewHistory?(): void;
onConfigureModel?(): void;
libraryView?: Parameters<typeof MemoryMap>[0]["libraryView"];
} = {}) {
const [detail, setDetail] = useState<MemoryDetail | null>(null);
const [detailLoad, setDetailLoad] = useState<{
memoryId: string;
status: "loading" | "failed";
} | null>(null);
return (
<MemoryMap
simplified={simplified}
@@ -391,14 +438,25 @@ function Host({
}
: null
}
detailLoad={detailLoad}
draft={detail?.memory.content ?? ""}
busy={false}
answer={null}
streaming={null}
requestIds={{ current: new Map() }}
onOpenMemory={(id) => {
if (failMemoryId && id === failMemoryId) {
setDetail(null);
setDetailLoad({ memoryId: id, status: "failed" });
return;
}
// Lets a test leave one read unanswered.
if (deferMemoryId && id === deferMemoryId) return;
if (deferMemoryId && id === deferMemoryId) {
setDetail(null);
setDetailLoad({ memoryId: id, status: "loading" });
return;
}
setDetailLoad(null);
setDetail({
memory: items.find((m) => m.memory_id === id) ?? items[0],
evidence: [
@@ -416,7 +474,10 @@ function Host({
index_sync: null,
});
}}
onCloseDetail={() => setDetail(null)}
onCloseDetail={() => {
setDetail(null);
setDetailLoad(null);
}}
onAsk={() => {}}
onDraft={() => {}}
onSave={() => {}}
@@ -745,6 +806,8 @@ it("keeps pagination and current detail stable while the next page is loading",
const libraryView = { ...pagedView(page), onPage };
const view = render(<Host simplified libraryView={libraryView} />);
const list = screen.getByRole("list", { name: "地图记忆列表" });
const statusSlot = document.querySelector(".map-list-status");
expect(statusSlot?.getAttribute("data-visible")).toBe("false");
list.scrollTop = 160;
const pager = screen.getByRole("navigation", { name: "地图记忆分页" });
await userEvent.click(
@@ -759,6 +822,8 @@ it("keeps pagination and current detail stable while the next page is loading",
.getByRole("navigation", { name: "地图记忆分页" })
.getAttribute("aria-busy"),
).toBe("true");
expect(document.querySelector(".map-list-status")).toBe(statusSlot);
expect(statusSlot?.getAttribute("data-visible")).toBe("true");
expect(
(
screen.getByRole("button", {
@@ -794,10 +859,131 @@ it("keeps pagination and current detail stable while the next page is loading",
/>,
);
expect(list.scrollTop).toBe(0);
expect(document.querySelector(".map-list-status")).toBe(statusSlot);
expect(statusSlot?.getAttribute("data-visible")).toBe("false");
expect(within(list).getByText("下一页第 1 条")).toBeTruthy();
await waitFor(() =>
expect(screen.queryByRole("heading", { name: "第一条" })).toBeNull(),
expect(
await screen.findByRole("heading", { name: "下一页第 1 条" }),
).toBeTruthy();
expect(
within(list)
.getByRole("button", { name: /下一页第 1 条/ })
.getAttribute("aria-pressed"),
).toBe("true");
expect(screen.queryByRole("heading", { name: "第一条" })).toBeNull();
});
it("keeps the page intent through a failed read and selects the first row after retry", async () => {
const onPage = vi.fn();
const onRetry = vi.fn();
const page = {
items: memories,
total: 202,
offset: 0,
limit: 100,
source_ids_by_memory: { m0: ["codex"], m1: ["codex"] },
};
const base = { ...pagedView(page), onPage, onRetry };
const view = render(<Host simplified libraryView={base} />);
await act(async () => {});
await userEvent.click(screen.getByRole("button", { name: "下一页记忆" }));
expect(
within(screen.getByRole("list", { name: "地图记忆列表" }))
.getByRole("button", { name: /第一条/ })
.hasAttribute("disabled"),
).toBe(true);
view.rerender(
<Host
simplified
libraryView={{
...base,
loading: false,
failed: true,
requestedOffset: 100,
}}
/>,
);
expect(screen.getByRole("heading", { name: "第一条" })).toBeTruthy();
await userEvent.click(
screen.getByRole("button", { name: "重新读取地图记忆" }),
);
expect(onRetry).toHaveBeenCalledOnce();
expect(
within(screen.getByRole("list", { name: "地图记忆列表" }))
.getByRole("button", { name: /第一条/ })
.hasAttribute("disabled"),
).toBe(true);
expect(
(
screen.getByRole("button", {
name: "搜索",
}) as HTMLButtonElement
).disabled,
).toBe(true);
const nextItems = memories.map((memory, index) => ({
...memory,
memory_id: `retry-${index}`,
content: `重试页第 ${index + 1} 条`,
}));
view.rerender(
<Host
simplified
items={nextItems}
libraryView={{
...base,
requestedOffset: 100,
page: {
...page,
items: nextItems,
offset: 100,
source_ids_by_memory: {
"retry-0": ["codex"],
"retry-1": ["codex"],
},
},
loading: false,
failed: false,
}}
/>,
);
expect(
await screen.findByRole("heading", { name: "重试页第 1 条" }),
).toBeTruthy();
});
it("returns to the shown page when a failed page request is cancelled by a row click", async () => {
const onPage = vi.fn();
const page = {
items: memories,
total: 202,
offset: 0,
limit: 100,
source_ids_by_memory: { m0: ["codex"], m1: ["codex"] },
};
const base = { ...pagedView(page), onPage };
const view = render(<Host simplified libraryView={base} />);
await act(async () => {});
await userEvent.click(screen.getByRole("button", { name: "下一页记忆" }));
view.rerender(
<Host
simplified
libraryView={{
...base,
failed: true,
requestedOffset: 100,
}}
/>,
);
await userEvent.click(
within(screen.getByRole("list", { name: "地图记忆列表" })).getByRole(
"button",
{ name: /第二条/ },
),
);
expect(onPage.mock.calls).toEqual([[100], [0]]);
expect(await screen.findByRole("heading", { name: "第二条" })).toBeTruthy();
});
it("names the SDK's no-source bucket 未识别来源, apart from a source under review", () => {
@@ -877,8 +1063,10 @@ it("badges each listed memory with its scene and pages 20 at a time", () => {
expect((next as HTMLButtonElement).disabled).toBe(false);
fireEvent.click(next);
expect(view.onPage).toHaveBeenCalledWith(40);
fireEvent.click(screen.getByRole("button", { name: "上一页记忆" }));
expect(view.onPage).toHaveBeenCalledWith(0);
const previous = screen.getByRole("button", { name: "上一页记忆" });
expect((previous as HTMLButtonElement).disabled).toBe(true);
fireEvent.click(previous);
expect(view.onPage).not.toHaveBeenCalledWith(0);
});
it("says a filtered source is empty rather than that nothing matched", () => {
+136 -25
View File
@@ -101,6 +101,7 @@ type Props = {
stale: boolean;
settledSource?: string;
settledQuery?: string;
requestedOffset?: number;
/** The source id the SDK uses for "no source given" (shown as 未识别来源). */
unattributedSource?: string;
onQuery(value: string): void;
@@ -224,6 +225,15 @@ export function MemoryMap(props: Props) {
const [pendingSourceSelection, setPendingSourceSelection] = useState<
string | null
>(null);
const [pendingPageOffset, setPendingPageOffset] = useState<number | null>(
null,
);
const [pageRetrying, setPageRetrying] = useState(false);
const pageRetrySawLoading = useRef(false);
const [detailResetRevision, setDetailResetRevision] = useState(0);
const pageTransitioning =
!!library?.loading ||
(pendingPageOffset !== null && (!library?.failed || pageRetrying));
const [question, setQuestion] = useState("");
const [inspectorOpen, setInspectorOpen] = useState(false);
const [filterNotice, setFilterNotice] = useState("");
@@ -426,6 +436,7 @@ export function MemoryMap(props: Props) {
: selectedSource
? `source:${selectedSource.source_id}`
: (visibleGroupNode?.node_id ?? "empty");
const inspectorResetKey = `${inspectorIdentity}:${detailResetRevision}`;
const visibleDetail =
detail &&
(!activeId || activeId === detail.memory.memory_id) &&
@@ -437,10 +448,24 @@ export function MemoryMap(props: Props) {
const inspectorDetail =
visibleDetail ??
(activeId && lastDetail.current ? lastDetail.current : null);
const readingActiveDetail =
!!activeId &&
props.detailLoad?.memoryId === activeId &&
props.detailLoad.status === "loading";
const failedActiveDetail =
!!activeId &&
props.detailLoad?.memoryId === activeId &&
props.detailLoad.status === "failed";
const pick = useCallback(
(memoryId: string, scroll = true) => {
if (!active.some((memory) => memory.memory_id === memoryId)) return;
if (pendingPageOffset !== null && library?.failed && library.page)
library.onPage(library.page.offset);
setPendingSourceSelection(null);
setPendingPageOffset(null);
setPageRetrying(false);
pageRetrySawLoading.current = false;
setDetailResetRevision((current) => current + 1);
scrollSelection.current = scroll;
if (!filtered.some((memory) => memory.memory_id === memoryId)) {
setGroupKey("");
@@ -452,7 +477,7 @@ export function MemoryMap(props: Props) {
setInspectorOpen(true);
onOpenMemory(memoryId);
},
[active, filtered, onOpenMemory],
[active, filtered, library, onOpenMemory, pendingPageOffset],
);
useEffect(() => {
if (
@@ -480,6 +505,24 @@ export function MemoryMap(props: Props) {
pick,
selectedSource,
]);
useLayoutEffect(() => {
if (pendingPageOffset === null || !library || library.loading) return;
if (library.failed || library.stale) return;
const expectedOffset = library.requestedOffset ?? pendingPageOffset;
if (library.page?.offset !== expectedOffset) return;
setPendingPageOffset(null);
setPageRetrying(false);
pageRetrySawLoading.current = false;
const first = active[0];
if (first) {
pick(first.memory_id, false);
return;
}
setSelectedId(null);
setGroupNode(null);
setInspectorOpen(false);
onCloseDetail();
}, [active, library, onCloseDetail, pendingPageOffset, pick]);
useEffect(() => {
if (!props.simplified || openedDefault.current || overlaysSuspended) return;
if (library?.loading && filtered.length === 0) return;
@@ -497,6 +540,17 @@ export function MemoryMap(props: Props) {
detail,
pick,
]);
useEffect(() => {
if (!pageRetrying || !library) return;
if (library.loading) {
pageRetrySawLoading.current = true;
return;
}
if (pageRetrySawLoading.current && library.failed) {
pageRetrySawLoading.current = false;
setPageRetrying(false);
}
}, [library, pageRetrying]);
useEffect(() => {
if (!activeId || !scrollSelection.current) return;
scrollSelection.current = false;
@@ -516,9 +570,9 @@ export function MemoryMap(props: Props) {
useLayoutEffect(() => {
// Detail content owns its own scrollport. A newly selected memory must
// start at its heading instead of inheriting the previous inspector view.
if (inspectorIdentity === "empty") return;
if (inspectorResetKey.startsWith("empty:")) return;
if (inspectorBody.current) inspectorBody.current.scrollTop = 0;
}, [inspectorIdentity]);
}, [inspectorResetKey]);
const settledPageOffset = library?.page?.offset;
useLayoutEffect(() => {
// A settled page is a new reading surface, not a continuation of the
@@ -531,12 +585,16 @@ export function MemoryMap(props: Props) {
setSelectedId(null);
setGroupNode(null);
setPendingSourceSelection(null);
setPendingPageOffset(null);
setPageRetrying(false);
pageRetrySawLoading.current = false;
setInspectorOpen(false);
onCloseDetail();
}, [onCloseDetail]);
useEffect(() => {
// A background map does not own the shared inspector in another mode.
if (overlaysSuspended || library?.loading) return;
if (overlaysSuspended || library?.loading || pendingPageOffset !== null)
return;
if (
requestedId &&
!active.some((memory) => memory.memory_id === requestedId)
@@ -556,6 +614,7 @@ export function MemoryMap(props: Props) {
clear,
overlaysSuspended,
library?.loading,
pendingPageOffset,
]);
function closeInspector() {
if (!compact) {
@@ -565,6 +624,14 @@ export function MemoryMap(props: Props) {
setPendingSourceSelection(null);
setInspectorOpen(false);
}
function retryLibrary() {
if (!library) return;
if (pendingPageOffset !== null) {
pageRetrySawLoading.current = false;
setPageRetrying(true);
}
library.onRetry();
}
function chooseGroup(key: string) {
setGroupNode(null);
if (library) {
@@ -619,6 +686,17 @@ export function MemoryMap(props: Props) {
? text("未识别来源")
: null) ??
text("全部记忆");
const libraryStatus = !library
? ""
: library.failed
? text("读取失败")
: library.stale
? text("正在核对来源…")
: pageTransitioning
? refreshing && library.page?.total !== 0
? text("正在核对…")
: text("正在读取…")
: "";
if (
props.simplified &&
(!library ||
@@ -819,21 +897,20 @@ export function MemoryMap(props: Props) {
<button
type="submit"
className="map-search-submit"
disabled={library.loading || hidden}
disabled={pageTransitioning || hidden}
>
{text("搜索")}
</button>
) : null}
</form>
{library && (library.loading || library.stale || library.failed) ? (
<p className="map-list-status" aria-live="polite">
{library.failed
? text("读取失败")
: library.stale
? text("正在核对来源…")
: refreshing && library.page?.total !== 0
? text("正在核对…")
: text("正在读取…")}
{library ? (
<p
className="map-list-status"
aria-live="polite"
aria-hidden={!libraryStatus || undefined}
data-visible={libraryStatus ? "true" : "false"}
>
{libraryStatus || "\u00a0"}
</p>
) : null}
{!props.simplified && (
@@ -903,6 +980,7 @@ export function MemoryMap(props: Props) {
type="button"
className="map-memory-item"
aria-pressed={activeId === m.memory_id}
disabled={pageTransitioning}
onClick={() => pick(m.memory_id)}
>
<span className={`map-state ${m.status}`} aria-hidden="true" />
@@ -956,7 +1034,7 @@ export function MemoryMap(props: Props) {
{library?.failed && (
<div role="alert">
<p>{text("记忆读取失败,请重试。")}</p>
<R6Button onClick={library.onRetry}>
<R6Button onClick={retryLibrary}>
{text("重新读取地图记忆")}
</R6Button>
</div>
@@ -991,7 +1069,7 @@ export function MemoryMap(props: Props) {
!library.stale && (
<nav
aria-label={text("地图记忆分页")}
aria-busy={library.loading || undefined}
aria-busy={pageTransitioning || undefined}
className="map-pagination"
>
<button
@@ -999,11 +1077,16 @@ export function MemoryMap(props: Props) {
className="quiet map-tool"
aria-label={text("上一页记忆")}
title={text("上一页记忆")}
disabled={library.loading || library.page.offset === 0}
disabled={pageTransitioning || library.page.offset === 0}
onClick={() => {
if (axis === "topic") setGroupKey("");
if (library.page)
library.onPage(library.page.offset - library.page.limit);
if (library.page) {
const offset = library.page.offset - library.page.limit;
setPageRetrying(false);
pageRetrySawLoading.current = false;
setPendingPageOffset(offset);
library.onPage(offset);
}
}}
>
<ChevronLeft size={16} aria-hidden="true" />
@@ -1029,13 +1112,18 @@ export function MemoryMap(props: Props) {
aria-label={text("下一页记忆")}
title={text("下一页记忆")}
disabled={
library.loading ||
pageTransitioning ||
library.page.offset + library.page.limit >= library.page.total
}
onClick={() => {
if (axis === "topic") setGroupKey("");
if (library.page)
library.onPage(library.page.offset + library.page.limit);
if (library.page) {
const offset = library.page.offset + library.page.limit;
setPageRetrying(false);
pageRetrySawLoading.current = false;
setPendingPageOffset(offset);
library.onPage(offset);
}
}}
>
<ChevronRight size={16} aria-hidden="true" />
@@ -1220,7 +1308,7 @@ export function MemoryMap(props: Props) {
<MemoryEvidencePanel
open={inspectorOpen && !!(activeId || visibleGroupNode)}
suspended={overlaysSuspended}
scrollResetKey={activeId ?? visibleGroupNode?.node_id ?? "empty"}
scrollResetKey={inspectorResetKey}
onClose={closeInspector}
>
<header className="map-inspector-heading">
@@ -1255,7 +1343,7 @@ export function MemoryMap(props: Props) {
<button
type="button"
className="secondary"
onClick={library.onRetry}
onClick={retryLibrary}
>
{text("重试")}
</button>
@@ -1276,8 +1364,17 @@ export function MemoryMap(props: Props) {
</div>
) : props.simplified && inspectorDetail ? (
<div
key={`${inspectorDetail.memory.memory_id}:${detailResetRevision}:${
readingActiveDetail
? "loading"
: failedActiveDetail
? "failed"
: "ready"
}`}
className="map-detail-surface"
aria-busy={
refreshing ||
readingActiveDetail ||
inspectorDetail.memory.memory_id !== activeId ||
undefined
}
@@ -1302,7 +1399,21 @@ export function MemoryMap(props: Props) {
until the new one arrived, which read as "nothing happened"
for two or three seconds. A read is not a value: show that a
read is happening instead. */}
{activeId && inspectorDetail.memory.memory_id !== activeId ? (
{failedActiveDetail ? (
<div className="map-detail-loading">
<p role="alert">
{text("这条记忆的原文暂时无法读取,选择已保留。")}
</p>
<button
type="button"
className="secondary"
onClick={() => activeId && onOpenMemory(activeId)}
>
{text("重试读取原文")}
</button>
</div>
) : readingActiveDetail ||
(activeId && inspectorDetail.memory.memory_id !== activeId) ? (
<div className="map-detail-loading">
<div className="map-detail-skeleton" aria-hidden="true">
<i />
+1
View File
@@ -1049,6 +1049,7 @@ export function MemorySpace({
stale: false,
settledSource: sdkLibrary.settledSource,
settledQuery: sdkLibrary.settledQuery,
requestedOffset: sdkLibraryOffset,
unattributedSource: UNATTRIBUTED_SOURCE_ID,
onQuery: setSdkLibraryQuery,
onSource: setSdkLibrarySource,
@@ -136,6 +136,70 @@ it("splits long memory text into a bounded heading and readable body", () => {
"selected-memory-body",
);
});
it("renders an SDK experience from its structured task and solution", () => {
const task =
"review an uncommitted diff for privacy and correctness blockers";
const solution = `Reusable checklist:\n1. Run \`git diff --check\`.\n2. Inspect ${"/very-long-path".repeat(20)}.`;
render(
<SelectedMemoryDetail
detail={{
...detail,
memory: {
...detail.memory,
content: `${task}\n${solution}`,
revision: 0,
},
evidence: [],
sdk_status: "active",
sdk_ref: {
scene: "coding",
kind: "experience",
variant: "coding",
},
sdk_task: task,
sdk_solution: solution,
}}
sources={[]}
/>,
);
expect(screen.getByRole("heading", { name: task })).toBeTruthy();
const body = document.querySelector(".selected-memory-body");
expect(body?.textContent).toBe(solution);
expect(
screen
.getByRole("heading", { name: task })
.closest("[data-memory-kind]")
?.getAttribute("data-memory-kind"),
).toBe("experience");
});
it("does not apply experience fields to a fact memory", () => {
const content = `${"事实".repeat(40)}。补充说明`;
render(
<SelectedMemoryDetail
detail={{
...detail,
memory: { ...detail.memory, content, revision: 0 },
evidence: [],
sdk_status: "active",
sdk_ref: {
scene: "working",
kind: "fact",
variant: "working",
},
sdk_task: "不应显示的任务标题",
sdk_solution: "不应显示的经验正文",
}}
sources={[]}
/>,
);
expect(screen.queryByText("不应显示的任务标题")).toBeNull();
expect(screen.queryByText("不应显示的经验正文")).toBeNull();
expect(
document
.querySelector(".selected-memory-detail")
?.getAttribute("data-memory-kind"),
).toBe("memory");
});
it.each([
[
"a late sentence end",
+20 -5
View File
@@ -65,7 +65,7 @@ export function SelectedMemoryDetail({
// (CJK text has none, so it breaks at the limit itself).
const punctuation = memory.content.search(/[。!?!?\n]/);
const space = memory.content.lastIndexOf(" ", HEADING_LIMIT);
const split =
const fallbackSplit =
punctuation > 0 && punctuation < HEADING_LIMIT
? punctuation + 1
: memory.content.length > HEADING_LIMIT
@@ -73,11 +73,26 @@ export function SelectedMemoryDetail({
? space
: HEADING_LIMIT
: -1;
const heading =
split > 0 ? memory.content.slice(0, split).trim() : memory.content;
const body = split > 0 ? memory.content.slice(split).trim() : "";
const fallbackHeading =
fallbackSplit > 0
? memory.content.slice(0, fallbackSplit).trim()
: memory.content;
const fallbackBody =
fallbackSplit > 0 ? memory.content.slice(fallbackSplit).trim() : "";
const experience =
detail.sdk_ref?.kind === "experience" &&
Boolean(detail.sdk_task?.trim() || detail.sdk_solution?.trim());
const heading = experience
? detail.sdk_task?.trim() || fallbackHeading
: fallbackHeading;
const body = experience
? detail.sdk_solution?.trim() || (detail.sdk_task ? "" : fallbackBody)
: fallbackBody;
return (
<article className="selected-memory-detail">
<article
className="selected-memory-detail"
data-memory-kind={experience ? "experience" : "memory"}
>
<p className="selected-memory-status">
{statusLabel}
{!detail.sdk_status &&
+16 -2
View File
@@ -788,12 +788,16 @@ button.rail-button.active::before {
}
.map-list-status {
flex: none;
min-height: 28px;
margin: 0;
padding: 4px;
color: var(--tx3);
font-size: 12px;
line-height: 20px;
}
.map-list-status[data-visible="false"] {
visibility: hidden;
}
.map-filter-notice {
font-size: 12px;
line-height: 1.65;
@@ -879,6 +883,10 @@ button.rail-button.active::before {
background: #fdfefe;
border-color: #c7d3dc;
}
.map-memory-item:disabled {
opacity: 1;
cursor: default;
}
.map-state {
width: 5px;
height: 5px;
@@ -1142,6 +1150,7 @@ button.map-tool {
}
.map-detail-loading {
display: grid;
min-height: 280px;
gap: 10px;
padding: 4px 0;
}
@@ -1152,8 +1161,13 @@ button.map-tool {
}
@keyframes map-detail-enter {
from {
opacity: 0;
transform: translateY(4px);
opacity: 0.35;
}
}
@media (prefers-reduced-motion: no-preference) {
.map-detail-surface,
.map-detail-loading {
animation: map-detail-enter 140ms cubic-bezier(0, 0, 0.2, 1);
}
}
.map-inspector .wb-detail {
+2
View File
@@ -263,12 +263,14 @@
display: block;
flex: none;
min-width: 0;
max-width: 100%;
font-size: 17px;
line-height: 29px;
letter-spacing: normal;
margin: 0;
white-space: pre-wrap;
overflow-wrap: anywhere;
word-break: break-word;
}
.selected-memory-source {
margin: 32px 0 22px;
+2 -2
View File
@@ -1,13 +1,13 @@
# MemHub 安装与发行
MemHub `v1.0.5`(build 14)的发行目标如下:
MemHub `v1.0.5`(build 36)的发行目标如下:
| 平台 | 发行物 | 构建与验收 |
| --- | --- | --- |
| Linux x64 | Runtime tar.gz | GitHub Actions 原生 x64 runner |
| Linux arm64 | Runtime tar.gz | GitHub Actions 原生 arm64 runner |
| macOS arm64 | DMG 与 Runtime tar.gz | Developer ID 签名、公证和原生 arm64 验收 |
| macOS x64 | DMG 与 Runtime tar.gz | Developer ID 签名、公证和原生 Intel 验收 |
| macOS x64 | 无 | 本版本不提供 Intel 构建 |
| Windows | 无 | 当前不支持 |
正式支持以对应 GitHub Release 的 `release-manifest.json` 为准。Manifest 记录版本、
@@ -0,0 +1,66 @@
# TODO: recall imported conversations before a model is configured
Status: open. Found 2026-10-28 by the Linux lifecycle run on build 35.
## Problem
A new user who has imported conversations but not configured a generation
model gets nothing from `MemHub_recall` or `memhub recall`.
`recall_with_fallback` (`apps/core/src/one_memory/recall_fallback.py`) is
meant to cover this case: without a model it searches the SDK raw layer
(`l1_raw`), the imported conversations themselves. But under SDK authority
nothing writes that layer until a model is active:
- File import and live capture only stage conversations into
`memory_write_jobs` (assembling, then pending).
- The durable write drain claims a job only when
`_sedimentation_claim_enabled` holds, which requires an active model
profile (`api.py`).
- The SDK writes `l1_raw` only inside `client.add`, which the drain calls.
So until a model is configured, the raw layer is empty and the fallback has
nothing to search. Before the SDK cutover, imported Evidence lived in
MemHub's own database and was searchable without a model.
The Linux lifecycle verification used to assert "import, then recall finds
it" on a machine with no model. It now asserts that the import reached the
write queue instead (`scripts/platform/linux/verify-linux-lifecycle.mjs`,
`waitForQueued`). Restore the recall assertion once this is fixed.
## Direction
Write imported conversations to the SDK raw layer without a generation
model, and leave extraction for when a model is configured.
- The raw layer needs embeddings only. The bundled local ONNX model
(`builtin_retrieval_snapshot`) can produce them, so no network model is
needed.
- SDK: a public raw-only write, for example `add(..., infer=False)` or
`add_raw(messages, ...)`, that stores `l1_raw` with source, session,
workspace and `memory_at`, and returns its raw ids. It must be idempotent
by `request_id`, like `add`.
- MemHub: split the claim gate. Without a model, the drain may claim a
pending job for a raw-only write. Once a model is active, the same job
still needs extraction, and that write must not store `l1_raw` again.
Either the job records that its raw part is done, or extraction runs
through `add_extracted` / a mode that skips the raw store.
- The source sync switch and grant still apply, exactly as for extraction.
## Open questions
- Should raw-only ingestion run at all when the user has turned
sedimentation off, or does that switch mean "store nothing"?
- Storage cost: raw ingestion of a full history import without a model
embeds every turn up front. Check the time and disk cost on a large
history.
- When the user configures a model later, extract from the stored raw rows,
or re-read the jobs' frozen inputs? The second keeps one write path.
## Done when
- On a fresh install with no model, `memhub recall "<phrase from an
imported conversation>"` returns that conversation with `mode: "raw"`.
- Configuring a model afterwards extracts memories from the same
conversations without duplicating their raw rows.
- `verify-linux-lifecycle.mjs` asserts recall again.
+1 -1
View File
@@ -2,7 +2,7 @@
"name": "one-memory",
"version": "1.0.5",
"release": {
"build_number": 35,
"build_number": 36,
"macos_min_version": "13.0",
"app_update": "manual",
"runtime_manifest_schema": 1,
+2 -1
View File
@@ -25,7 +25,8 @@ test("installation docs disclose publication and lifecycle boundaries", async ()
assert.match(installation, /Linux x64.*GitHub Actions/s);
assert.match(installation, /Linux arm64.*GitHub Actions/s);
assert.match(installation, /macOS arm64.*Developer ID/s);
assert.match(installation, /macOS x64.*Developer ID/s);
// v1.0.5 ships no Intel build; the table has to say so rather than omit it.
assert.match(installation, /\| macOS x64 \| 无 \|/);
assert.match(installation, /release-manifest\.json/);
assert.match(installation, /fake Release/);
assert.match(installation, /--source/);
@@ -66,7 +66,6 @@ describe("Linux runtime scripts", () => {
'"stop"',
'"init"',
'"scan"',
'"recall"',
'"uninstall"',
]) {
assert.match(script, new RegExp(command));
@@ -88,9 +87,11 @@ describe("Linux runtime scripts", () => {
script,
/Exit 1 is a valid transient receipt[\s\S]{0,300}\[0, 1, 3\]/,
);
assert.match(script, /append_recalled/);
assert.match(script, /initial_queued/);
assert.match(script, /append_queued/);
assert.match(script, /FROM memory_write_jobs/);
assert.match(script, /idempotent_upgrade_instance_id_changed/);
assert.match(script, /post_upgrade_recalled/);
assert.match(script, /post_upgrade_queued/);
assert.match(script, /uninstall_preserved_data/);
assert.match(script, /is-enabled/);
assert.match(script, /is-active/);
@@ -10,6 +10,7 @@ import {
} from "node:fs";
import os from "node:os";
import path from "node:path";
import { DatabaseSync } from "node:sqlite";
function fail(message, receipt) {
if (receipt) process.stdout.write(`${JSON.stringify(receipt, null, 2)}\n`);
@@ -146,18 +147,35 @@ function doctorCheck(document, id) {
return check;
}
async function waitForRecall(prefix, phrase) {
/**
* Whether an imported phrase reached Core's durable write queue.
*
* Recall cannot prove an import here: this runner has no generation model,
* and with SDK authority nothing reaches the SDK (not even its raw layer)
* until a model is active. What the lifecycle can prove is that the import
* was read and queued for the SDK, durably, in the Runtime's own database.
*/
async function waitForQueued(phrase) {
const deadline = Date.now() + 90_000;
let attempt = 0;
const database = path.join(dataDir, "one-memory.sqlite3");
while (Date.now() < deadline) {
attempt += 1;
const result = run(
`${prefix}-${attempt}`,
["recall", phrase, "--json"],
[0, 1, 3],
15_000,
);
if (result.stdout.includes(phrase)) return true;
if (existsSync(database)) {
let connection;
try {
connection = new DatabaseSync(database, { readOnly: true });
const row = connection
.prepare(
"SELECT COUNT(*) AS count FROM memory_write_jobs " +
"WHERE instr(payload_json, ?) > 0",
)
.get(phrase);
if (Number(row?.count) > 0) return true;
} catch {
// The queue table appears once Core has migrated its schema.
} finally {
connection?.close();
}
}
await new Promise((resolve) => setTimeout(resolve, 2_000));
}
return false;
@@ -320,12 +338,11 @@ try {
"--import",
"--json",
]);
observations.initial_recalled = await waitForRecall(
"recall-initial",
observations.initial_queued = await waitForQueued(
"Linux lifecycle initial phrase",
);
if (!observations.initial_recalled)
throw new Error("initial import was not recalled");
if (!observations.initial_queued)
throw new Error("initial import was not queued for the SDK");
status.import = "passed";
writeFileSync(
@@ -338,12 +355,11 @@ try {
[0],
30_000,
);
observations.append_recalled = await waitForRecall(
"recall-appended",
observations.append_queued = await waitForQueued(
"Linux lifecycle appended phrase",
);
if (!observations.append_recalled)
throw new Error("appended turn was not recalled");
if (!observations.append_queued)
throw new Error("appended turn was not queued for the SDK");
status.append_sync = "passed";
const beforeUpgrade = await waitForRunningStatus("status-before-upgrade");
@@ -378,12 +394,11 @@ try {
if (!observations.idempotent_upgrade_instance_id_changed) {
throw new Error("idempotent upgrade did not restart the Runtime");
}
observations.post_upgrade_recalled = await waitForRecall(
"recall-after-upgrade",
observations.post_upgrade_queued = await waitForQueued(
"Linux lifecycle appended phrase",
);
if (!observations.post_upgrade_recalled) {
throw new Error("idempotent upgrade lost previously recalled data");
if (!observations.post_upgrade_queued) {
throw new Error("idempotent upgrade lost previously queued data");
}
status.upgrade_idempotent = "passed";