Compare commits

..
Author SHA1 Message Date
alvinfeiandClaude Opus 5.5 b50c9f33eb fix(memhub): give the map its own graph-loading state
The map hid the previous scope's graph by passing stale into the library
view, which also blanked the list status, pagination and empty states.
A separate graphLoading flag now hides the old graph and shows
"正在读取记忆地图…" while stale keeps its original meaning and text.
The client-side revoked-source graph filter is dropped to match Core.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-10-28 20:24:39 +08:00
alvinfeiandClaude Opus 5.5 545b7d38bf fix(memhub): keep revoked-source memories on the memory map
Memories already distilled from a revoked source stay visible, so the
graph endpoint no longer checks revocation before or after the SDK read,
no longer filters memories by revoked source and reports the SDK total
again. The map and the list now agree. The scene="all" and
statuses=["active"] scope stays.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-10-28 20:24:39 +08:00
alvinfei 33d7713d4e Merge remote-tracking branch 'github/main' into fix/memory-source-filter-sync 2026-10-28 20:14:52 +08:00
kristinhe cbcc50f35e Merge remote-tracking branch 'origin/main' into fix/memory-source-filter-sync
Co-authored-by: Cursor <cursoragent@cursor.com>

# Conflicts:
#	memhub/apps/web/src/MemorySpace.tsx
2026-10-28 20:09:35 +08:00
kristinhe1111 0eb9a2844e Merge pull request #216 from Tencent-Hunyuan/fix/onboarding-language-menu-clipping
fix(memhub): keep option menus inside viewport
2026-10-28 20:01:37 +08:00
kristinheandCursor 5103492762 fix(memhub): harden memory overview recovery
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-10-28 19:26:01 +08:00
kristinheandCursor 082aeee1f2 fix(memhub): align graph scope and startup states
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-10-28 19:16:48 +08:00
kristinheandCursor 201901d35e fix(memhub): keep option menus inside viewport
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-10-28 19:04:11 +08:00
19 changed files with 1060 additions and 265 deletions

No files matched your search

+4 -2
View File
@@ -2980,11 +2980,13 @@ def create_app(
"""
try:
client = await _sdk_client_handle(app).client()
# The map draws the page the list shows, so it reads the same
# source filter and window rather than the newest memories overall.
# The map visualizes the active source scope, independently of the
# left list's 20-row pagination window.
raw = await asyncio.to_thread(
client.list_memories,
user_id="local",
scene="all",
statuses=["active"],
include_sources=[source] if source else None,
limit=limit,
offset=offset,
@@ -1375,6 +1375,8 @@ def test_the_sdk_memory_map_draws_sdk_memories_without_legacy_tables(tmp_path) -
graph = asyncio.run(scenario())
assert listed[0]["include_sources"] == ["cursor"]
assert listed[0]["scene"] == "all"
assert listed[0]["statuses"] == ["active"]
assert (listed[0]["offset"], listed[0]["limit"]) == (100, 100)
assert (graph["total_memories"], graph["projected_memories"]) == (7, 4)
assert {node["node_id"] for node in graph["nodes"]} == {
@@ -1399,6 +1401,71 @@ def test_the_sdk_memory_map_draws_sdk_memories_without_legacy_tables(tmp_path) -
assert graph["hy_graph_available"] is True
@pytest.mark.integration
@pytest.mark.fresh_sdk
def test_sdk_memory_map_still_draws_memories_from_a_revoked_source(tmp_path) -> None:
# Revoking a source stops new reads from it; memories already distilled
# from it stay visible, so the map agrees with the list.
async def scenario():
app = create_app(_app_settings(tmp_path), secret_store=InMemorySecretStore())
class Client:
def list_memories(self, **_kwargs):
return {
"vdb": {
"memories": [
{
"memory_id": "kept",
"content": "distilled before revocation",
"layer": "l2_fact",
"status": "active",
"user_id": "local",
"agent_id": "all-apps",
"session_id": "s1",
"source": "cursor",
}
],
"total": 1,
}
}
class Handle:
async def client(self):
return Client()
async def close(self):
return None
async with (
app.router.lifespan_context(app),
AsyncClient(
transport=ASGITransport(app=app),
base_url="http://test",
headers={"Authorization": "Bearer authority-test"},
) as client,
):
app.state.sdk_client_handle = Handle()
database = app.state.settings.data_dir / "one-memory.sqlite3"
with sqlite3.connect(database) as db:
db.execute(
"INSERT INTO source_grants "
"(user_id,source_id,root_path,requested_path,root_identity,"
"candidate_kind,granted_at,revoked_at,revision) VALUES "
"('local','cursor','/fixture','/fixture','fixture','default',"
"'2026-10-01T00:00:00Z','2026-10-02T00:00:00Z',1)"
)
response = await client.get(
"/api/v2/memories/graph",
params={"source": "cursor", "limit": 100},
)
assert response.status_code == 200
return response.json()
graph = asyncio.run(scenario())
assert (graph["total_memories"], graph["projected_memories"]) == (1, 1)
assert "memory:kept" in {node["node_id"] for node in graph["nodes"]}
def test_a_completed_sdk_write_does_not_touch_retired_evidence(store) -> None:
"""Build 21 logged RetiredAuthorityAccess after every successful write."""
from types import SimpleNamespace
+121 -9
View File
@@ -172,7 +172,7 @@ it.each([
expect(sedimentation).toHaveBeenCalledOnce();
},
);
it("localizes failure and empty states without leaking errors", async () => {
it("localizes startup recovery and empty states without leaking errors", async () => {
summary.mockRejectedValue(new Error("synthetic_private_error"));
Object.assign(window.oneMemory ?? {}, {
getLanguagePreference: vi
@@ -191,12 +191,15 @@ it("localizes failure and empty states without leaking errors", async () => {
</LanguageProvider>,
);
expect(
await screen.findByText("No se puede leer el estado de las memorias"),
await screen.findByText(
"El servicio se está iniciando; se actualizará automáticamente",
),
).toBeTruthy();
expect(screen.getByText("0")).toBeTruthy();
expect(screen.getByText("Aún no hay aplicaciones para mostrar")).toBeTruthy();
expect(
screen.getByRole("button", { name: "Reintentar lectura" }),
).toBeTruthy();
screen.queryByRole("button", { name: "Reintentar lectura" }),
).toBeNull();
expect(document.body.textContent).not.toContain("synthetic_private_error");
});
it("shows latest recorded call separately without exposing recent-update detail entries", async () => {
@@ -765,7 +768,7 @@ it("keeps authoritative home and processing data when only the source snapshot b
expect(screen.getAllByText("整理中")).toHaveLength(2);
expect(screen.queryByText("状态待核对")).toBeNull();
});
it("does not turn a failed home summary into zero or expose a private error", async () => {
it("keeps a neutral zero while the first home summary read recovers", async () => {
summary.mockRejectedValue(new Error("private offline detail"));
render(
<ApplicationsHome
@@ -775,10 +778,117 @@ it("does not turn a failed home summary into zero or expose a private error", as
onSettings={() => {}}
/>,
);
await waitFor(() => expect(screen.getByText("暂无法读取")).toBeTruthy());
expect(screen.queryByText("0")).toBeNull();
await waitFor(() =>
expect(screen.getByText("后台正在启动,稍后自动更新")).toBeTruthy(),
);
expect(screen.getByText("0")).toBeTruthy();
expect(screen.getByText("正在准备记忆概览")).toBeTruthy();
expect(screen.queryByText(/暂时?无法读取|读取失败/)).toBeNull();
expect(document.body.textContent).not.toContain("private offline detail");
});
it("offers a calm retry after repeated home summary failures", async () => {
vi.useFakeTimers({ shouldAdvanceTime: true });
try {
summary.mockRejectedValue(new Error("private offline detail"));
render(
<ApplicationsHome
imports={imports}
onManage={() => {}}
onMemories={() => {}}
onSettings={() => {}}
/>,
);
await waitFor(() =>
expect(screen.getByText("后台正在启动,稍后自动更新")).toBeTruthy(),
);
await act(async () => vi.advanceTimersByTimeAsync(10_000));
expect(await screen.findByText("记忆概览还没准备好")).toBeTruthy();
expect(
screen.getByText("后台恢复后会自动更新,也可以重新读取。"),
).toBeTruthy();
expect(screen.getByRole("button", { name: "重新读取" })).toBeTruthy();
} finally {
vi.useRealTimers();
}
});
it("counts repeated stale summaries and recovers on the next confirmed read", async () => {
vi.useFakeTimers({ shouldAdvanceTime: true });
try {
const stale = {
memory_count: 0,
latest: null,
observed_at: "2026-09-19T00:00:00Z",
stale: true,
};
summary
.mockResolvedValueOnce(stale)
.mockResolvedValueOnce(stale)
.mockResolvedValue({
memory_count: 4,
latest: null,
observed_at: "2026-09-19T00:01:00Z",
});
render(
<ApplicationsHome
imports={imports}
onManage={() => {}}
onMemories={() => {}}
onSettings={() => {}}
/>,
);
await waitFor(() =>
expect(screen.getByText("后台正在启动,稍后自动更新")).toBeTruthy(),
);
await act(async () => vi.advanceTimersByTimeAsync(10_000));
expect(await screen.findByText("记忆概览还没准备好")).toBeTruthy();
fireEvent.click(screen.getByRole("button", { name: "重新读取" }));
expect(await screen.findByText("4")).toBeTruthy();
expect(screen.queryByText("记忆概览还没准备好")).toBeNull();
} finally {
vi.useRealTimers();
}
});
it("labels a retained count as previous data after repeated stale reads", async () => {
vi.useFakeTimers({ shouldAdvanceTime: true });
try {
const stale = {
memory_count: 0,
latest: null,
observed_at: "2026-09-19T00:00:00Z",
stale: true,
};
summary
.mockResolvedValueOnce({
memory_count: 601,
latest: null,
observed_at: "2026-09-19T00:00:00Z",
})
.mockResolvedValueOnce(stale)
.mockResolvedValueOnce(stale)
.mockResolvedValue({
memory_count: 4,
latest: null,
observed_at: "2026-09-19T00:01:00Z",
});
render(
<ApplicationsHome
imports={imports}
onManage={() => {}}
onMemories={() => {}}
onSettings={() => {}}
/>,
);
expect(await screen.findByText("601")).toBeTruthy();
await act(async () => vi.advanceTimersByTimeAsync(20_000));
expect(screen.getByText("601")).toBeTruthy();
expect(screen.getByText("正在显示上次数据,稍后自动更新")).toBeTruthy();
await act(async () => vi.advanceTimersByTimeAsync(10_000));
expect(await screen.findByText("4")).toBeTruthy();
expect(screen.queryByText("正在显示上次数据,稍后自动更新")).toBeNull();
} finally {
vi.useRealTimers();
}
});
it("never calls a recall event a success when only stored memories are available", async () => {
render(
<ApplicationsHome
@@ -1200,7 +1310,7 @@ it("keeps the conversation-sync checking copy before the first home read", () =>
expect(screen.queryByText(/个应用已开启/)).toBeNull();
});
it("does not claim there are no memories while the first summary read is pending", async () => {
it("shows a provisional zero while the first summary read is pending", async () => {
summary.mockImplementation(() => new Promise(() => {}));
history.mockResolvedValue({
available: true,
@@ -1216,7 +1326,9 @@ it("does not claim there are no memories while the first summary read is pending
onSettings={() => {}}
/>,
);
await screen.findByText("正在读取动态");
await screen.findByText("正在准备记忆概览");
expect(screen.getByText("0")).toBeTruthy();
expect(screen.getByText("正在核对本机记忆…")).toBeTruthy();
expect(screen.queryByText("还没有已生成的记忆")).toBeNull();
});
+41 -45
View File
@@ -195,7 +195,7 @@ export function ApplicationsHome({
null,
);
const [syncHealth, setSyncHealth] = useState<LiveSyncHealth[]>([]);
const [failed, setFailed] = useState(false);
const [summaryFailures, setSummaryFailures] = useState(0);
const [recentCall, setRecentCall] = useState<CallEvent | null>(null);
const [callEvents, setCallEvents] = useState<CallEvent[]>([]);
const [callState, setCallState] = useState<
@@ -554,13 +554,17 @@ export function ApplicationsHome({
busy = false;
if (stopped) return;
if (home.status === "fulfilled") {
setFailed(false);
// stale=true 表示这轮读取超时,数字不代表现状:保留上一次的概览,
// 等 10 秒后的下一轮轮询补上,绝不清零也不报错。
if (!("stale" in home.value) || !home.value.stale)
// 等 10 秒后的下一轮轮询补上。重复 stale 仍要进入可恢复状态,
// 不能永远停在“正在核对”。
if (!("stale" in home.value) || !home.value.stale) {
setSummaryFailures(0);
setSummary(home.value);
} else {
setSummaryFailures((count) => count + 1);
}
} else {
setFailed(true);
setSummaryFailures((count) => count + 1);
}
if (state.status === "fulfilled") setProcessing(state.value);
if (sync.status === "fulfilled") setSyncHealth(sync.value);
@@ -578,7 +582,8 @@ export function ApplicationsHome({
}, [active, requestKey]);
const visible = summary;
const latest = visible?.latest;
const summaryUnavailable = failed;
const summaryUnavailable = visible === null && summaryFailures >= 2;
const summaryStale = visible !== null && summaryFailures >= 2;
const hasCall =
recentCall !== null &&
(callState === "ready" || callState === "incomplete");
@@ -642,7 +647,7 @@ export function ApplicationsHome({
? t("apps.running")
: sedimentation?.enabled === false
? t("apps.paused")
: t("apps.unchecked");
: t("apps.checkingStatus");
const stateDetail =
sedimentation?.enabled === false
? t("apps.stoppedNew")
@@ -821,42 +826,27 @@ export function ApplicationsHome({
<h2>{t("apps.overview")}</h2>
</header>
<div className="r6-home-metrics">
<div>
<div role="status" aria-live="polite" aria-atomic="true">
<p>{t("apps.memories")}</p>
{/* The figure slot used to hold the word 读取中 while the first
read was in flight, which is how "大数字不可见" (D51) shows up
on a slow link: the biggest type on the page spends the first
frame saying something that is not a number. A bar of the same
height reserves the digits instead, and the wording moves to a
live region so screen readers still hear it. 暂无法读取 stays a
word, because that is a settled state rather than progress. */}
{!visible && !summaryUnavailable ? (
<>
<p className="r6-home-figure">
<span
className="r6-home-figure-pending"
aria-hidden="true"
/>
</p>
<small role="status">{t("apps.readingCount")}</small>
</>
) : (
<>
<p className="r6-home-figure">
<strong className={visible ? "" : "r6-home-word"}>
{visible
? new Intl.NumberFormat(locale).format(
visible.memory_count,
)
: t("apps.unreadable")}
</strong>
{visible && t("apps.unit") ? (
<i>{t("apps.unit")}</i>
) : null}
</p>
<small>{t("apps.scope")}</small>
</>
)}
<p className="r6-home-figure">
<strong>
{new Intl.NumberFormat(locale).format(
visible?.memory_count ?? 0,
)}
</strong>
{t("apps.unit") ? <i>{t("apps.unit")}</i> : null}
</p>
<small>
{visible
? summaryStale
? t("apps.showingPrevious")
: t("apps.scope")
: summaryUnavailable
? t("apps.countWillUpdate")
: summaryFailures
? t("apps.startingRetry")
: t("apps.readingCount")}
</small>
</div>
<div>
<p>{t("apps.processing")}</p>
@@ -867,12 +857,16 @@ export function ApplicationsHome({
<p>{t("apps.recentUpdate")}</p>
<strong className={time ? "r6-home-time" : "r6-home-word"}>
{time ??
(visible ? t("apps.notGenerated") : t("apps.unchecked"))}
(visible
? t("apps.notGenerated")
: t("apps.checkingRecent"))}
</strong>
<small>
{time
? `${latestClock?.today ? `${t("apps.today")}· ` : ""}${t("apps.saved")}`
: t("apps.separate")}
: visible
? t("apps.separate")
: t("apps.recentWillUpdate")}
</small>
</div>
</div>
@@ -972,7 +966,9 @@ export function ApplicationsHome({
.join(" · ")
: activity === "importing"
? t("apps.savingConversations")
: t("apps.waitingMemories")}
: activity === "loading"
? t("apps.recentWillUpdate")
: t("apps.waitingMemories")}
</small>
</section>
{(activity === "unavailable" ||
+146 -1
View File
@@ -1,11 +1,18 @@
// @vitest-environment jsdom
import { translate } from "@one-memory/i18n";
import { cleanup, render, screen } from "@testing-library/react";
import {
cleanup,
fireEvent,
render,
screen,
waitFor,
} from "@testing-library/react";
import userEvent from "@testing-library/user-event";
import { afterEach, expect, it, vi } from "vitest";
import { LANGUAGE_STORAGE_KEY, LanguageProvider } from "./gui-language";
import { LanguageSelector } from "./LanguageSelector";
import { optionMenuPosition } from "./OptionMenu";
afterEach(() => {
cleanup();
@@ -159,3 +166,141 @@ it("announces storage errors without replacing the confirmed choice", async () =
"zh-CN",
);
});
it("keeps a right-edge menu inside the viewport", () => {
expect(
optionMenuPosition(
{ top: 8, right: 1016, bottom: 40, left: 872, width: 144 },
160,
1024,
600,
),
).toMatchObject({
top: 44,
left: 868,
width: 144,
maxHeight: 280,
placement: "below",
});
});
it("flips the menu above when the lower edge has no room", () => {
expect(
optionMenuPosition(
{ top: 540, right: 500, bottom: 572, left: 356, width: 144 },
180,
1024,
600,
),
).toMatchObject({
top: 356,
left: 356,
width: 144,
maxHeight: 280,
placement: "above",
});
});
it("limits the panel to the actual space when neither side has 280px", () => {
expect(
optionMenuPosition(
{ top: 40, right: 160, bottom: 60, left: 16, width: 144 },
180,
320,
100,
),
).toMatchObject({
top: 64,
left: 16,
width: 144,
maxHeight: 24,
placement: "below",
});
});
it("portals the open menu outside clipping layout containers", async () => {
render(
<LanguageProvider>
<div data-testid="clipping-parent">
<LanguageSelector compact />
</div>
</LanguageProvider>,
);
await userEvent.click(screen.getByRole("combobox"));
const listbox = screen.getByRole("listbox");
expect(listbox.parentElement).toBe(document.body);
expect(
listbox.classList.contains("r6-language-option-menu-panel--compact"),
).toBe(true);
});
it("keeps the menu open while its portal scrollbar or padding is pressed", async () => {
render(
<LanguageProvider>
<LanguageSelector compact />
</LanguageProvider>,
);
const selector = screen.getByRole("combobox");
await userEvent.click(selector);
const listbox = screen.getByRole("listbox");
fireEvent.mouseDown(listbox);
fireEvent.blur(selector, { relatedTarget: null });
expect(selector.getAttribute("aria-expanded")).toBe("true");
fireEvent.mouseUp(document);
});
it("clears the portal pointer guard when the menu closes mid-press", async () => {
render(
<LanguageProvider>
<LanguageSelector compact />
</LanguageProvider>,
);
const selector = screen.getByRole("combobox");
await userEvent.click(selector);
fireEvent.mouseDown(screen.getByRole("listbox"));
fireEvent.keyDown(document, { key: "Escape" });
expect(selector.getAttribute("aria-expanded")).toBe("false");
await userEvent.click(selector);
fireEvent.blur(selector, { relatedTarget: null });
expect(selector.getAttribute("aria-expanded")).toBe("false");
});
it("clears the portal pointer guard when the window loses focus", async () => {
render(
<LanguageProvider>
<LanguageSelector compact />
</LanguageProvider>,
);
const selector = screen.getByRole("combobox");
await userEvent.click(selector);
fireEvent.mouseDown(screen.getByRole("listbox"));
fireEvent.blur(window);
fireEvent.blur(selector, { relatedTarget: null });
expect(selector.getAttribute("aria-expanded")).toBe("false");
});
it("closes the portal when its anchor scrolls completely out of view", async () => {
render(
<LanguageProvider>
<LanguageSelector compact />
</LanguageProvider>,
);
const selector = screen.getByRole("combobox");
vi.spyOn(selector, "getBoundingClientRect").mockReturnValue({
top: 20,
right: 1168,
bottom: 52,
left: 1024,
width: 144,
height: 32,
x: 1024,
y: 20,
toJSON: () => ({}),
});
await userEvent.click(selector);
await waitFor(() =>
expect(selector.getAttribute("aria-expanded")).toBe("false"),
);
expect(screen.queryByRole("listbox")).toBeNull();
});
+5
View File
@@ -54,6 +54,11 @@ export function LanguageSelector({ compact = false }: { compact?: boolean }) {
disabled={pending}
options={options}
className="r6-language-option-menu"
panelClassName={
compact
? "r6-language-option-menu-panel--compact"
: "r6-language-option-menu-panel--settings"
}
onChange={async (value) => {
if (!isLanguagePreference(value)) return;
setPending(true);
+168 -2
View File
@@ -348,6 +348,7 @@ function Host({
processing = null,
loaded = true,
deferMemoryId,
graphValue = graph,
onViewHistory,
onConfigureModel = () => {},
libraryView,
@@ -363,6 +364,7 @@ function Host({
processing?: MemoryProcessingSummary | null;
loaded?: boolean;
deferMemoryId?: string;
graphValue?: MemoryGraphResponse;
onViewHistory?(): void;
onConfigureModel?(): void;
libraryView?: Parameters<typeof MemoryMap>[0]["libraryView"];
@@ -375,7 +377,7 @@ function Host({
hidden={hidden}
overlayBlocked={overlayBlocked}
memories={items}
graph={graph}
graph={graphValue}
sources={sources}
arrival={arrival}
onViewHistory={onViewHistory}
@@ -467,6 +469,57 @@ it("does not present an unverified empty page as zero memories", () => {
expect(screen.queryByText(/0 条/)).toBeNull();
expect(screen.queryByText("还没有已生成的记忆")).toBeNull();
});
it("does not show the previous source graph while the new scope's graph loads", () => {
render(
<Host
simplified
libraryView={{
...pagedView({
items: memories,
total: memories.length,
offset: 0,
limit: 20,
source_ids_by_memory: { m0: ["codex"], m1: ["codex"] },
}),
query: "项目",
graphLoading: true,
}}
/>,
);
expect(screen.queryByTestId("canvas")).toBeNull();
expect(screen.getByText("正在读取记忆地图…")).toBeTruthy();
// The list keeps its own state: graph loading is not page staleness.
expect(screen.queryByText("正在核对来源…")).toBeNull();
});
it("shows a retry instead of collapsing a failed graph to the current page", async () => {
const onRetry = vi.fn();
render(
<Host
simplified
libraryView={{
...pagedView({
items: memories,
total: memories.length,
offset: 0,
limit: 20,
source_ids_by_memory: { m0: ["codex"], m1: ["codex"] },
}),
graphFailed: true,
onRetry,
}}
/>,
);
expect(screen.queryByTestId("canvas")).toBeNull();
expect(screen.getByText("记忆暂时无法读取")).toBeTruthy();
expect(screen.getByRole("status").textContent).toBe("记忆暂时无法读取");
await userEvent.click(
screen.getByRole("button", { name: "重新读取地图记忆" }),
);
expect(onRetry).toHaveBeenCalledOnce();
});
it("starts with receipt-backed source grouping, keeping topics secondary", () => {
render(<Host />);
expect(
@@ -478,6 +531,118 @@ it("starts with receipt-backed source grouping, keeping topics secondary", () =>
expect(screen.getByRole("button", { name: "Codex2" })).toBeDefined();
});
it("restores the full graph scope when the source filter returns to all applications", async () => {
const workbuddyMemory = {
...memories[1],
memory_id: "workbuddy-memory",
content: "较早的 WorkBuddy 记忆",
};
const allGraph = {
total_memories: 2,
projected_memories: 2,
hy_graph_available: false,
nodes: [
graph.nodes.find((node) => node.node_id === "memory:m0"),
graph.nodes.find((node) => node.node_id === "source:codex"),
{
node_id: "memory:workbuddy-memory",
node_type: "memory",
memory_id: "workbuddy-memory",
label: workbuddyMemory.content,
},
{
node_id: "source:workbuddy",
node_type: "source",
source_id: "workbuddy",
label: "WorkBuddy",
},
].filter(Boolean),
edges: [
{
edge_id: "cursor-memory",
source_node_id: "source:codex",
target_node_id: "memory:m0",
relation: "evidence_source",
origin: "provenance",
},
{
edge_id: "workbuddy-memory",
source_node_id: "source:workbuddy",
target_node_id: "memory:workbuddy-memory",
relation: "evidence_source",
origin: "provenance",
},
],
} as MemoryGraphResponse;
const cursorGraph = {
...allGraph,
total_memories: 1,
projected_memories: 1,
nodes: allGraph.nodes.filter(
(node) => node.node_id === "memory:m0" || node.node_id === "source:codex",
),
edges: [allGraph.edges[0]],
};
function ScopeHarness() {
const [source, setSource] = useState("codex");
return (
<Host
simplified
items={[memories[0]]}
graphValue={source ? cursorGraph : allGraph}
sources={
[
{
source_id: "codex",
display_name: "Cursor",
authorized: true,
},
{
source_id: "workbuddy",
display_name: "WorkBuddy",
authorized: true,
},
{
source_id: "retired",
display_name: "Retired",
authorized: false,
},
] as SourceResponse[]
}
libraryView={{
...pagedView({
items: [memories[0]],
total: source ? 1 : 2,
offset: 0,
limit: 20,
source_ids_by_memory: { m0: ["codex"] },
}),
source,
onSource: setSource,
}}
/>
);
}
render(<ScopeHarness />);
expect(
screen.queryByRole("button", {
name: "图中选择 较早的 WorkBuddy 记忆",
}),
).toBeNull();
await userEvent.click(screen.getByRole("combobox", { name: "来源" }));
await userEvent.click(screen.getByRole("option", { name: "全部应用" }));
expect(
screen.getByRole("button", {
name: "图中选择 较早的 WorkBuddy 记忆",
}),
).toBeTruthy();
expect(
screen.getByRole("button", { name: "图中选择 WorkBuddy" }),
).toBeTruthy();
});
function pagedView(
page: Parameters<typeof MemoryMap>[0]["libraryView"] extends infer V
? V extends { page: infer P }
@@ -1238,7 +1403,8 @@ it("drops a pending source selection when that source is revoked", async () => {
}}
/>,
);
expect(screen.queryByRole("button", { name: "图中选择 Codex" })).toBeNull();
// Memories already distilled from a revoked source stay on the map, but the
// revoked source can no longer be the selection.
expect(screen.getByTestId("canvas").dataset.selected).not.toBe(
"source:codex",
);
+66 -13
View File
@@ -95,6 +95,9 @@ type Props = {
page: MemoryLibraryPage | null;
loading: boolean;
failed: boolean;
/** The graph for the current source/query scope has not arrived yet. */
graphLoading?: boolean;
graphFailed?: boolean;
stale: boolean;
settledSource?: string;
settledQuery?: string;
@@ -316,6 +319,31 @@ export function MemoryMap(props: Props) {
[active, group, search, serverPaged, axis, groupKey],
);
const projection = useMemo(() => {
// Until the new scope's graph arrives (or after it failed), drawing the
// previous scope's graph would show the wrong source.
if (serverPaged && (library?.graphLoading || library?.graphFailed))
return {
total_memories: library.page?.total ?? 0,
projected_memories: 0,
hy_graph_available: false,
nodes: [],
edges: [],
};
// Source filtering owns the graph scope, while pagination owns only the
// left-hand list window. Projecting the graph back down to the current
// 20-row page made "All applications" look like whichever source happened
// to dominate that page. Keyword search is intentionally page-scoped.
if (serverPaged && !library?.query) {
return (
baseGraph ??
projectMap(
graph,
active,
axis,
filtered.map((m) => m.memory_id),
)
);
}
const result = projectMap(
graph,
active,
@@ -347,7 +375,21 @@ export function MemoryMap(props: Props) {
confidence: null,
})),
};
}, [graph, axis, filtered, active, groupKey, search, sources, serverPaged]);
}, [
baseGraph,
graph,
axis,
filtered,
active,
groupKey,
search,
sources,
serverPaged,
library?.query,
library?.graphLoading,
library?.graphFailed,
library?.page?.total,
]);
const visibleGroupNode = groupNode
? (projection.nodes.find(
(node) =>
@@ -1059,22 +1101,33 @@ export function MemoryMap(props: Props) {
) : (
<div className="map-empty-stage">
<Network size={32} strokeWidth={1} />
<p>
<p
{...(library?.graphFailed || library?.graphLoading
? { role: "status", "aria-live": "polite" as const }
: {})}
>
{library?.stale
? text("可在沉淀记忆后查看视图")
: library?.failed
: library?.graphFailed || library?.failed
? text("记忆暂时无法读取")
: library?.page &&
(library.query || library.source || groupKey)
? text("当前筛选没有可显示的记忆")
: !loaded
? text("正在读取记忆…")
: active.length
? text("当前筛选没有可显示的记忆")
: arrival.importedMessages > 0
? text("已保存的对话还未生成记忆")
: text("还没有可显示的记忆")}
: library?.graphLoading
? text("正在读取记忆地图…")
: library?.page &&
(library.query || library.source || groupKey)
? text("当前筛选没有可显示的记忆")
: !loaded
? text("正在读取记忆…")
: active.length
? text("当前筛选没有可显示的记忆")
: arrival.importedMessages > 0
? text("已保存的对话还未生成记忆")
: text("还没有可显示的记忆")}
</p>
{library?.graphFailed ? (
<R6Button onClick={library.onRetry}>
{text("重新读取地图记忆")}
</R6Button>
) : null}
</div>
)}
</div>
@@ -5,6 +5,7 @@ import type {
SdkMemoryRecord,
} from "@one-memory/contracts";
import {
act,
cleanup,
fireEvent,
render,
@@ -63,6 +64,9 @@ vi.mock("./MemoryMap", () => ({
source_ids_by_memory?: Record<string, string[]>;
offset: number;
} | null;
graphLoading?: boolean;
graphFailed?: boolean;
stale: boolean;
unattributedSource?: string;
onSource(value: string): void;
onQuery(value: string): void;
@@ -91,6 +95,13 @@ vi.mock("./MemoryMap", () => ({
<span data-testid="map-busy">{String(busy)}</span>
<span data-testid="map-detail">{detail?.memory.memory_id ?? ""}</span>
<span data-testid="map-offset">{libraryView?.page?.offset ?? ""}</span>
<span data-testid="map-stale">{String(libraryView?.stale)}</span>
<span data-testid="map-graph-loading">
{String(libraryView?.graphLoading)}
</span>
<span data-testid="map-graph-failed">
{String(libraryView?.graphFailed)}
</span>
<button type="button" onClick={() => onOpenMemory("e1")}>
open e1
</button>
@@ -133,6 +144,15 @@ vi.mock("./MemoryMap", () => ({
>
pick workbuddy
</button>
<button
type="button"
onClick={() => {
libraryView?.onSource("");
libraryView?.resetPage();
}}
>
pick all
</button>
</div>
),
}));
@@ -214,11 +234,23 @@ it("draws the map from SDK memories on SDK authority, without legacy reads", asy
expect.any(AbortSignal),
);
expect(api.sdkGraph).toHaveBeenCalledWith(
{ source: undefined, offset: 0, limit: 100 },
{ source: undefined, offset: 0, limit: 300 },
expect.any(AbortSignal),
);
});
it("keeps a graph read failure explicit instead of presenting the list page as the graph", async () => {
sdkAuthority();
api.sdkGraph.mockRejectedValue(new Error("graph unavailable"));
render(<MemorySpace simplified active />);
await waitFor(() =>
expect(screen.getByTestId("map-loaded").textContent).toBe("true"),
);
expect(screen.getByTestId("map-graph-failed").textContent).toBe("true");
expect(screen.getByTestId("map-nodes").textContent).toBe("none");
});
it("keeps rows, sources, count and graph on the one filtered page", async () => {
sdkAuthority();
render(<MemorySpace simplified active />);
@@ -251,12 +283,150 @@ it("keeps rows, sources, count and graph on the one filtered page", async () =>
expect.any(AbortSignal),
);
expect(api.sdkGraph).toHaveBeenLastCalledWith(
{ source: "workbuddy", offset: 0, limit: 100 },
{ source: "workbuddy", offset: 0, limit: 300 },
expect.any(AbortSignal),
);
expect(await screen.findByText(/566/)).toBeTruthy();
});
it("hides the previous source graph until the all-applications graph settles", async () => {
sdkAuthority();
render(<MemorySpace simplified active />);
await waitFor(() =>
expect(screen.getByTestId("map-nodes").textContent).toBe("3"),
);
const workbuddyPage = {
memories: [{ ...record("w1"), source: "workbuddy" }],
total: 1,
limit: 20,
offset: 0,
};
const workbuddyGraph: MemoryGraphResponse = {
total_memories: 1,
projected_memories: 1,
nodes: [
{
node_id: "memory:w1",
node_type: "memory",
label: "w1",
memory_id: "w1",
source_id: null,
kind: null,
status: "active",
confidence: null,
},
{
node_id: "source:workbuddy",
node_type: "source",
label: "WorkBuddy",
source_id: "workbuddy",
memory_id: null,
kind: null,
status: null,
confidence: null,
},
],
edges: [],
hy_graph_available: false,
};
api.sdkLibrary.mockResolvedValue(workbuddyPage);
api.sdkGraph.mockResolvedValue(workbuddyGraph);
fireEvent.click(screen.getByText("pick workbuddy"));
await waitFor(() =>
expect(screen.getByTestId("map-nodes").textContent).toBe("2"),
);
const allPage = {
memories: [record("a"), { ...record("w1"), source: "workbuddy" }],
total: 2,
limit: 20,
offset: 0,
};
const allGraph: MemoryGraphResponse = {
...workbuddyGraph,
total_memories: 2,
projected_memories: 2,
nodes: [
...workbuddyGraph.nodes,
{
node_id: "memory:a",
node_type: "memory",
label: "a",
memory_id: "a",
source_id: null,
kind: null,
status: "active",
confidence: null,
},
{
node_id: "source:cursor",
node_type: "source",
label: "Cursor",
source_id: "cursor",
memory_id: null,
kind: null,
status: null,
confidence: null,
},
],
};
let releasePage: (value: typeof allPage) => void = () => {};
let releaseGraph: (value: MemoryGraphResponse) => void = () => {};
api.sdkLibrary.mockReturnValueOnce(
new Promise((resolve) => {
releasePage = resolve;
}),
);
api.sdkGraph.mockReturnValueOnce(
new Promise((resolve) => {
releaseGraph = resolve;
}),
);
fireEvent.click(screen.getByText("pick all"));
await waitFor(() =>
expect(screen.getByTestId("map-graph-loading").textContent).toBe("true"),
);
expect(screen.getByTestId("map-nodes").textContent).toBe("none");
// Waiting for the graph is not page staleness: the list stays usable.
expect(screen.getByTestId("map-stale").textContent).toBe("false");
await act(async () => {
releasePage(allPage);
releaseGraph(allGraph);
});
await waitFor(() =>
expect(screen.getByTestId("map-nodes").textContent).toBe("4"),
);
expect(screen.getByTestId("map-graph-loading").textContent).toBe("false");
const searchPage = {
memories: [record("search-result")],
total: 1,
limit: 20,
offset: 0,
};
let releaseSearch: (value: typeof searchPage) => void = () => {};
api.sdkLibrary.mockReturnValueOnce(
new Promise((resolve) => {
releaseSearch = resolve;
}),
);
const graphCalls = api.sdkGraph.mock.calls.length;
fireEvent.click(screen.getByText("search deploy"));
await waitFor(() =>
expect(screen.getByTestId("map-graph-loading").textContent).toBe("true"),
);
expect(screen.getByTestId("map-nodes").textContent).toBe("none");
expect(api.sdkGraph).toHaveBeenCalledTimes(graphCalls);
await act(async () => releaseSearch(searchPage));
await waitFor(() =>
expect(screen.getByTestId("map-graph-loading").textContent).toBe("false"),
);
});
it("opens an SDK memory in place instead of switching away from the map", async () => {
sdkAuthority();
api.sdkMemory.mockResolvedValue(record("a"));
+43 -23
View File
@@ -166,11 +166,14 @@ export function MemorySpace({
// The SDK library is no longer where a memory opens from the map; the id is
// kept so the panel still has a focus target for its own tests.
const [sdkFocusedMemoryId] = useState<string | null>(null);
// The list, its count, its source and time labels and the graph are all one
// page: the library page and the graph for that same source and window,
// read together. Reading the graph from a different, unfiltered window made
// rows, sources and totals disagree.
const [sdkGraph, setSdkGraph] = useState<MemoryGraphResponse | null>(null);
// The graph covers the active source scope (up to its visual cap), while the
// list pages through that scope. Keep source+query with the value so a
// filter change cannot paint the previous filter's graph during the new read.
const [sdkGraph, setSdkGraph] = useState<{
scope: string;
status: "ready" | "failed";
value: MemoryGraphResponse | null;
} | null>(null);
// Bumping this re-reads the page and graph after an edit or a delete.
const [sdkMapRevision, setSdkMapRevision] = useState(0);
const bumpSdkMap = useCallback(() => setSdkMapRevision((v) => v + 1), []);
@@ -191,6 +194,17 @@ export function MemorySpace({
const [sdkLibraryQuery, setSdkLibraryQuery] = useState("");
const [sdkLibrarySource, setSdkLibrarySource] = useState("");
const [sdkLibraryOffset, setSdkLibraryOffset] = useState(0);
const sdkRequestedScope = JSON.stringify([
sdkLibraryQuery.trim(),
sdkLibrarySource,
]);
const sdkGraphSettled = sdkGraph?.scope === sdkRequestedScope;
const visibleSdkGraph =
sdkGraph?.scope === sdkRequestedScope && sdkGraph.status === "ready"
? sdkGraph.value
: null;
const sdkGraphFailed =
sdkGraph?.scope === sdkRequestedScope && sdkGraph.status === "failed";
// A keyword search pages through one server-side result window. The id is
// kept for the query and source it was searched with; a new query or
// source starts without one, so the server searches afresh.
@@ -233,22 +247,26 @@ export function MemorySpace({
request.signal,
),
// The overlay is decoration: the list stands without it. It reads
// chat memories only, newest first; a mixed page's chat rows are
// among the newest offset+page chat memories, so that prefix holds
// every relation the page can draw.
getSdkMemoryGraph(
{
source,
offset: 0,
limit: query
? 100
: Math.min(
300,
Math.max(100, sdkLibraryOffset + SDK_MEMORY_PAGE_SIZE),
),
},
request.signal,
).catch(() => null),
// the full active/source-filtered scene up to the API's visual cap.
// Reading only the first list-page prefix made "All applications"
// look identical to Cursor whenever newer Cursor memories occupied
// that prefix.
query
? Promise.resolve({
status: "ready" as const,
value: null,
})
: getSdkMemoryGraph(
{
source,
offset: 0,
limit: 300,
},
request.signal,
).then(
(value) => ({ status: "ready" as const, value }),
() => ({ status: "failed" as const, value: null }),
),
]);
if (cancelled) return;
if (sdkSearchWindow.current.key === windowKey)
@@ -265,7 +283,7 @@ export function MemorySpace({
settledSource: sdkLibrarySource,
settledQuery: sdkLibraryQuery,
});
setSdkGraph(graph);
setSdkGraph({ scope: windowKey, ...graph });
} catch {
if (cancelled || request.signal.aborted) return;
setSdkLibrary((current) => ({
@@ -1026,6 +1044,8 @@ export function MemorySpace({
page: sdkLibrary.page,
loading: sdkLibrary.loading,
failed: sdkLibrary.failed,
graphLoading: !sdkGraphSettled,
graphFailed: sdkGraphFailed,
stale: false,
settledSource: sdkLibrary.settledSource,
settledQuery: sdkLibrary.settledQuery,
@@ -1040,7 +1060,7 @@ export function MemorySpace({
}
hidden={!active || displayedMode !== "map"}
memories={sdkLibrary.page?.items ?? []}
graph={sdkGraph}
graph={visibleSdkGraph}
sources={sources}
processing={processing}
arrival={memoryArrival}
+155 -26
View File
@@ -1,4 +1,13 @@
import { useEffect, useId, useRef, useState } from "react";
import {
type CSSProperties,
useCallback,
useEffect,
useId,
useLayoutEffect,
useRef,
useState,
} from "react";
import { createPortal } from "react-dom";
import { useGuiLanguage } from "./gui-language";
export interface OptionMenuItem {
@@ -8,6 +17,43 @@ export interface OptionMenuItem {
lang?: string;
}
const VIEWPORT_MARGIN = 12;
const PANEL_GAP = 4;
const MAX_PANEL_HEIGHT = 280;
export function optionMenuPosition(
anchor: Pick<DOMRect, "top" | "right" | "bottom" | "left" | "width">,
panelHeight: number,
viewportWidth: number,
viewportHeight: number,
) {
const width = Math.min(
anchor.width,
Math.max(0, viewportWidth - VIEWPORT_MARGIN * 2),
);
const left = Math.min(
Math.max(VIEWPORT_MARGIN, anchor.left),
Math.max(VIEWPORT_MARGIN, viewportWidth - VIEWPORT_MARGIN - width),
);
const below = Math.max(
0,
viewportHeight - VIEWPORT_MARGIN - anchor.bottom - PANEL_GAP,
);
const above = Math.max(0, anchor.top - VIEWPORT_MARGIN - PANEL_GAP);
const placement: "above" | "below" =
below >= Math.min(panelHeight, 120) || below >= above ? "below" : "above";
const maxHeight = Math.min(
MAX_PANEL_HEIGHT,
placement === "below" ? below : above,
);
const height = Math.min(panelHeight, maxHeight);
const top =
placement === "below"
? anchor.bottom + PANEL_GAP
: anchor.top - PANEL_GAP - height;
return { top, left, width, maxHeight, placement };
}
/** Replaces a native select so the open list follows the product field, not the OS menu. */
export function OptionMenu(props: {
id?: string;
@@ -17,6 +63,7 @@ export function OptionMenu(props: {
value: string;
disabled?: boolean;
className?: string;
panelClassName?: string;
options: readonly OptionMenuItem[];
onChange: (value: string) => void;
}) {
@@ -32,8 +79,14 @@ export function OptionMenu(props: {
? selectedIndex
: firstEnabledIndex;
const [activeIndex, setActiveIndex] = useState(preferredIndex);
const [panelStyle, setPanelStyle] = useState<CSSProperties>({
visibility: "hidden",
});
const [placement, setPlacement] = useState<"above" | "below">("below");
const root = useRef<HTMLDivElement>(null);
const button = useRef<HTMLButtonElement>(null);
const panel = useRef<HTMLDivElement>(null);
const panelMouseDown = useRef(false);
const selected = props.options.find((item) => item.value === props.value);
const visibleLabel = selected?.label ?? t("wb.select");
const controlId = props.id ?? `${generatedId}-control`;
@@ -62,20 +115,77 @@ export function OptionMenu(props: {
button.current?.focus();
}
const placePanel = useCallback(() => {
const anchor = button.current?.getBoundingClientRect();
const menu = panel.current;
if (!anchor || !menu) return;
if (
anchor.width > 0 &&
(anchor.bottom <= 0 ||
anchor.top >= window.innerHeight ||
anchor.right <= 0 ||
anchor.left >= window.innerWidth)
) {
setOpen(false);
return;
}
const measuredWidth = Math.min(
anchor.width,
Math.max(0, window.innerWidth - VIEWPORT_MARGIN * 2),
);
menu.style.width = `${measuredWidth}px`;
menu.style.maxHeight = `${MAX_PANEL_HEIGHT}px`;
const next = optionMenuPosition(
anchor,
menu.scrollHeight,
window.innerWidth,
window.innerHeight,
);
setPlacement(next.placement);
setPanelStyle({
top: next.top,
left: next.left,
width: next.width,
maxHeight: next.maxHeight,
visibility: "visible",
});
}, []);
useLayoutEffect(() => {
if (!open) return;
placePanel();
window.addEventListener("resize", placePanel);
window.addEventListener("scroll", placePanel, true);
return () => {
window.removeEventListener("resize", placePanel);
window.removeEventListener("scroll", placePanel, true);
};
}, [open, placePanel]);
useEffect(() => {
if (!open) return;
setActiveIndex(preferredIndex);
const close = (event: MouseEvent) => {
if (!root.current?.contains(event.target as Node)) setOpen(false);
const target = event.target as Node;
if (!root.current?.contains(target) && !panel.current?.contains(target))
setOpen(false);
};
const onKey = (event: KeyboardEvent) => {
if (event.key === "Escape") setOpen(false);
};
const releasePanelPointer = () => {
panelMouseDown.current = false;
};
document.addEventListener("mousedown", close);
document.addEventListener("mouseup", releasePanelPointer);
document.addEventListener("keydown", onKey);
window.addEventListener("blur", releasePanelPointer);
return () => {
panelMouseDown.current = false;
document.removeEventListener("mousedown", close);
document.removeEventListener("mouseup", releasePanelPointer);
document.removeEventListener("keydown", onKey);
window.removeEventListener("blur", releasePanelPointer);
};
}, [open, preferredIndex]);
return (
@@ -105,7 +215,12 @@ export function OptionMenu(props: {
}
disabled={props.disabled}
onBlur={(event) => {
if (!root.current?.contains(event.relatedTarget as Node | null))
if (panelMouseDown.current) return;
const target = event.relatedTarget as Node | null;
if (
!root.current?.contains(target) &&
!panel.current?.contains(target)
)
setOpen(false);
}}
onClick={() => {
@@ -152,29 +267,43 @@ export function OptionMenu(props: {
>
{visibleLabel}
</button>
{open && (
<div id={panelId} className="om-option-menu-panel" role="listbox">
{props.options.map((item, index) => (
<button
key={item.value || item.label}
id={`${panelId}-option-${index}`}
type="button"
role="option"
lang={item.lang}
data-active={activeIndex === index || undefined}
aria-selected={item.value === props.value}
disabled={item.disabled}
tabIndex={-1}
onMouseEnter={() => {
if (!item.disabled) setActiveIndex(index);
}}
onClick={() => choose(index)}
>
{item.label}
</button>
))}
</div>
)}
{open &&
createPortal(
<div
ref={panel}
id={panelId}
className={["om-option-menu-panel", props.panelClassName]
.filter(Boolean)
.join(" ")}
role="listbox"
data-placement={placement}
style={panelStyle}
onMouseDownCapture={() => {
panelMouseDown.current = true;
}}
>
{props.options.map((item, index) => (
<button
key={item.value || item.label}
id={`${panelId}-option-${index}`}
type="button"
role="option"
lang={item.lang}
data-active={activeIndex === index || undefined}
aria-selected={item.value === props.value}
disabled={item.disabled}
tabIndex={-1}
onMouseEnter={() => {
if (!item.disabled) setActiveIndex(index);
}}
onClick={() => choose(index)}
>
{item.label}
</button>
))}
</div>,
document.body,
)}
</div>
);
}
+5 -8
View File
@@ -144,13 +144,12 @@ button svg {
background: #edf0f3;
}
.om-option-menu-panel {
position: absolute;
z-index: 30;
top: calc(100% + 4px);
left: 0;
right: 0;
box-sizing: border-box;
position: fixed;
z-index: 1100;
max-height: 280px;
overflow: auto;
overscroll-behavior: contain;
padding: 6px;
background: #fff;
border: 1px solid #c0ccd5;
@@ -178,10 +177,8 @@ button svg {
.om-option-menu-panel [role="option"]:hover:not(:disabled),
button.om-option-menu-button:hover:not(:disabled),
.cold-start button.om-option-menu-button:hover:not(:disabled),
.cold-start .om-option-menu-panel button[role="option"]:hover:not(:disabled),
.r6-settings button.om-option-menu-button:hover:not(:disabled),
.r6-settings .om-option-menu-panel button[role="option"]:hover:not(:disabled),
.r6-memory .om-option-menu-panel button[role="option"]:hover:not(:disabled) {
.r6-memory button.om-option-menu-button:hover:not(:disabled) {
color: #262a2e;
background: #e7eef2;
border-color: transparent;
+5 -15
View File
@@ -72,37 +72,27 @@
border-color: #7892a6;
box-shadow: 0 0 0 3px rgb(85 124 147 / 12%);
}
.r6-settings .r6-language-option-menu .om-option-menu-panel {
top: calc(100% + 6px);
.r6-language-option-menu-panel--settings {
padding: 6px;
border-color: #c6d2da;
box-shadow: 0 12px 32px rgb(48 71 87 / 13%);
}
.r6-settings .r6-language-option-menu .om-option-menu-panel [role="option"] {
.r6-language-option-menu-panel--settings [role="option"] {
min-height: 38px;
padding: 8px 10px;
color: #304757;
font-size: 13px;
line-height: 22px;
}
.r6-settings
.r6-language-option-menu
.om-option-menu-panel
[role="option"][aria-selected="true"] {
.r6-language-option-menu-panel--settings [role="option"][aria-selected="true"] {
background: #dfe9ef;
font-weight: 500;
}
.r6-settings
.r6-language-option-menu
.om-option-menu-panel
[role="option"][data-active="true"] {
.r6-language-option-menu-panel--settings [role="option"][data-active="true"] {
color: #304757;
background: #e9f0f4;
}
.r6-settings
.r6-language-option-menu
.om-option-menu-panel
[role="option"]:active:not(:disabled) {
.r6-language-option-menu-panel--settings [role="option"]:active:not(:disabled) {
color: #304757;
background: #d4e2ea;
}
@@ -1,66 +0,0 @@
# 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.
@@ -7,7 +7,10 @@ export const applicationsChineseMessages = {
"apps.back": "返回首页",
"apps.overview": "记忆概览",
"apps.memories": "已沉淀记忆",
"apps.readingCount": "正在读取已沉淀记忆数量…",
"apps.readingCount": "正在核对本机记忆…",
"apps.startingRetry": "后台正在启动,稍后自动更新",
"apps.countWillUpdate": "记忆数量稍后自动更新",
"apps.showingPrevious": "正在显示上次数据,稍后自动更新",
"apps.unreadable": "暂无法读取",
"apps.unit": "条",
"apps.scope": "按应用授权范围使用",
@@ -16,9 +19,12 @@ export const applicationsChineseMessages = {
"apps.paused": "已暂停",
"apps.stoppedNew": "已停止生成新记忆",
"apps.checkingProcessing": "正在核对整理状态",
"apps.checkingStatus": "正在核对",
"apps.unchecked": "待核对",
"apps.organizingNew": "新的对话正在整理",
"apps.recentUpdate": "最近更新",
"apps.checkingRecent": "正在核对",
"apps.recentWillUpdate": "最近更新会自动显示",
"apps.notGenerated": "尚未生成",
"apps.saved": "记忆已保存",
"apps.separate": "历史与记忆分别处理",
@@ -26,14 +32,14 @@ export const applicationsChineseMessages = {
"apps.activityUnavailable": "动态暂不可用",
"apps.firstBatch": "首批沉淀",
"apps.callsUnavailable": "暂时无法读取调用记录",
"apps.memoryUnavailable": "暂时无法读取记忆状态",
"apps.memoryUnavailable": "记忆概览还没准备好",
"apps.importing": "对话持续导入中,即将沉淀你的记忆",
"apps.firstRunning": "正在沉淀第一批记忆",
"apps.firstQueued": "准备沉淀第一批记忆",
"apps.readingActivity": "正在读取动态",
"apps.readingActivity": "正在准备记忆概览",
"apps.emptyMemories": "还没有已生成的记忆",
"apps.callReadFailure": "已有记忆仍可查看;重试不会发起新的调用。",
"apps.memoryReadFailure": "读取失败不会当作没有记忆。",
"apps.memoryReadFailure": "后台恢复后会自动更新,也可以重新读取。",
"apps.savingConversations": "对话正在后台持续保存。",
"apps.waitingMemories": "历史已保存,记忆生成后会显示在这里。",
"apps.reread": "重新读取",
@@ -121,7 +127,12 @@ export const applicationsSpanishMessages: Record<
"apps.back": "Volver al inicio",
"apps.overview": "Resumen de memorias",
"apps.memories": "Memorias guardadas",
"apps.readingCount": "Leyendo el total de memorias…",
"apps.readingCount": "Comprobando las memorias locales…",
"apps.startingRetry":
"El servicio se está iniciando; se actualizará automáticamente",
"apps.countWillUpdate": "El total de memorias se actualizará automáticamente",
"apps.showingPrevious":
"Se muestran los datos anteriores; se actualizarán automáticamente",
"apps.unreadable": "No disponible",
"apps.unit": "",
"apps.scope": "Según los permisos de cada aplicación",
@@ -130,9 +141,12 @@ export const applicationsSpanishMessages: Record<
"apps.paused": "En pausa",
"apps.stoppedNew": "Se dejó de generar memorias nuevas",
"apps.checkingProcessing": "Comprobando el estado del procesamiento",
"apps.checkingStatus": "Verificando",
"apps.unchecked": "Sin verificar",
"apps.organizingNew": "Organizando nuevas conversaciones",
"apps.recentUpdate": "Última actualización",
"apps.checkingRecent": "Verificando",
"apps.recentWillUpdate": "La última actualización aparecerá automáticamente",
"apps.notGenerated": "Sin generar",
"apps.saved": "Memoria guardada",
"apps.separate": "Historial y memorias se procesan por separado",
@@ -140,16 +154,16 @@ export const applicationsSpanishMessages: Record<
"apps.activityUnavailable": "Actividad no disponible",
"apps.firstBatch": "Primeras memorias",
"apps.callsUnavailable": "No se pueden leer las consultas",
"apps.memoryUnavailable": "No se puede leer el estado de las memorias",
"apps.memoryUnavailable": "El resumen de memorias aún no está listo",
"apps.importing": "Importando conversaciones para generar memorias",
"apps.firstRunning": "Generando las primeras memorias",
"apps.firstQueued": "Preparando las primeras memorias",
"apps.readingActivity": "Leyendo actividad",
"apps.readingActivity": "Preparando el resumen de memorias",
"apps.emptyMemories": "Aún no hay memorias generadas",
"apps.callReadFailure":
"Puedes ver las memorias guardadas. Reintentar no inicia otra consulta.",
"apps.memoryReadFailure":
"Un error de lectura no significa que no haya memorias.",
"Se actualizará cuando el servicio se recupere. También puedes volver a leerlo.",
"apps.savingConversations": "Las conversaciones se guardan en segundo plano.",
"apps.waitingMemories":
"El historial está guardado. Las memorias aparecerán cuando se generen.",
+13 -4
View File
@@ -1305,7 +1305,12 @@ const baseEnglishMessages = {
"apps.back": "Back to home",
"apps.overview": "Memory overview",
"apps.memories": "Saved memories",
"apps.readingCount": "Loading memory count…",
"apps.readingCount": "Checking local memories…",
"apps.startingRetry":
"The background service is starting; this will update automatically",
"apps.countWillUpdate": "The memory count will update automatically",
"apps.showingPrevious":
"Showing the previous data; this will update automatically",
"apps.unreadable": "Unavailable",
"apps.unit": "items",
"apps.scope": "Used within app permissions",
@@ -1314,9 +1319,12 @@ const baseEnglishMessages = {
"apps.paused": "Paused",
"apps.stoppedNew": "No new memories are being created",
"apps.checkingProcessing": "Checking processing status",
"apps.checkingStatus": "Checking",
"apps.unchecked": "Needs review",
"apps.organizingNew": "Processing new conversations",
"apps.recentUpdate": "Recent update",
"apps.checkingRecent": "Checking",
"apps.recentWillUpdate": "The latest update will appear automatically",
"apps.notGenerated": "Not created yet",
"apps.saved": "Memory saved",
"apps.separate": "History and memories are processed separately",
@@ -1324,16 +1332,16 @@ const baseEnglishMessages = {
"apps.activityUnavailable": "Activity unavailable",
"apps.firstBatch": "First memories",
"apps.callsUnavailable": "Could not read call history",
"apps.memoryUnavailable": "Could not read memory status",
"apps.memoryUnavailable": "The memory overview is not ready yet",
"apps.importing": "Importing conversations before creating memories",
"apps.firstRunning": "Creating the first memories",
"apps.firstQueued": "Preparing the first memories",
"apps.readingActivity": "Loading activity",
"apps.readingActivity": "Preparing memory overview",
"apps.emptyMemories": "No memories created yet",
"apps.callReadFailure":
"You can still view saved memories. Retrying will not make a new call.",
"apps.memoryReadFailure":
"A failed read does not mean there are no memories.",
"This will update when the service recovers. You can also retry now.",
"apps.savingConversations":
"Conversations are still being saved in the background.",
"apps.waitingMemories": "History is saved. New memories will appear here.",
@@ -1613,6 +1621,7 @@ const baseEnglishMessages = {
"Newly imported original text, not yet memories",
"memory:来源归属": "Source connections",
"memory:可在沉淀记忆后查看视图": "Create memories to view the graph",
"memory:正在读取记忆地图…": "Loading memory map…",
"memory:查看选中来源原文": "View selected source text",
"memory:查看选中记忆原文": "View selected memory text",
"memory:查看原文": "View original conversation",
@@ -187,6 +187,7 @@ export const memoryChineseMessages = {
"memory:刚导入的原话,还不是沉淀后的记忆": "刚导入的原话,还不是沉淀后的记忆",
"memory:来源归属": "来源归属",
"memory:可在沉淀记忆后查看视图": "可在沉淀记忆后查看视图",
"memory:正在读取记忆地图…": "正在读取记忆地图…",
"memory:查看选中来源原文": "查看选中来源原文",
"memory:查看选中记忆原文": "查看选中记忆原文",
"memory:查看原文": "查看原文",
@@ -589,6 +590,7 @@ export const memorySpanishMessages: Record<
"memory:来源归属": "Relación con el origen",
"memory:可在沉淀记忆后查看视图":
"La vista estará disponible cuando se generen memorias",
"memory:正在读取记忆地图…": "Leyendo el mapa de memorias…",
"memory:查看选中来源原文": "Ver texto del origen seleccionado",
"memory:查看选中记忆原文": "Ver texto de la memoria seleccionada",
"memory:查看原文": "Ver texto original",
@@ -66,6 +66,7 @@ describe("Linux runtime scripts", () => {
'"stop"',
'"init"',
'"scan"',
'"recall"',
'"uninstall"',
]) {
assert.match(script, new RegExp(command));
@@ -87,11 +88,9 @@ describe("Linux runtime scripts", () => {
script,
/Exit 1 is a valid transient receipt[\s\S]{0,300}\[0, 1, 3\]/,
);
assert.match(script, /initial_queued/);
assert.match(script, /append_queued/);
assert.match(script, /FROM memory_write_jobs/);
assert.match(script, /append_recalled/);
assert.match(script, /idempotent_upgrade_instance_id_changed/);
assert.match(script, /post_upgrade_queued/);
assert.match(script, /post_upgrade_recalled/);
assert.match(script, /uninstall_preserved_data/);
assert.match(script, /is-enabled/);
assert.match(script, /is-active/);
@@ -10,7 +10,6 @@ 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`);
@@ -147,35 +146,18 @@ function doctorCheck(document, id) {
return check;
}
/**
* 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) {
async function waitForRecall(prefix, phrase) {
const deadline = Date.now() + 90_000;
const database = path.join(dataDir, "one-memory.sqlite3");
let attempt = 0;
while (Date.now() < deadline) {
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();
}
}
attempt += 1;
const result = run(
`${prefix}-${attempt}`,
["recall", phrase, "--json"],
[0, 1, 3],
15_000,
);
if (result.stdout.includes(phrase)) return true;
await new Promise((resolve) => setTimeout(resolve, 2_000));
}
return false;
@@ -338,11 +320,12 @@ try {
"--import",
"--json",
]);
observations.initial_queued = await waitForQueued(
observations.initial_recalled = await waitForRecall(
"recall-initial",
"Linux lifecycle initial phrase",
);
if (!observations.initial_queued)
throw new Error("initial import was not queued for the SDK");
if (!observations.initial_recalled)
throw new Error("initial import was not recalled");
status.import = "passed";
writeFileSync(
@@ -355,11 +338,12 @@ try {
[0],
30_000,
);
observations.append_queued = await waitForQueued(
observations.append_recalled = await waitForRecall(
"recall-appended",
"Linux lifecycle appended phrase",
);
if (!observations.append_queued)
throw new Error("appended turn was not queued for the SDK");
if (!observations.append_recalled)
throw new Error("appended turn was not recalled");
status.append_sync = "passed";
const beforeUpgrade = await waitForRunningStatus("status-before-upgrade");
@@ -394,11 +378,12 @@ try {
if (!observations.idempotent_upgrade_instance_id_changed) {
throw new Error("idempotent upgrade did not restart the Runtime");
}
observations.post_upgrade_queued = await waitForQueued(
observations.post_upgrade_recalled = await waitForRecall(
"recall-after-upgrade",
"Linux lifecycle appended phrase",
);
if (!observations.post_upgrade_queued) {
throw new Error("idempotent upgrade lost previously queued data");
if (!observations.post_upgrade_recalled) {
throw new Error("idempotent upgrade lost previously recalled data");
}
status.upgrade_idempotent = "passed";