Compare commits

..
Author SHA1 Message Date
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
19 changed files with 265 additions and 1060 deletions

No files matched your search

+2 -4
View File
@@ -2980,13 +2980,11 @@ def create_app(
"""
try:
client = await _sdk_client_handle(app).client()
# The map visualizes the active source scope, independently of the
# left list's 20-row pagination window.
# The map draws the page the list shows, so it reads the same
# source filter and window rather than the newest memories overall.
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,8 +1375,6 @@ 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"]} == {
@@ -1401,71 +1399,6 @@ 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
+9 -121
View File
@@ -172,7 +172,7 @@ it.each([
expect(sedimentation).toHaveBeenCalledOnce();
},
);
it("localizes startup recovery and empty states without leaking errors", async () => {
it("localizes failure and empty states without leaking errors", async () => {
summary.mockRejectedValue(new Error("synthetic_private_error"));
Object.assign(window.oneMemory ?? {}, {
getLanguagePreference: vi
@@ -191,15 +191,12 @@ it("localizes startup recovery and empty states without leaking errors", async (
</LanguageProvider>,
);
expect(
await screen.findByText(
"El servicio se está iniciando; se actualizará automáticamente",
),
await screen.findByText("No se puede leer el estado de las memorias"),
).toBeTruthy();
expect(screen.getByText("0")).toBeTruthy();
expect(screen.getByText("Aún no hay aplicaciones para mostrar")).toBeTruthy();
expect(
screen.queryByRole("button", { name: "Reintentar lectura" }),
).toBeNull();
screen.getByRole("button", { name: "Reintentar lectura" }),
).toBeTruthy();
expect(document.body.textContent).not.toContain("synthetic_private_error");
});
it("shows latest recorded call separately without exposing recent-update detail entries", async () => {
@@ -768,7 +765,7 @@ it("keeps authoritative home and processing data when only the source snapshot b
expect(screen.getAllByText("整理中")).toHaveLength(2);
expect(screen.queryByText("状态待核对")).toBeNull();
});
it("keeps a neutral zero while the first home summary read recovers", async () => {
it("does not turn a failed home summary into zero or expose a private error", async () => {
summary.mockRejectedValue(new Error("private offline detail"));
render(
<ApplicationsHome
@@ -778,117 +775,10 @@ it("keeps a neutral zero while the first home summary read recovers", async () =
onSettings={() => {}}
/>,
);
await waitFor(() =>
expect(screen.getByText("后台正在启动,稍后自动更新")).toBeTruthy(),
);
expect(screen.getByText("0")).toBeTruthy();
expect(screen.getByText("正在准备记忆概览")).toBeTruthy();
expect(screen.queryByText(/暂时?无法读取|读取失败/)).toBeNull();
await waitFor(() => expect(screen.getByText("暂无法读取")).toBeTruthy());
expect(screen.queryByText("0")).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
@@ -1310,7 +1200,7 @@ it("keeps the conversation-sync checking copy before the first home read", () =>
expect(screen.queryByText(/个应用已开启/)).toBeNull();
});
it("shows a provisional zero while the first summary read is pending", async () => {
it("does not claim there are no memories while the first summary read is pending", async () => {
summary.mockImplementation(() => new Promise(() => {}));
history.mockResolvedValue({
available: true,
@@ -1326,9 +1216,7 @@ it("shows a provisional zero while the first summary read is pending", async ()
onSettings={() => {}}
/>,
);
await screen.findByText("正在准备记忆概览");
expect(screen.getByText("0")).toBeTruthy();
expect(screen.getByText("正在核对本机记忆…")).toBeTruthy();
await screen.findByText("正在读取动态");
expect(screen.queryByText("还没有已生成的记忆")).toBeNull();
});
+45 -41
View File
@@ -195,7 +195,7 @@ export function ApplicationsHome({
null,
);
const [syncHealth, setSyncHealth] = useState<LiveSyncHealth[]>([]);
const [summaryFailures, setSummaryFailures] = useState(0);
const [failed, setFailed] = useState(false);
const [recentCall, setRecentCall] = useState<CallEvent | null>(null);
const [callEvents, setCallEvents] = useState<CallEvent[]>([]);
const [callState, setCallState] = useState<
@@ -554,17 +554,13 @@ export function ApplicationsHome({
busy = false;
if (stopped) return;
if (home.status === "fulfilled") {
setFailed(false);
// stale=true 表示这轮读取超时,数字不代表现状:保留上一次的概览,
// 等 10 秒后的下一轮轮询补上。重复 stale 仍要进入可恢复状态,
// 不能永远停在“正在核对”。
if (!("stale" in home.value) || !home.value.stale) {
setSummaryFailures(0);
// 等 10 秒后的下一轮轮询补上,绝不清零也不报错。
if (!("stale" in home.value) || !home.value.stale)
setSummary(home.value);
} else {
setSummaryFailures((count) => count + 1);
}
} else {
setSummaryFailures((count) => count + 1);
setFailed(true);
}
if (state.status === "fulfilled") setProcessing(state.value);
if (sync.status === "fulfilled") setSyncHealth(sync.value);
@@ -582,8 +578,7 @@ export function ApplicationsHome({
}, [active, requestKey]);
const visible = summary;
const latest = visible?.latest;
const summaryUnavailable = visible === null && summaryFailures >= 2;
const summaryStale = visible !== null && summaryFailures >= 2;
const summaryUnavailable = failed;
const hasCall =
recentCall !== null &&
(callState === "ready" || callState === "incomplete");
@@ -647,7 +642,7 @@ export function ApplicationsHome({
? t("apps.running")
: sedimentation?.enabled === false
? t("apps.paused")
: t("apps.checkingStatus");
: t("apps.unchecked");
const stateDetail =
sedimentation?.enabled === false
? t("apps.stoppedNew")
@@ -826,27 +821,42 @@ export function ApplicationsHome({
<h2>{t("apps.overview")}</h2>
</header>
<div className="r6-home-metrics">
<div role="status" aria-live="polite" aria-atomic="true">
<div>
<p>{t("apps.memories")}</p>
<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>
{/* 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>
</>
)}
</div>
<div>
<p>{t("apps.processing")}</p>
@@ -857,16 +867,12 @@ export function ApplicationsHome({
<p>{t("apps.recentUpdate")}</p>
<strong className={time ? "r6-home-time" : "r6-home-word"}>
{time ??
(visible
? t("apps.notGenerated")
: t("apps.checkingRecent"))}
(visible ? t("apps.notGenerated") : t("apps.unchecked"))}
</strong>
<small>
{time
? `${latestClock?.today ? `${t("apps.today")}· ` : ""}${t("apps.saved")}`
: visible
? t("apps.separate")
: t("apps.recentWillUpdate")}
: t("apps.separate")}
</small>
</div>
</div>
@@ -966,9 +972,7 @@ export function ApplicationsHome({
.join(" · ")
: activity === "importing"
? t("apps.savingConversations")
: activity === "loading"
? t("apps.recentWillUpdate")
: t("apps.waitingMemories")}
: t("apps.waitingMemories")}
</small>
</section>
{(activity === "unavailable" ||
+1 -146
View File
@@ -1,18 +1,11 @@
// @vitest-environment jsdom
import { translate } from "@one-memory/i18n";
import {
cleanup,
fireEvent,
render,
screen,
waitFor,
} from "@testing-library/react";
import { cleanup, render, screen } 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();
@@ -166,141 +159,3 @@ 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,11 +54,6 @@ 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);
+2 -168
View File
@@ -348,7 +348,6 @@ function Host({
processing = null,
loaded = true,
deferMemoryId,
graphValue = graph,
onViewHistory,
onConfigureModel = () => {},
libraryView,
@@ -364,7 +363,6 @@ function Host({
processing?: MemoryProcessingSummary | null;
loaded?: boolean;
deferMemoryId?: string;
graphValue?: MemoryGraphResponse;
onViewHistory?(): void;
onConfigureModel?(): void;
libraryView?: Parameters<typeof MemoryMap>[0]["libraryView"];
@@ -377,7 +375,7 @@ function Host({
hidden={hidden}
overlayBlocked={overlayBlocked}
memories={items}
graph={graphValue}
graph={graph}
sources={sources}
arrival={arrival}
onViewHistory={onViewHistory}
@@ -469,57 +467,6 @@ 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(
@@ -531,118 +478,6 @@ 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 }
@@ -1403,8 +1238,7 @@ it("drops a pending source selection when that source is revoked", async () => {
}}
/>,
);
// Memories already distilled from a revoked source stay on the map, but the
// revoked source can no longer be the selection.
expect(screen.queryByRole("button", { name: "图中选择 Codex" })).toBeNull();
expect(screen.getByTestId("canvas").dataset.selected).not.toBe(
"source:codex",
);
+13 -66
View File
@@ -95,9 +95,6 @@ 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;
@@ -319,31 +316,6 @@ 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,
@@ -375,21 +347,7 @@ export function MemoryMap(props: Props) {
confidence: null,
})),
};
}, [
baseGraph,
graph,
axis,
filtered,
active,
groupKey,
search,
sources,
serverPaged,
library?.query,
library?.graphLoading,
library?.graphFailed,
library?.page?.total,
]);
}, [graph, axis, filtered, active, groupKey, search, sources, serverPaged]);
const visibleGroupNode = groupNode
? (projection.nodes.find(
(node) =>
@@ -1101,33 +1059,22 @@ export function MemoryMap(props: Props) {
) : (
<div className="map-empty-stage">
<Network size={32} strokeWidth={1} />
<p
{...(library?.graphFailed || library?.graphLoading
? { role: "status", "aria-live": "polite" as const }
: {})}
>
<p>
{library?.stale
? text("可在沉淀记忆后查看视图")
: library?.graphFailed || library?.failed
: library?.failed
? text("记忆暂时无法读取")
: library?.graphLoading
? text("正在读取记忆地图…")
: library?.page &&
(library.query || library.source || groupKey)
? text("当前筛选没有可显示的记忆")
: !loaded
? text("正在读取记忆…")
: active.length
? text("当前筛选没有可显示的记忆")
: arrival.importedMessages > 0
? text("已保存的对话还未生成记忆")
: 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,7 +5,6 @@ import type {
SdkMemoryRecord,
} from "@one-memory/contracts";
import {
act,
cleanup,
fireEvent,
render,
@@ -64,9 +63,6 @@ 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;
@@ -95,13 +91,6 @@ 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>
@@ -144,15 +133,6 @@ vi.mock("./MemoryMap", () => ({
>
pick workbuddy
</button>
<button
type="button"
onClick={() => {
libraryView?.onSource("");
libraryView?.resetPage();
}}
>
pick all
</button>
</div>
),
}));
@@ -234,23 +214,11 @@ 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: 300 },
{ source: undefined, offset: 0, limit: 100 },
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 />);
@@ -283,150 +251,12 @@ 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: 300 },
{ source: "workbuddy", offset: 0, limit: 100 },
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"));
+23 -43
View File
@@ -166,14 +166,11 @@ 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 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);
// 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);
// 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), []);
@@ -194,17 +191,6 @@ 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.
@@ -247,26 +233,22 @@ export function MemorySpace({
request.signal,
),
// The overlay is decoration: the list stands without it. It reads
// 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 }),
),
// 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),
]);
if (cancelled) return;
if (sdkSearchWindow.current.key === windowKey)
@@ -283,7 +265,7 @@ export function MemorySpace({
settledSource: sdkLibrarySource,
settledQuery: sdkLibraryQuery,
});
setSdkGraph({ scope: windowKey, ...graph });
setSdkGraph(graph);
} catch {
if (cancelled || request.signal.aborted) return;
setSdkLibrary((current) => ({
@@ -1044,8 +1026,6 @@ export function MemorySpace({
page: sdkLibrary.page,
loading: sdkLibrary.loading,
failed: sdkLibrary.failed,
graphLoading: !sdkGraphSettled,
graphFailed: sdkGraphFailed,
stale: false,
settledSource: sdkLibrary.settledSource,
settledQuery: sdkLibrary.settledQuery,
@@ -1060,7 +1040,7 @@ export function MemorySpace({
}
hidden={!active || displayedMode !== "map"}
memories={sdkLibrary.page?.items ?? []}
graph={visibleSdkGraph}
graph={sdkGraph}
sources={sources}
processing={processing}
arrival={memoryArrival}
+26 -155
View File
@@ -1,13 +1,4 @@
import {
type CSSProperties,
useCallback,
useEffect,
useId,
useLayoutEffect,
useRef,
useState,
} from "react";
import { createPortal } from "react-dom";
import { useEffect, useId, useRef, useState } from "react";
import { useGuiLanguage } from "./gui-language";
export interface OptionMenuItem {
@@ -17,43 +8,6 @@ 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;
@@ -63,7 +17,6 @@ export function OptionMenu(props: {
value: string;
disabled?: boolean;
className?: string;
panelClassName?: string;
options: readonly OptionMenuItem[];
onChange: (value: string) => void;
}) {
@@ -79,14 +32,8 @@ 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`;
@@ -115,77 +62,20 @@ 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) => {
const target = event.target as Node;
if (!root.current?.contains(target) && !panel.current?.contains(target))
setOpen(false);
if (!root.current?.contains(event.target as Node)) 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 (
@@ -215,12 +105,7 @@ export function OptionMenu(props: {
}
disabled={props.disabled}
onBlur={(event) => {
if (panelMouseDown.current) return;
const target = event.relatedTarget as Node | null;
if (
!root.current?.contains(target) &&
!panel.current?.contains(target)
)
if (!root.current?.contains(event.relatedTarget as Node | null))
setOpen(false);
}}
onClick={() => {
@@ -267,43 +152,29 @@ export function OptionMenu(props: {
>
{visibleLabel}
</button>
{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,
)}
{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>
)}
</div>
);
}
+8 -5
View File
@@ -144,12 +144,13 @@ button svg {
background: #edf0f3;
}
.om-option-menu-panel {
box-sizing: border-box;
position: fixed;
z-index: 1100;
position: absolute;
z-index: 30;
top: calc(100% + 4px);
left: 0;
right: 0;
max-height: 280px;
overflow: auto;
overscroll-behavior: contain;
padding: 6px;
background: #fff;
border: 1px solid #c0ccd5;
@@ -177,8 +178,10 @@ 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-memory 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) {
color: #262a2e;
background: #e7eef2;
border-color: transparent;
+15 -5
View File
@@ -72,27 +72,37 @@
border-color: #7892a6;
box-shadow: 0 0 0 3px rgb(85 124 147 / 12%);
}
.r6-language-option-menu-panel--settings {
.r6-settings .r6-language-option-menu .om-option-menu-panel {
top: calc(100% + 6px);
padding: 6px;
border-color: #c6d2da;
box-shadow: 0 12px 32px rgb(48 71 87 / 13%);
}
.r6-language-option-menu-panel--settings [role="option"] {
.r6-settings .r6-language-option-menu .om-option-menu-panel [role="option"] {
min-height: 38px;
padding: 8px 10px;
color: #304757;
font-size: 13px;
line-height: 22px;
}
.r6-language-option-menu-panel--settings [role="option"][aria-selected="true"] {
.r6-settings
.r6-language-option-menu
.om-option-menu-panel
[role="option"][aria-selected="true"] {
background: #dfe9ef;
font-weight: 500;
}
.r6-language-option-menu-panel--settings [role="option"][data-active="true"] {
.r6-settings
.r6-language-option-menu
.om-option-menu-panel
[role="option"][data-active="true"] {
color: #304757;
background: #e9f0f4;
}
.r6-language-option-menu-panel--settings [role="option"]:active:not(:disabled) {
.r6-settings
.r6-language-option-menu
.om-option-menu-panel
[role="option"]:active:not(:disabled) {
color: #304757;
background: #d4e2ea;
}
@@ -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.
@@ -7,10 +7,7 @@ export const applicationsChineseMessages = {
"apps.back": "返回首页",
"apps.overview": "记忆概览",
"apps.memories": "已沉淀记忆",
"apps.readingCount": "正在核对本机记忆…",
"apps.startingRetry": "后台正在启动,稍后自动更新",
"apps.countWillUpdate": "记忆数量稍后自动更新",
"apps.showingPrevious": "正在显示上次数据,稍后自动更新",
"apps.readingCount": "正在读取已沉淀记忆数量…",
"apps.unreadable": "暂无法读取",
"apps.unit": "条",
"apps.scope": "按应用授权范围使用",
@@ -19,12 +16,9 @@ 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": "历史与记忆分别处理",
@@ -32,14 +26,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": "重新读取",
@@ -127,12 +121,7 @@ export const applicationsSpanishMessages: Record<
"apps.back": "Volver al inicio",
"apps.overview": "Resumen de memorias",
"apps.memories": "Memorias guardadas",
"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.readingCount": "Leyendo el total de memorias…",
"apps.unreadable": "No disponible",
"apps.unit": "",
"apps.scope": "Según los permisos de cada aplicación",
@@ -141,12 +130,9 @@ 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",
@@ -154,16 +140,16 @@ export const applicationsSpanishMessages: Record<
"apps.activityUnavailable": "Actividad no disponible",
"apps.firstBatch": "Primeras memorias",
"apps.callsUnavailable": "No se pueden leer las consultas",
"apps.memoryUnavailable": "El resumen de memorias aún no está listo",
"apps.memoryUnavailable": "No se puede leer el estado de las memorias",
"apps.importing": "Importando conversaciones para generar memorias",
"apps.firstRunning": "Generando las primeras memorias",
"apps.firstQueued": "Preparando las primeras memorias",
"apps.readingActivity": "Preparando el resumen de memorias",
"apps.readingActivity": "Leyendo actividad",
"apps.emptyMemories": "Aún no hay memorias generadas",
"apps.callReadFailure":
"Puedes ver las memorias guardadas. Reintentar no inicia otra consulta.",
"apps.memoryReadFailure":
"Se actualizará cuando el servicio se recupere. También puedes volver a leerlo.",
"Un error de lectura no significa que no haya memorias.",
"apps.savingConversations": "Las conversaciones se guardan en segundo plano.",
"apps.waitingMemories":
"El historial está guardado. Las memorias aparecerán cuando se generen.",
+4 -13
View File
@@ -1305,12 +1305,7 @@ const baseEnglishMessages = {
"apps.back": "Back to home",
"apps.overview": "Memory overview",
"apps.memories": "Saved memories",
"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.readingCount": "Loading memory count…",
"apps.unreadable": "Unavailable",
"apps.unit": "items",
"apps.scope": "Used within app permissions",
@@ -1319,12 +1314,9 @@ 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",
@@ -1332,16 +1324,16 @@ const baseEnglishMessages = {
"apps.activityUnavailable": "Activity unavailable",
"apps.firstBatch": "First memories",
"apps.callsUnavailable": "Could not read call history",
"apps.memoryUnavailable": "The memory overview is not ready yet",
"apps.memoryUnavailable": "Could not read memory status",
"apps.importing": "Importing conversations before creating memories",
"apps.firstRunning": "Creating the first memories",
"apps.firstQueued": "Preparing the first memories",
"apps.readingActivity": "Preparing memory overview",
"apps.readingActivity": "Loading activity",
"apps.emptyMemories": "No memories created yet",
"apps.callReadFailure":
"You can still view saved memories. Retrying will not make a new call.",
"apps.memoryReadFailure":
"This will update when the service recovers. You can also retry now.",
"A failed read does not mean there are no memories.",
"apps.savingConversations":
"Conversations are still being saved in the background.",
"apps.waitingMemories": "History is saved. New memories will appear here.",
@@ -1621,7 +1613,6 @@ 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,7 +187,6 @@ export const memoryChineseMessages = {
"memory:刚导入的原话,还不是沉淀后的记忆": "刚导入的原话,还不是沉淀后的记忆",
"memory:来源归属": "来源归属",
"memory:可在沉淀记忆后查看视图": "可在沉淀记忆后查看视图",
"memory:正在读取记忆地图…": "正在读取记忆地图…",
"memory:查看选中来源原文": "查看选中来源原文",
"memory:查看选中记忆原文": "查看选中记忆原文",
"memory:查看原文": "查看原文",
@@ -590,7 +589,6 @@ 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,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";