Compare commits
2
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
896a9c6c83 | ||
|
|
193fabeeb4 |
No files matched your search
@@ -4,6 +4,10 @@ Hy Memory and MemHub are versioned and released independently.
|
||||
|
||||
## Unreleased
|
||||
|
||||
- Reused the existing three-column memory map for SDK-authority list/detail
|
||||
views and removed the retired "original evidence" surface. SDK-native status,
|
||||
source, conversation time and creation time now render without fabricated
|
||||
revisions; source and text filters are applied by the paged Core endpoint.
|
||||
- Fixed legacy-to-SDK startup migration to adopt native Coding Experience and
|
||||
Working Fact IDs instead of importing them as Chat layers. Raw migration now
|
||||
rebuilds bounded user/assistant turns from the latest three days, excludes
|
||||
|
||||
@@ -3115,19 +3115,41 @@ def create_app(
|
||||
)
|
||||
async def sdk_memory_library(
|
||||
statuses: list[str] = Query(default=["active"]), # noqa: B008
|
||||
source: str | None = Query(default=None, min_length=1),
|
||||
query: str | None = Query(default=None, max_length=512),
|
||||
offset: int = Query(default=0, ge=0),
|
||||
limit: int = Query(default=100, ge=1, le=500),
|
||||
) -> HySdkMemoryPage:
|
||||
try:
|
||||
client = await _sdk_client_handle(app).client()
|
||||
needle = (query or "").strip().casefold()
|
||||
raw = await asyncio.to_thread(
|
||||
client.list_memories,
|
||||
user_id="local",
|
||||
statuses=list(statuses),
|
||||
include_sources=[source] if source else None,
|
||||
offset=0 if needle else offset,
|
||||
limit=100_000 if needle else limit,
|
||||
)
|
||||
page = HySdkMemoryPage.from_sdk(
|
||||
raw,
|
||||
limit=100_000 if needle else limit,
|
||||
offset=0 if needle else offset,
|
||||
)
|
||||
if not needle:
|
||||
return page
|
||||
matches = tuple(
|
||||
memory
|
||||
for memory in page.memories
|
||||
if needle
|
||||
in f"{memory.content} {' '.join(memory.tags)}".casefold()
|
||||
)
|
||||
return HySdkMemoryPage(
|
||||
memories=matches[offset : offset + limit],
|
||||
total=len(matches),
|
||||
limit=limit,
|
||||
offset=offset,
|
||||
limit=limit,
|
||||
)
|
||||
return HySdkMemoryPage.from_sdk(raw, limit=limit, offset=offset)
|
||||
except Exception as error:
|
||||
raise _sdk_http_error(error, "hy_memory_list_failed") from None
|
||||
|
||||
|
||||
@@ -278,3 +278,71 @@ def test_sdk_user_profile_http_returns_native_assembled_profile(tmp_path):
|
||||
}
|
||||
|
||||
asyncio.run(scenario())
|
||||
|
||||
|
||||
@pytest.mark.fresh_sdk
|
||||
def test_sdk_memory_library_forwards_source_and_filters_query(tmp_path):
|
||||
async def scenario():
|
||||
app = application(tmp_path)
|
||||
calls = []
|
||||
|
||||
class Client:
|
||||
def list_memories(self, **kwargs):
|
||||
calls.append(kwargs)
|
||||
rows = [
|
||||
{
|
||||
"memory_id": "release",
|
||||
"content": "Release plan",
|
||||
"layer": "l2_fact",
|
||||
"status": "active",
|
||||
"user_id": "local",
|
||||
"agent_id": "all-apps",
|
||||
"session_id": "s1",
|
||||
"source": "cursor",
|
||||
"tags": ["shipping"],
|
||||
},
|
||||
{
|
||||
"memory_id": "other",
|
||||
"content": "Unrelated",
|
||||
"layer": "l2_fact",
|
||||
"status": "active",
|
||||
"user_id": "local",
|
||||
"agent_id": "all-apps",
|
||||
"session_id": "s2",
|
||||
"source": "cursor",
|
||||
"tags": [],
|
||||
},
|
||||
]
|
||||
return {
|
||||
"vdb": {
|
||||
"memories": rows,
|
||||
"total": len(rows),
|
||||
"limit": kwargs["limit"],
|
||||
"offset": kwargs["offset"],
|
||||
}
|
||||
}
|
||||
|
||||
class Handle:
|
||||
async def client(self):
|
||||
return Client()
|
||||
|
||||
async def close(self):
|
||||
return None
|
||||
|
||||
async with app.router.lifespan_context(app):
|
||||
app.state.sdk_client_handle = Handle()
|
||||
async with client_for(app) as client:
|
||||
response = await client.get(
|
||||
"/api/v2/memory-library",
|
||||
params={"source": "cursor", "query": "release"},
|
||||
)
|
||||
assert response.status_code == 200
|
||||
body = response.json()
|
||||
assert body["total"] == 1
|
||||
assert [item["memory_id"] for item in body["memories"]] == [
|
||||
"release"
|
||||
]
|
||||
assert calls[0]["include_sources"] == ["cursor"]
|
||||
assert calls[0]["limit"] == 100_000
|
||||
|
||||
asyncio.run(scenario())
|
||||
@@ -177,7 +177,7 @@ it("OBS-22: the graph inspector has no editing entry and never says changes cann
|
||||
await act(async () => {});
|
||||
// The detail pane is open and the page is being re-read, which used to be
|
||||
// the exact condition that printed the warning.
|
||||
expect(screen.getByText("共同原文")).toBeTruthy();
|
||||
expect(screen.getByRole("heading", { name: "第一条" })).toBeTruthy();
|
||||
expect(screen.queryByText(/暂不能保存更改/)).toBeNull();
|
||||
});
|
||||
|
||||
@@ -224,11 +224,11 @@ it("OBS-15: switching memory shows a read in progress instead of the previous me
|
||||
// screen while it is open.
|
||||
render(<Host simplified deferMemoryId="m1" />);
|
||||
const list = screen.getByRole("list", { name: "地图记忆列表" });
|
||||
expect(screen.getByText("共同原文")).toBeTruthy();
|
||||
expect(screen.getByRole("heading", { name: "第一条" })).toBeTruthy();
|
||||
await user.click(within(list).getByRole("button", { name: /第二条/ }));
|
||||
// The first memory's evidence must not still be presented as if it
|
||||
// belonged to the selection that is now the second one.
|
||||
expect(screen.queryByText("共同原文")).toBeNull();
|
||||
expect(screen.queryByRole("heading", { name: "第一条" })).toBeNull();
|
||||
expect(screen.getByRole("status").textContent).toContain("正在读取这条记忆");
|
||||
});
|
||||
|
||||
@@ -253,10 +253,10 @@ it("opens the newest listed memory in the detail pane and keeps it closed after
|
||||
const list = screen.getByRole("list", { name: "地图记忆列表" });
|
||||
const first = within(list).getByRole("button", { name: /第一条/ });
|
||||
expect(first.getAttribute("aria-pressed")).toBe("true");
|
||||
expect(screen.getByText("共同原文")).toBeTruthy();
|
||||
expect(screen.getByText(/原对话/)).toBeTruthy();
|
||||
expect(screen.getByRole("heading", { name: "第一条" })).toBeTruthy();
|
||||
expect(screen.queryByText(/原对话/)).toBeNull();
|
||||
await user.click(screen.getByRole("button", { name: "关闭记忆详情" }));
|
||||
expect(screen.queryByText("共同原文")).toBeNull();
|
||||
expect(screen.queryByRole("heading", { name: "第一条" })).toBeNull();
|
||||
});
|
||||
|
||||
it("OBS-14: lists what the conversation settled instead of asking to settle first", async () => {
|
||||
|
||||
@@ -21,6 +21,7 @@ const api = vi.hoisted(() => ({
|
||||
legacyGraph: vi.fn(),
|
||||
sdkLibrary: vi.fn(),
|
||||
sdkGraph: vi.fn(),
|
||||
sdkMemory: vi.fn(),
|
||||
}));
|
||||
vi.mock("./api", () => ({
|
||||
getMemoryAuthority: api.authority,
|
||||
@@ -29,6 +30,9 @@ vi.mock("./api", () => ({
|
||||
getMemoryGraph: api.legacyGraph,
|
||||
getSdkMemoryLibrary: api.sdkLibrary,
|
||||
getSdkMemoryGraph: api.sdkGraph,
|
||||
getSdkMemory: api.sdkMemory,
|
||||
updateSdkMemory: vi.fn(),
|
||||
deleteSdkMemory: vi.fn(),
|
||||
getMemoryProcessing: async () => null,
|
||||
getModelProfiles: async () => [],
|
||||
apiErrorCode: () => null,
|
||||
@@ -140,8 +144,9 @@ it("draws the map from SDK memories on SDK authority, without legacy reads", asy
|
||||
expect(api.legacyGraph).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("opens a memory from the SDK map in the SDK library", async () => {
|
||||
it("opens an SDK memory in place instead of switching away from the map", async () => {
|
||||
sdkAuthority();
|
||||
api.sdkMemory.mockResolvedValue(record("a"));
|
||||
|
||||
render(<MemorySpace simplified active />);
|
||||
await waitFor(() =>
|
||||
@@ -149,10 +154,10 @@ it("opens a memory from the SDK map in the SDK library", async () => {
|
||||
);
|
||||
fireEvent.click(screen.getByText("open a"));
|
||||
|
||||
const library = await screen.findByTestId("sdk-library");
|
||||
expect(library.textContent).toBe("a");
|
||||
expect(library.closest("[hidden]")).toBeNull();
|
||||
expect(screen.getByTestId("map").hidden).toBe(true);
|
||||
await waitFor(() => expect(api.sdkMemory).toHaveBeenCalledWith("a"));
|
||||
// The inspector stays on the map; the reader is never moved to a second list.
|
||||
expect(screen.getByTestId("map").hidden).toBe(false);
|
||||
expect(screen.getByTestId("sdk-library").textContent).toBe("");
|
||||
});
|
||||
|
||||
it("keeps asking for the authority until Core answers", async () => {
|
||||
|
||||
@@ -5,6 +5,7 @@ import type {
|
||||
ImportJob,
|
||||
MemoryDetail,
|
||||
MemoryGraphResponse,
|
||||
MemoryLibraryPage,
|
||||
MemoryProcessingSummary,
|
||||
SdkAskMemory,
|
||||
SourceResponse,
|
||||
@@ -32,6 +33,7 @@ import {
|
||||
apiErrorCode,
|
||||
askMemoriesStream,
|
||||
correctMemory,
|
||||
deleteSdkMemory,
|
||||
digestMemoryGraph,
|
||||
getMemories,
|
||||
getMemory,
|
||||
@@ -39,6 +41,7 @@ import {
|
||||
getMemoryGraph,
|
||||
getMemoryProcessing,
|
||||
getModelProfiles,
|
||||
getSdkMemory,
|
||||
getSdkMemoryGraph,
|
||||
getSdkMemoryLibrary,
|
||||
manualExtract,
|
||||
@@ -47,6 +50,7 @@ import {
|
||||
saveMemoryNote,
|
||||
setExtractionWindow,
|
||||
syncMemoryIndex,
|
||||
updateSdkMemory,
|
||||
} from "./api";
|
||||
import { ConversationSearch } from "./ConversationSearch";
|
||||
import { clockStamp } from "./clock-stamp";
|
||||
@@ -58,7 +62,7 @@ import { subscribePoll } from "./poll-scheduler";
|
||||
import { R6Button } from "./R6Controls";
|
||||
import { SdkMemoryLibrary } from "./SdkMemoryLibrary";
|
||||
import { SdkRawHistory } from "./SdkRawHistory";
|
||||
import { sdkMapMemory } from "./sdk-memory-map";
|
||||
import { sdkDetail, sdkLibraryPage, sdkMapMemory } from "./sdk-memory-map";
|
||||
import { sourceNameOr } from "./source-label";
|
||||
import { traceUiAction } from "./ui-trace";
|
||||
import { useMemoryLibrary } from "./use-memory-library";
|
||||
@@ -296,9 +300,9 @@ export function MemorySpace({
|
||||
text: string;
|
||||
memories: AskMemory[];
|
||||
} | null>(null);
|
||||
const [sdkFocusedMemoryId, setSdkFocusedMemoryId] = useState<string | null>(
|
||||
null,
|
||||
);
|
||||
// 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 map on SDK authority. Legacy reads (library page, governed graph)
|
||||
// are refused there, so it draws SDK memories and the v2 graph instead.
|
||||
const [sdkMap, setSdkMap] = useState<{
|
||||
@@ -306,6 +310,21 @@ export function MemorySpace({
|
||||
graph: MemoryGraphResponse;
|
||||
} | null>(null);
|
||||
const [sdkMapFailed, setSdkMapFailed] = useState(false);
|
||||
// A write on SDK authority has no governed refresh to ride on; bumping this
|
||||
// re-reads the SDK page and graph after an edit or a delete.
|
||||
const [sdkMapRevision, setSdkMapRevision] = useState(0);
|
||||
const bumpSdkMap = useCallback(() => setSdkMapRevision((v) => v + 1), []);
|
||||
// The map's left list pages through the SDK library the same way it pages
|
||||
// through the governed one: the list is always one page, and the count above
|
||||
// it is the library's total, not the page's length.
|
||||
const [sdkLibrary, setSdkLibrary] = useState<{
|
||||
page: MemoryLibraryPage | null;
|
||||
loading: boolean;
|
||||
failed: boolean;
|
||||
}>({ page: null, loading: false, failed: false });
|
||||
const [sdkLibraryQuery, setSdkLibraryQuery] = useState("");
|
||||
const [sdkLibrarySource, setSdkLibrarySource] = useState("");
|
||||
const [sdkLibraryOffset, setSdkLibraryOffset] = useState(0);
|
||||
const sdkMapVisible =
|
||||
memoryAuthority === "sdk" &&
|
||||
simplified &&
|
||||
@@ -313,6 +332,9 @@ export function MemorySpace({
|
||||
displayedMode === "map";
|
||||
useEffect(() => {
|
||||
if (!sdkMapVisible) return;
|
||||
// A write bumps the revision; reading it here re-runs the read at once
|
||||
// instead of waiting for the 30s poll.
|
||||
void sdkMapRevision;
|
||||
let cancelled = false;
|
||||
let controller: AbortController | null = null;
|
||||
const read = async () => {
|
||||
@@ -341,7 +363,54 @@ export function MemorySpace({
|
||||
clearInterval(timer);
|
||||
controller?.abort();
|
||||
};
|
||||
}, [sdkMapVisible]);
|
||||
}, [sdkMapVisible, sdkMapRevision]);
|
||||
useEffect(() => {
|
||||
if (memoryAuthority !== "sdk" || !simplified || !active) return;
|
||||
if (displayedMode !== "map") return;
|
||||
void sdkMapRevision;
|
||||
let cancelled = false;
|
||||
const controller = new AbortController();
|
||||
setSdkLibrary((current) => ({ ...current, loading: true }));
|
||||
void getSdkMemoryLibrary(
|
||||
{
|
||||
statuses: ["active"],
|
||||
source: sdkLibrarySource || undefined,
|
||||
query: sdkLibraryQuery || undefined,
|
||||
offset: sdkLibraryOffset,
|
||||
limit: 100,
|
||||
},
|
||||
controller.signal,
|
||||
)
|
||||
.then((page) => {
|
||||
if (cancelled) return;
|
||||
setSdkLibrary({
|
||||
page: sdkLibraryPage(page),
|
||||
loading: false,
|
||||
failed: false,
|
||||
});
|
||||
})
|
||||
.catch(() => {
|
||||
if (cancelled || controller.signal.aborted) return;
|
||||
setSdkLibrary((current) => ({
|
||||
...current,
|
||||
loading: false,
|
||||
failed: true,
|
||||
}));
|
||||
});
|
||||
return () => {
|
||||
cancelled = true;
|
||||
controller.abort();
|
||||
};
|
||||
}, [
|
||||
memoryAuthority,
|
||||
simplified,
|
||||
active,
|
||||
displayedMode,
|
||||
sdkLibraryOffset,
|
||||
sdkLibraryQuery,
|
||||
sdkLibrarySource,
|
||||
sdkMapRevision,
|
||||
]);
|
||||
const [draft, setDraft] = useState("");
|
||||
const [selection, setSelection] = useState<Set<string>>(new Set());
|
||||
const bulkInFlight = useRef(false);
|
||||
@@ -1025,8 +1094,9 @@ export function MemorySpace({
|
||||
open(memory.memory_id);
|
||||
return;
|
||||
}
|
||||
setSdkFocusedMemoryId(memory.memory_id);
|
||||
setMode("library");
|
||||
// An SDK answer cites SDK memories; they open in the inspector like any
|
||||
// other rather than switching the reader to a second list.
|
||||
void open(memory.memory_id);
|
||||
}
|
||||
|
||||
async function saveAnswer(draft: { content: string }) {
|
||||
@@ -1075,7 +1145,13 @@ export function MemorySpace({
|
||||
setDetailFreshness(null);
|
||||
setDetailLoad({ memoryId, status: "loading" });
|
||||
try {
|
||||
const result = await getMemory(memoryId);
|
||||
// Governed detail is refused on SDK authority, so the inspector reads the
|
||||
// same memory through v2 and renders it in place. Staying here keeps the
|
||||
// map's list, graph and detail where the design put them.
|
||||
const result =
|
||||
memoryAuthority === "sdk"
|
||||
? sdkDetail(await getSdkMemory(memoryId))
|
||||
: await getMemory(memoryId);
|
||||
if (epoch !== detailEpoch.current || result.memory.memory_id !== memoryId)
|
||||
return;
|
||||
setDetail(result);
|
||||
@@ -1123,6 +1199,28 @@ export function MemorySpace({
|
||||
return;
|
||||
setBusy(true);
|
||||
setError(null);
|
||||
if (memoryAuthority === "sdk") {
|
||||
// An SDK memory is removed outright: there is no revision to undo.
|
||||
if (actionName !== "delete") {
|
||||
setBusy(false);
|
||||
return;
|
||||
}
|
||||
const trace = traceUiAction("memory", "confirm");
|
||||
try {
|
||||
await deleteSdkMemory(detail.memory.memory_id);
|
||||
trace.done("succeeded");
|
||||
closeDetail();
|
||||
bumpSdkMap();
|
||||
} catch (reason) {
|
||||
trace.done(writeTraceStatus(reason), {
|
||||
reason: "memory_action_failed",
|
||||
});
|
||||
setError(message(reason, t));
|
||||
} finally {
|
||||
setBusy(false);
|
||||
}
|
||||
return;
|
||||
}
|
||||
const intent = `${detail.memory.memory_id}:${actionName}:${detail.memory.revision}`;
|
||||
const requestId = requestIdFor(requestIds.current, intent);
|
||||
const trace = traceUiAction("memory", "confirm");
|
||||
@@ -1329,6 +1427,28 @@ export function MemorySpace({
|
||||
return;
|
||||
setBusy(true);
|
||||
setError(null);
|
||||
if (memoryAuthority === "sdk") {
|
||||
// A rewrite replaces the SDK row instead of adding a revision, so there
|
||||
// is no revision to send and nothing to reconcile afterwards.
|
||||
const trace = traceUiAction("memory", "submit");
|
||||
try {
|
||||
const saved = await updateSdkMemory(detail.memory.memory_id, draft);
|
||||
trace.done("succeeded");
|
||||
setDetail(sdkDetail(saved));
|
||||
setDraft(saved.content);
|
||||
bumpSdkMap();
|
||||
} catch (reason) {
|
||||
trace.done(writeTraceStatus(reason), {
|
||||
reason: isDefiniteRefusal(reason)
|
||||
? "memory_correct_failed"
|
||||
: "memory_correct_result_unknown",
|
||||
});
|
||||
setError(message(reason, t));
|
||||
} finally {
|
||||
setBusy(false);
|
||||
}
|
||||
return;
|
||||
}
|
||||
const intent = `${detail.memory.memory_id}:correct:${detail.memory.revision}`;
|
||||
const requestId = requestIdFor(requestIds.current, intent);
|
||||
const trace = traceUiAction("memory", "submit");
|
||||
@@ -1510,7 +1630,8 @@ export function MemorySpace({
|
||||
{
|
||||
filter: "",
|
||||
shown: new Intl.NumberFormat(locale).format(
|
||||
sdkMap.memories.length,
|
||||
sdkLibrary.page?.items.length ??
|
||||
sdkMap.memories.length,
|
||||
),
|
||||
},
|
||||
)
|
||||
@@ -1887,23 +2008,37 @@ export function MemorySpace({
|
||||
focusDialogue={focusDialogue}
|
||||
onFocusDialogueConsumed={onFocusDialogueConsumed}
|
||||
libraryView={
|
||||
simplified && memoryAuthority !== "sdk"
|
||||
simplified && memoryAuthority === "sdk"
|
||||
? {
|
||||
query: mapQuery,
|
||||
source: mapSource,
|
||||
page: mapLibrary.page,
|
||||
loading: mapLibrary.loading,
|
||||
failed: mapLibrary.failed,
|
||||
stale: sourceSnapshotStale,
|
||||
onQuery: setMapQuery,
|
||||
onSource: setMapSource,
|
||||
resetPage: () => mapLibrary.go(0),
|
||||
onPage: mapLibrary.go,
|
||||
onRetry: () => {
|
||||
void mapLibrary.refresh().catch(() => {});
|
||||
},
|
||||
query: sdkLibraryQuery,
|
||||
source: sdkLibrarySource,
|
||||
page: sdkLibrary.page,
|
||||
loading: sdkLibrary.loading,
|
||||
failed: sdkLibrary.failed,
|
||||
stale: false,
|
||||
onQuery: setSdkLibraryQuery,
|
||||
onSource: setSdkLibrarySource,
|
||||
resetPage: () => setSdkLibraryOffset(0),
|
||||
onPage: (offset) => setSdkLibraryOffset(offset),
|
||||
onRetry: bumpSdkMap,
|
||||
}
|
||||
: undefined
|
||||
: simplified
|
||||
? {
|
||||
query: mapQuery,
|
||||
source: mapSource,
|
||||
page: mapLibrary.page,
|
||||
loading: mapLibrary.loading,
|
||||
failed: mapLibrary.failed,
|
||||
stale: sourceSnapshotStale,
|
||||
onQuery: setMapQuery,
|
||||
onSource: setMapSource,
|
||||
resetPage: () => mapLibrary.go(0),
|
||||
onPage: mapLibrary.go,
|
||||
onRetry: () => {
|
||||
void mapLibrary.refresh().catch(() => {});
|
||||
},
|
||||
}
|
||||
: undefined
|
||||
}
|
||||
onMutationComplete={(id) => {
|
||||
void refresh(query)
|
||||
@@ -1951,14 +2086,6 @@ export function MemorySpace({
|
||||
streaming={streaming}
|
||||
onDraft={setDraft}
|
||||
onOpenMemory={(memoryId) => {
|
||||
if (memoryAuthority === "sdk") {
|
||||
// Governed detail is refused on SDK authority; the SDK library
|
||||
// opens, edits and deletes the same memory through v2.
|
||||
setSdkFocusedMemoryId(memoryId);
|
||||
if (simplified) setManagementOpen(true);
|
||||
else setMode("library");
|
||||
return;
|
||||
}
|
||||
const recalled = answerMemories.current.find(
|
||||
(memory) => memory.memory_id === memoryId,
|
||||
);
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
// @vitest-environment jsdom
|
||||
|
||||
import type { MemoryDetail } from "@one-memory/contracts";
|
||||
import type { MemoryDetail, SourceResponse } from "@one-memory/contracts";
|
||||
import {
|
||||
act,
|
||||
cleanup,
|
||||
@@ -14,7 +14,7 @@ import { afterEach, beforeEach, expect, it, vi } from "vitest";
|
||||
import { LANGUAGE_STORAGE_KEY, LanguageProvider } from "./gui-language";
|
||||
import { SelectedMemoryDetail } from "./SelectedMemoryDetail";
|
||||
|
||||
it("localizes informational details and dates while preserving evidence and memory", async () => {
|
||||
it("localizes memory details without exposing the retired evidence surface", async () => {
|
||||
delete window.oneMemory;
|
||||
localStorage.setItem(LANGUAGE_STORAGE_KEY, "es");
|
||||
render(
|
||||
@@ -29,9 +29,9 @@ it("localizes informational details and dates while preserving evidence and memo
|
||||
);
|
||||
expect(await screen.findByText("Guardada · Versión 2")).toBeTruthy();
|
||||
expect(
|
||||
screen.getByRole("heading", { name: "Evidencia original" }),
|
||||
).toBeTruthy();
|
||||
expect(screen.getByText("这次先确认范围。")).toBeTruthy();
|
||||
screen.queryByRole("heading", { name: "Evidencia original" }),
|
||||
).toBeNull();
|
||||
expect(screen.queryByText("这次先确认范围。")).toBeNull();
|
||||
expect(correct).not.toHaveBeenCalled();
|
||||
expect(remove).not.toHaveBeenCalled();
|
||||
localStorage.clear();
|
||||
@@ -145,6 +145,29 @@ it.each([
|
||||
expect(screen.getByText(`${label} · 第 2 版`)).toBeTruthy();
|
||||
expect(screen.queryByText("待核对 · 第 2 版")).toBeNull();
|
||||
});
|
||||
it("shows the native SDK state without inventing a revision", () => {
|
||||
render(
|
||||
<SelectedMemoryDetail
|
||||
detail={{
|
||||
...detail,
|
||||
memory: { ...detail.memory, revision: 0 },
|
||||
evidence: [],
|
||||
sdk_status: "shadow",
|
||||
source_id: "cursor",
|
||||
sdk_memory_at: "2026-09-18T00:00:00Z",
|
||||
sdk_created_at: "2026-09-19T00:00:00Z",
|
||||
}}
|
||||
sources={[
|
||||
{ source_id: "cursor", display_name: "Cursor" } as SourceResponse,
|
||||
]}
|
||||
onChanged={vi.fn()}
|
||||
hideActions
|
||||
/>,
|
||||
);
|
||||
expect(screen.getByText("已隐藏")).toBeTruthy();
|
||||
expect(screen.queryByText(/第 0 版/)).toBeNull();
|
||||
expect(screen.getByText(/^来源 Cursor · /)).toBeTruthy();
|
||||
});
|
||||
it("opens full editor and saves content without rewriting scope", async () => {
|
||||
const changed = vi.fn();
|
||||
render(
|
||||
@@ -204,27 +227,7 @@ it("keeps draft after conflict until latest revision is explicitly acknowledged"
|
||||
),
|
||||
);
|
||||
});
|
||||
it("original page never substitutes unavailable evidence with stale text", () => {
|
||||
const view = render(
|
||||
<SelectedMemoryDetail detail={detail} sources={[]} onChanged={() => {}} />,
|
||||
);
|
||||
fireEvent.click(screen.getByRole("button", { name: "查看原文" }));
|
||||
expect(screen.getAllByText("这次先确认范围。").length).toBeGreaterThan(0);
|
||||
view.rerender(
|
||||
<SelectedMemoryDetail
|
||||
detail={{
|
||||
...detail,
|
||||
evidence: [{ ...detail.evidence[0], available: false }],
|
||||
}}
|
||||
sources={[]}
|
||||
onChanged={() => {}}
|
||||
/>,
|
||||
);
|
||||
expect(screen.queryByText("这次先确认范围。")).toBeNull();
|
||||
expect(screen.getByText("来源权限已变化或原文不可用。")).toBeTruthy();
|
||||
});
|
||||
|
||||
it.each(["编辑", "查看原文", "删除记忆"])(
|
||||
it.each(["编辑", "删除记忆"])(
|
||||
"returns focus to %s without scrolling the background",
|
||||
(name) => {
|
||||
render(
|
||||
@@ -275,7 +278,6 @@ it.each(["escape", "cancel"])(
|
||||
"value",
|
||||
"尚未保存的草稿",
|
||||
);
|
||||
expect(screen.getByText("这次先确认范围。")).toBeTruthy();
|
||||
expect(correct).not.toHaveBeenCalled();
|
||||
confirm.mockReturnValue(true);
|
||||
close();
|
||||
@@ -535,7 +537,7 @@ it("suspends the editor without losing its draft or leaving the background inert
|
||||
);
|
||||
});
|
||||
|
||||
it("stamps today's source time the same way as 06A and 06E", () => {
|
||||
it("stamps today's source and conversation time without an evidence page", () => {
|
||||
render(
|
||||
<SelectedMemoryDetail
|
||||
detail={{
|
||||
@@ -552,8 +554,6 @@ it("stamps today's source time the same way as 06A and 06E", () => {
|
||||
/>,
|
||||
);
|
||||
expect(screen.getByText(/来源 workbuddy · 今天 /)).toBeTruthy();
|
||||
expect(screen.getByText(/原对话 今天 /)).toBeTruthy();
|
||||
fireEvent.click(screen.getByRole("button", { name: "查看原文" }));
|
||||
expect(screen.getByRole("heading", { name: "原始对话" })).toBeTruthy();
|
||||
expect(screen.getByText(/^workbuddy · 今天 /)).toBeTruthy();
|
||||
expect(screen.getByText(/对话时间 今天 /)).toBeTruthy();
|
||||
expect(screen.queryByRole("button", { name: "查看原文" })).toBeNull();
|
||||
});
|
||||
@@ -1,4 +1,8 @@
|
||||
import type { MemoryDetail, SourceResponse } from "@one-memory/contracts";
|
||||
import type {
|
||||
MemoryDetail,
|
||||
SdkMemoryStatus,
|
||||
SourceResponse,
|
||||
} from "@one-memory/contracts";
|
||||
import { useEffect, useRef, useState } from "react";
|
||||
import { createPortal } from "react-dom";
|
||||
import { apiErrorCode, correctMemory, getMemory, mutateMemory } from "./api";
|
||||
@@ -10,7 +14,7 @@ import { traceUiAction } from "./ui-trace";
|
||||
import { isDefiniteRefusal, writeTraceStatus } from "./write-outcome";
|
||||
import "./selected-memory.css";
|
||||
|
||||
type Page = "read" | "edit" | "original" | "delete";
|
||||
type Page = "read" | "edit" | "delete";
|
||||
|
||||
export function SelectedMemoryDetail({
|
||||
detail,
|
||||
@@ -50,8 +54,17 @@ export function SelectedMemoryDetail({
|
||||
}, []);
|
||||
const memory = detail.memory;
|
||||
const evidence = detail.evidence[0];
|
||||
const statusLabel =
|
||||
memory.status === "confirmed"
|
||||
const sdkStatusLabels: Record<SdkMemoryStatus, Parameters<typeof t>[0]> = {
|
||||
active: "selectedMemory.status.active",
|
||||
superseded: "selectedMemory.status.superseded",
|
||||
shadow: "selectedMemory.status.shadow",
|
||||
negated: "selectedMemory.status.negated",
|
||||
conflicted: "selectedMemory.status.conflicted",
|
||||
archived: "selectedMemory.status.archived",
|
||||
};
|
||||
const statusLabel = detail.sdk_status
|
||||
? t(sdkStatusLabels[detail.sdk_status])
|
||||
: memory.status === "confirmed"
|
||||
? t("selectedMemory.status.confirmed")
|
||||
: memory.status === "deleted"
|
||||
? t("selectedMemory.status.deleted")
|
||||
@@ -60,7 +73,7 @@ export function SelectedMemoryDetail({
|
||||
: t("selectedMemory.status.unknown");
|
||||
const source = sourceNameOr(
|
||||
sources,
|
||||
evidence?.source_id,
|
||||
detail.source_id ?? evidence?.source_id,
|
||||
t("selectedMemory.sourceUnknown"),
|
||||
);
|
||||
const dirty = page === "edit" && draft !== memory.content;
|
||||
@@ -221,13 +234,6 @@ export function SelectedMemoryDetail({
|
||||
if (alive.current) setBusy(false);
|
||||
}
|
||||
}
|
||||
const author =
|
||||
evidence?.written_by === "person" || evidence?.written_by === "user"
|
||||
? t("selectedMemory.author.you")
|
||||
: evidence?.written_by === "agent" || evidence?.written_by === "assistant"
|
||||
? t("selectedMemory.author.assistant")
|
||||
: t("selectedMemory.author.unknown");
|
||||
const timestamp = clockStamp(evidence?.created_at, locale);
|
||||
const split = memory.content.search(/[。\n]/);
|
||||
const heading =
|
||||
split > 0 && split < 85
|
||||
@@ -242,23 +248,37 @@ export function SelectedMemoryDetail({
|
||||
data-hide-actions={hideActions ? "true" : "false"}
|
||||
>
|
||||
<p className="selected-memory-status">
|
||||
{statusLabel} ·{" "}
|
||||
{t("selectedMemory.version", {
|
||||
revision: new Intl.NumberFormat(locale).format(memory.revision),
|
||||
})}
|
||||
{statusLabel}
|
||||
{!detail.sdk_status &&
|
||||
` · ${t("selectedMemory.version", {
|
||||
revision: new Intl.NumberFormat(locale).format(memory.revision),
|
||||
})}`}
|
||||
</p>
|
||||
<h3>{heading}</h3>
|
||||
{body && <p className="selected-memory-body">{body}</p>}
|
||||
<p className="selected-memory-source">
|
||||
{t("selectedMemory.sourceLine", { source, time: timestamp })}
|
||||
</p>
|
||||
{detail.source_id || detail.evidence.length ? (
|
||||
<p className="selected-memory-source">
|
||||
{t("selectedMemory.sourceLine", {
|
||||
source,
|
||||
time: clockStamp(
|
||||
detail.sdk_memory_at ?? evidence?.created_at,
|
||||
locale,
|
||||
),
|
||||
})}
|
||||
</p>
|
||||
) : null}
|
||||
<p className="selected-memory-times">
|
||||
{t("selectedMemory.timestamps", {
|
||||
conversation: clockStamp(
|
||||
conversationAt?.at ?? evidence?.created_at,
|
||||
conversationAt?.at ??
|
||||
detail.sdk_memory_at ??
|
||||
evidence?.created_at,
|
||||
locale,
|
||||
),
|
||||
created: clockStamp(
|
||||
detail.sdk_created_at ?? memory.updated_at,
|
||||
locale,
|
||||
),
|
||||
created: clockStamp(memory.updated_at, locale),
|
||||
})}
|
||||
</p>
|
||||
{!hideActions && (
|
||||
@@ -276,27 +296,6 @@ export function SelectedMemoryDetail({
|
||||
{t("selectedMemory.edit")}
|
||||
</R6Button>
|
||||
)}
|
||||
<section className="selected-memory-evidence">
|
||||
<h4>{t("selectedMemory.evidence")}</h4>
|
||||
<small>
|
||||
{t("selectedMemory.evidenceLine", { author, time: timestamp })}
|
||||
</small>
|
||||
<blockquote>
|
||||
{evidence?.available
|
||||
? evidence.verbatim
|
||||
: t("selectedMemory.evidenceUnavailable")}
|
||||
</blockquote>
|
||||
{!hideActions && (
|
||||
<R6Button
|
||||
onClick={(event) => {
|
||||
trigger.current = event.currentTarget;
|
||||
setPage("original");
|
||||
}}
|
||||
>
|
||||
{t("selectedMemory.viewOriginal")}
|
||||
</R6Button>
|
||||
)}
|
||||
</section>
|
||||
{!hideActions && (
|
||||
<R6Button variant="quiet" onClick={() => setShowHistory((v) => !v)}>
|
||||
{t("selectedMemory.history")}
|
||||
@@ -345,9 +344,7 @@ export function SelectedMemoryDetail({
|
||||
aria-label={
|
||||
page === "edit"
|
||||
? t("selectedMemory.editTitle")
|
||||
: page === "original"
|
||||
? t("selectedMemory.originalTitle")
|
||||
: t("selectedMemory.delete")
|
||||
: t("selectedMemory.delete")
|
||||
}
|
||||
onKeyDown={(event) => {
|
||||
if (event.key === "Tab") {
|
||||
@@ -380,13 +377,8 @@ export function SelectedMemoryDetail({
|
||||
<h1 ref={focus} tabIndex={-1}>
|
||||
{page === "edit"
|
||||
? t("selectedMemory.editTitle")
|
||||
: page === "original"
|
||||
? t("selectedMemory.originalTitle")
|
||||
: t("selectedMemory.delete")}
|
||||
: t("selectedMemory.delete")}
|
||||
</h1>
|
||||
{page === "original" && (
|
||||
<R6Button onClick={close}>{t("selectedMemory.back")}</R6Button>
|
||||
)}
|
||||
</header>
|
||||
{page === "edit" ? (
|
||||
<div className="selected-editor">
|
||||
@@ -443,40 +435,6 @@ export function SelectedMemoryDetail({
|
||||
</R6Button>
|
||||
</div>
|
||||
</div>
|
||||
) : page === "original" ? (
|
||||
<div className="selected-original">
|
||||
<small>
|
||||
{source} · {timestamp}
|
||||
</small>
|
||||
<p>{author}</p>
|
||||
{detail.evidence.map((item) => (
|
||||
<section key={item.evidence_id}>
|
||||
<small>
|
||||
{sourceNameOr(
|
||||
sources,
|
||||
item.source_id,
|
||||
t("selectedMemory.sourceUnknown"),
|
||||
)}{" "}
|
||||
·{" "}
|
||||
{item.written_by === "person"
|
||||
? t("selectedMemory.author.user")
|
||||
: item.written_by === "agent"
|
||||
? t("selectedMemory.author.agent")
|
||||
: t("selectedMemory.author.unknown")}
|
||||
</small>
|
||||
<blockquote>
|
||||
{item.available
|
||||
? item.verbatim
|
||||
: t("selectedMemory.sourceUnavailable")}
|
||||
</blockquote>
|
||||
</section>
|
||||
))}
|
||||
<small>{t("selectedMemory.related")}</small>
|
||||
<h2>{heading}</h2>
|
||||
<R6Button onClick={close}>
|
||||
{t("selectedMemory.viewMemory")}
|
||||
</R6Button>
|
||||
</div>
|
||||
) : (
|
||||
<div className="selected-delete">
|
||||
<h2>{t("selectedMemory.deleteTitle")}</h2>
|
||||
|
||||
@@ -68,6 +68,8 @@ describe("paged memory library client", () => {
|
||||
expect(
|
||||
await getSdkMemoryLibrary({
|
||||
statuses: ["active", "superseded"],
|
||||
source: "cursor",
|
||||
query: "release",
|
||||
offset: 0,
|
||||
limit: 25,
|
||||
}),
|
||||
@@ -76,5 +78,7 @@ describe("paged memory library client", () => {
|
||||
const params = get.mock.calls[0][1].params as URLSearchParams;
|
||||
expect(params.getAll("statuses")).toEqual(["active", "superseded"]);
|
||||
expect(params.get("limit")).toBe("25");
|
||||
expect(params.get("source")).toBe("cursor");
|
||||
expect(params.get("query")).toBe("release");
|
||||
});
|
||||
});
|
||||
@@ -891,10 +891,14 @@ export async function getMemoryLibrary(
|
||||
export async function getSdkMemoryLibrary(
|
||||
{
|
||||
statuses = ["active"],
|
||||
source,
|
||||
query,
|
||||
offset = 0,
|
||||
limit = 100,
|
||||
}: {
|
||||
statuses?: SdkMemoryStatus[];
|
||||
source?: string;
|
||||
query?: string;
|
||||
offset?: number;
|
||||
limit?: number;
|
||||
} = {},
|
||||
@@ -905,6 +909,8 @@ export async function getSdkMemoryLibrary(
|
||||
limit: String(limit),
|
||||
});
|
||||
for (const status of statuses) params.append("statuses", status);
|
||||
if (source) params.set("source", source);
|
||||
if (query?.trim()) params.set("query", query.trim());
|
||||
const response = await client.get<SdkMemoryPage>("/api/v2/memory-library", {
|
||||
params,
|
||||
signal,
|
||||
|
||||
@@ -1,6 +1,9 @@
|
||||
import type {
|
||||
GovernedMemory,
|
||||
MemoryDetail,
|
||||
MemoryKind,
|
||||
MemoryLibraryPage,
|
||||
SdkMemoryPage,
|
||||
SdkMemoryRecord,
|
||||
} from "@one-memory/contracts";
|
||||
|
||||
@@ -20,9 +23,11 @@ function stamp(seconds: number | null | undefined): string {
|
||||
|
||||
/** An SDK memory in the shape the memory map draws.
|
||||
*
|
||||
* The map groups and labels by id, content, kind, status and tags only. It
|
||||
* never writes through this shape: opening a memory goes to the SDK library,
|
||||
* so the governed-only fields are inert placeholders here.
|
||||
* The map groups and labels by id, content, kind, status and tags only, so the
|
||||
* governed-only fields are inert placeholders here. Opening, editing and
|
||||
* deleting still go through the SDK (v2) endpoints — `sdkDetail()` below wraps
|
||||
* the same projection for the map's inspector, which is why the inspector can
|
||||
* stay where the design put it instead of sending the reader to another view.
|
||||
*/
|
||||
export function sdkMapMemory(record: SdkMemoryRecord): GovernedMemory {
|
||||
const created = stamp(record.gmt_created ?? record.memory_at);
|
||||
@@ -43,9 +48,65 @@ export function sdkMapMemory(record: SdkMemoryRecord): GovernedMemory {
|
||||
tags: [...record.tags],
|
||||
supersedes: [...record.supersedes],
|
||||
superseded_by: [...record.superseded_by],
|
||||
revision: 1,
|
||||
// GovernedMemory still requires a revision for the shared map model. SDK
|
||||
// has no revision contract; the inspector recognises sdk_status and never
|
||||
// renders this placeholder.
|
||||
revision: 0,
|
||||
created_at: created,
|
||||
updated_at: created,
|
||||
deleted_at: null,
|
||||
};
|
||||
}
|
||||
|
||||
/** One SDK memory as the map's inspector reads it.
|
||||
*
|
||||
* The inspector was built for a governed detail. SDK-native memory has no
|
||||
* product revision or "original evidence" section; source_raw_ids remain an
|
||||
* internal SDK provenance relation and are deliberately not projected as UI
|
||||
* evidence.
|
||||
*/
|
||||
export function sdkDetail(record: SdkMemoryRecord): MemoryDetail {
|
||||
return {
|
||||
memory: sdkMapMemory(record),
|
||||
evidence: [],
|
||||
history: [],
|
||||
index_sync: null,
|
||||
sdk_status: record.status,
|
||||
source_id: record.source,
|
||||
sdk_memory_at: stamp(record.memory_at) || null,
|
||||
sdk_created_at: stamp(record.gmt_created) || null,
|
||||
};
|
||||
}
|
||||
|
||||
/** One SDK page as the map's left list reads it.
|
||||
*
|
||||
* The list asks a library page for its rows, its total and — per row — when
|
||||
* the cited conversation happened. The SDK page carries the row itself, so the
|
||||
* time is taken from the memory: `memory_at` is the moment the conversation
|
||||
* happened, `gmt_created` only the moment it was imported. Naming which one it
|
||||
* is keeps "对话于" from silently becoming "沉淀于".
|
||||
*/
|
||||
export function sdkLibraryPage(page: SdkMemoryPage): MemoryLibraryPage {
|
||||
const sourceTime: Record<
|
||||
string,
|
||||
{ at: string; basis: "conversation" | "import" }
|
||||
> = {};
|
||||
const sourceIds: Record<string, string[]> = {};
|
||||
for (const record of page.memories) {
|
||||
if (record.source) sourceIds[record.memory_id] = [record.source];
|
||||
const at = record.memory_at ?? record.gmt_created;
|
||||
if (typeof at !== "number" || !Number.isFinite(at)) continue;
|
||||
sourceTime[record.memory_id] = {
|
||||
at: new Date(at * 1_000).toISOString(),
|
||||
basis: record.memory_at ? "conversation" : "import",
|
||||
};
|
||||
}
|
||||
return {
|
||||
items: page.memories.map(sdkMapMemory),
|
||||
total: page.total,
|
||||
offset: page.offset,
|
||||
limit: page.limit,
|
||||
source_ids_by_memory: sourceIds,
|
||||
source_time_by_memory: sourceTime,
|
||||
};
|
||||
}
|
||||
@@ -518,6 +518,11 @@ export interface MemoryDetail {
|
||||
memory: GovernedMemory;
|
||||
evidence: MemoryEvidenceExcerpt[];
|
||||
history: MemoryVersion[];
|
||||
/** SDK-native display metadata. Legacy governed details leave these absent. */
|
||||
sdk_status?: SdkMemoryStatus;
|
||||
source_id?: string | null;
|
||||
sdk_memory_at?: string | null;
|
||||
sdk_created_at?: string | null;
|
||||
// Null for a memory nobody has rewritten: there is no index to be behind,
|
||||
// and the interface should not explain a state the user never caused.
|
||||
index_sync: MemoryIndexSync | null;
|
||||
|
||||
@@ -74,8 +74,13 @@ export const currentChineseMessages = {
|
||||
"memorySpace.tabs.workbench": "工作台",
|
||||
|
||||
"selectedMemory.status.confirmed": "已沉淀",
|
||||
"selectedMemory.status.active": "已生效",
|
||||
"selectedMemory.status.deleted": "已删除",
|
||||
"selectedMemory.status.superseded": "已被取代",
|
||||
"selectedMemory.status.shadow": "已隐藏",
|
||||
"selectedMemory.status.negated": "已否定",
|
||||
"selectedMemory.status.conflicted": "有冲突",
|
||||
"selectedMemory.status.archived": "已归档",
|
||||
"selectedMemory.status.unknown": "状态待核对",
|
||||
"selectedMemory.sourceUnknown": "来源待核对",
|
||||
"selectedMemory.discardDraft": "放弃尚未保存的修改?",
|
||||
@@ -208,8 +213,13 @@ export const currentEnglishMessages: Record<
|
||||
"memorySpace.tabs.workbench": "Workbench",
|
||||
|
||||
"selectedMemory.status.confirmed": "Created",
|
||||
"selectedMemory.status.active": "Active",
|
||||
"selectedMemory.status.deleted": "Deleted",
|
||||
"selectedMemory.status.superseded": "Superseded",
|
||||
"selectedMemory.status.shadow": "Hidden",
|
||||
"selectedMemory.status.negated": "Negated",
|
||||
"selectedMemory.status.conflicted": "Conflicted",
|
||||
"selectedMemory.status.archived": "Archived",
|
||||
"selectedMemory.status.unknown": "Status needs review",
|
||||
"selectedMemory.sourceUnknown": "Source needs review",
|
||||
"selectedMemory.discardDraft": "Discard unsaved changes?",
|
||||
@@ -356,8 +366,13 @@ export const currentSpanishMessages: Record<
|
||||
"memorySpace.tabs.workbench": "Espacio de trabajo",
|
||||
|
||||
"selectedMemory.status.confirmed": "Guardada",
|
||||
"selectedMemory.status.active": "Activa",
|
||||
"selectedMemory.status.deleted": "Eliminada",
|
||||
"selectedMemory.status.superseded": "Sustituida",
|
||||
"selectedMemory.status.shadow": "Oculta",
|
||||
"selectedMemory.status.negated": "Negada",
|
||||
"selectedMemory.status.conflicted": "En conflicto",
|
||||
"selectedMemory.status.archived": "Archivada",
|
||||
"selectedMemory.status.unknown": "Estado pendiente de comprobar",
|
||||
"selectedMemory.sourceUnknown": "Origen sin verificar",
|
||||
"selectedMemory.discardDraft": "¿Descartar los cambios sin guardar?",
|
||||
|
||||
Reference in new issue
Block a user