Compare commits

...
Author SHA1 Message Date
kristinhe 1848a3b700 chore(memhub): build 23 2026-10-25 11:50:17 +08:00
feitianxiang 99e3c8d9d7 Merge pull request #172 from Tencent-Hunyuan/fix/sdk-memory-native-detail
fix(memhub): reuse the memory map for SDK-native details
2026-10-25 09:54:03 +08:00
alvinfeiandCursor 896a9c6c83 fix(memhub): render SDK memory details natively
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-10-25 09:46:34 +08:00
kristinhe 193fabeeb4 fix(memhub): open an SDK memory where the map put it
On SDK authority, clicking a memory switched the whole page to the SDK
library — a second screen made of status tabs and a ledger, where the memory
page's design has one list, one graph and one inspector. Those three already
exist: they are the components the mock-data screen was built from. This wires
the real SDK reads into them instead of standing a second screen beside them.

- opening a memory reads it through v2 and draws it in the map's inspector;
  edits and deletes go to updateSdkMemory / deleteSdkMemory, and a write
  re-reads the page instead of waiting for the poll
- the left list gets a real library page (50 a page, with the total and each
  memory's source and conversation time), so the count, the paging footer and
  the provenance line read the way the design reads
- a memory with no cited excerpt no longer renders an evidence block and a
  source line that can only say "not available"

The SDK library panel is left as it was; it is no longer on this path.
2026-10-25 09:36:02 +08:00
feitianxiang 0aeb3e8760 Merge pull request #167 from Tencent-Hunyuan/fix/simplify-sdk-migration
fix(memhub): migrate to SDK authority without copying memory
2026-10-24 23:18:32 +08:00
feitianxiang 70aaa7be85 Merge pull request #170 from Tencent-Hunyuan/fix/upgrade-runtime-and-sdk-memory-map
fix(memhub): install the bundled Runtime on upgrade and show SDK memories
2026-10-24 23:12:03 +08:00
alvinfeiandClaude Opus 5.5 18046152ab feat(memhub): draw the memory map from SDK memories on SDK authority
The default memory view is the map, and on SDK authority it had nothing
to draw: its list and graph read only legacy authority. It now reads SDK
memories (`/api/v2/memory-library`) and `/api/v2/memories/graph` and
draws them with the same map model, so the map stays the default view.
Opening a memory from the map goes to the SDK library, which reads,
edits and deletes it through v2; governed detail is refused there.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-10-24 22:22:55 +08:00
alvinfeiandClaude Opus 5.5 7b946a28ef fix(memhub): install the bundled Runtime on upgrade and show SDK memories
Build 21 acceptance found an upgraded App on the previous Runtime and a
memory page that never left "reading":

- Desktop skipped `installPackagedRuntime` whenever the service owner was
  "cli", which it also assumes itself when a plist exists without an owner
  receipt. The App now always installs its bundled Runtime (a newer build
  from `memhub upgrade` is kept) and refuses to start on a `current` older
  than its own build (`packaged_runtime_not_current`). The startup failure
  event now records the cause, with the home path shortened.
- On SDK authority the default memory map loaded nothing: its list and
  graph read only legacy authority. The memory page now opens the v2 SDK
  library there, and `/api/v2/memories/graph` serves the map from SDK
  memories plus the Kuzu overlay. A failed authority probe is retried
  instead of leaving the page unknown forever.
- Every completed SDK write logged RetiredAuthorityAccess: the retired
  Evidence facade raised from `getattr(..., None)`. It now has a no-op
  `release_sdk_staging`.
- A repair of an already migrated home is reported as `complete`, not a
  second `cutover`, and the CLI treats it as non-rollbackable too.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-10-24 22:08:14 +08:00
feitianxiang 0c36a6c960 Merge pull request #168 from Tencent-Hunyuan/feat/single-sdk-migrate-command
feat(memhub): migrate every install with one sdk-migrate command
2026-10-24 11:47:25 +08:00
22 changed files with 1260 additions and 213 deletions

No files matched your search

+4
View File
@@ -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
+4 -2
View File
@@ -3180,7 +3180,8 @@ async function runCommand(
migration.code ?? "sdk_migration_incomplete",
);
}
migrated = migration.state === "cutover";
migrated =
migration.state === "cutover" || migration.state === "complete";
await applyPreparedServiceInstall(preparedService);
serviceApplied = true;
// The receipt exists so a later command can find the data dir without
@@ -3665,7 +3666,8 @@ async function runCommand(
}
// An older Runtime refuses a migrated home, so from here a failure
// keeps the new version instead of rolling back to one that cannot start.
migrated = migration.state === "cutover";
migrated =
migration.state === "cutover" || migration.state === "complete";
await restartUserService(layout, serviceOptions);
// A restart that returns without error only means launchd/systemd
// accepted the command. O-22: the first `upgrade` implementation
+152 -3
View File
@@ -3103,7 +3103,10 @@ def create_app(
if store is None:
store = SdkDataPlaneMigrationStore(_evidence_store(app).database_path)
authority, migration_id = store.authority_state()
return MemoryAuthorityResponse(authority=authority.value, migration_id=migration_id)
return MemoryAuthorityResponse(
authority="sdk" if authority is MemoryAuthority.SDK else "legacy",
migration_id=migration_id,
)
@app.get(
"/api/v2/memory-library",
@@ -3112,22 +3115,168 @@ 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
@app.get(
"/api/v2/memories/graph",
response_model=MemoryGraphResponse,
dependencies=protected,
)
async def sdk_memory_graph(
limit: int = Query(default=160, ge=1, le=300),
) -> MemoryGraphResponse:
"""The memory map on SDK authority: SDK memories plus the Kuzu overlay.
Memories come from the public client; only the overlay reads Kuzu
(`hy_graph_projection`, the visualization exception). Shape matches
the legacy map so the same view draws both.
"""
try:
client = await _sdk_client_handle(app).client()
raw = await asyncio.to_thread(
client.list_memories, user_id="local", limit=limit, offset=0, order="desc"
)
page = HySdkMemoryPage.from_sdk(raw, limit=limit, offset=0)
except Exception as error:
raise _sdk_http_error(error, "hy_memory_list_failed") from None
vdb = raw.get("vdb") if isinstance(raw, dict) else None
total = vdb.get("total") if isinstance(vdb, dict) else None
memories = [memory for memory in page.memories if memory.layer != "l1_raw"]
catalog = await asyncio.to_thread(_source_catalog, app)
nodes: dict[str, MemoryGraphNode] = {}
edges: list[MemoryGraphEdge] = []
sessions: dict[str, list[str]] = {}
for memory in memories:
memory_node_id = f"memory:{memory.memory_id}"
nodes[memory_node_id] = MemoryGraphNode(
node_id=memory_node_id,
node_type="memory",
label=memory.content,
memory_id=memory.memory_id,
kind=memory.layer,
status=memory.status,
)
if memory.session_id:
sessions.setdefault(memory.session_id, []).append(memory_node_id)
if memory.source:
source_node_id = f"source:{memory.source}"
registration = catalog.get(memory.source)
nodes[source_node_id] = MemoryGraphNode(
node_id=source_node_id,
node_type="source",
label=(
registration.descriptor.display_name if registration else memory.source
),
source_id=memory.source,
)
edges.append(
MemoryGraphEdge(
edge_id=f"{source_node_id}->{memory_node_id}",
source_node_id=source_node_id,
target_node_id=memory_node_id,
relation="evidence_source",
origin="provenance",
)
)
for _session_id, node_ids in sorted(sessions.items()):
for left, right in zip(node_ids, node_ids[1:], strict=False):
edges.append(
MemoryGraphEdge(
edge_id=f"{left}->{right}:same_conversation",
source_node_id=left,
target_node_id=right,
relation="same_conversation",
origin="same_conversation",
)
)
hy_graph = None
# Aggregate nodes carry no per-source provenance yet, so a revoked
# source withholds the overlay, as on the legacy map.
if not await asyncio.to_thread(_read_access(app).has_revoked_sources):
try:
hy_graph = await _sdk_memory_runtime(app).graph_snapshot(limit=limit)
except HySdkError:
hy_graph = None
if hy_graph is not None:
for node in hy_graph.nodes:
node_id = f"memory:{node.node_id}"
if node_id in nodes:
continue
node_id = f"hy:{node.node_id}"
nodes.setdefault(
node_id,
MemoryGraphNode(
node_id=node_id,
node_type="topic" if node.layer == "TOPIC" else "schema",
label=node.content,
confidence=node.confidence,
),
)
for edge in hy_graph.edges:
source_node_id, target_node_id = (
f"memory:{edge.source_node_id}"
if f"memory:{edge.source_node_id}" in nodes
else f"hy:{edge.source_node_id}",
f"memory:{edge.target_node_id}"
if f"memory:{edge.target_node_id}" in nodes
else f"hy:{edge.target_node_id}",
)
if source_node_id not in nodes or target_node_id not in nodes:
continue
edges.append(
MemoryGraphEdge(
edge_id=f"{source_node_id}->{target_node_id}:{edge.relation}",
source_node_id=source_node_id,
target_node_id=target_node_id,
relation=edge.relation,
weight=edge.weight,
origin="hy_relation",
)
)
return MemoryGraphResponse(
total_memories=total if isinstance(total, int) else len(memories),
projected_memories=len(memories),
nodes=tuple(nodes.values()),
edges=tuple(edges),
hy_graph_available=hy_graph is not None and hy_graph.consulted,
)
@app.get(
"/api/v2/user-profile",
response_model=SdkUserProfileResponse,
@@ -22,6 +22,13 @@ class RetiredEvidenceStore:
del limit
return 0
def release_sdk_staging(self, evidence_ids: tuple[str, ...]) -> int:
# The write drain releases staged Evidence after every SDK write. On
# SDK authority nothing is staged; without this, `__getattr__` raised
# past `getattr(..., None)` and every completed write logged an error.
del evidence_ids
return 0
def get(self, _identifier: str) -> None:
return 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())
@@ -866,3 +866,124 @@ def test_a_legacy_home_reports_legacy_authority(tmp_path) -> None:
assert document == {"authority": "legacy", "migration_id": None}
assert gate == "legacy"
@pytest.mark.integration
@pytest.mark.fresh_sdk
def test_the_sdk_memory_map_draws_sdk_memories_without_legacy_tables(tmp_path) -> None:
"""Build 21: the memory map had no SDK read and waited forever."""
from one_memory.hy_graph_projection import HyGraphEdge, HyGraphNode, HyGraphSnapshot
rows = [
{
"memory_id": memory_id,
"content": f"memory {memory_id}",
"layer": "l2_fact",
"status": "active",
"is_latest": True,
"user_id": "local",
"agent_id": "all-apps",
"session_id": "s1",
"source": "cursor",
}
for memory_id in ("a", "b")
]
class Client:
def list_memories(self, **_kwargs):
return {"vdb": {"memories": rows, "total": 7}}
class Handle:
async def client(self):
return Client()
async def close(self):
return None
async def scenario():
app = create_app(_app_settings(tmp_path), secret_store=InMemorySecretStore())
async with (
app.router.lifespan_context(app),
AsyncClient(
transport=ASGITransport(app=app),
base_url="http://test",
headers={"Authorization": "Bearer authority-test"},
) as client,
):
original = app.state.sdk_client_handle
app.state.sdk_client_handle = Handle()
async def graph_snapshot(*, limit):
del limit
return HyGraphSnapshot(
nodes=(HyGraphNode(node_id="t1", content="topic", layer="TOPIC"),),
edges=(
HyGraphEdge(
source_node_id="t1", target_node_id="a", relation="derived_from"
),
),
)
app.state.sdk_memory_runtime.graph_snapshot = graph_snapshot
assert (await client.get("/api/v1/memories/graph")).status_code == 409
response = await client.get("/api/v2/memories/graph")
app.state.sdk_client_handle = original
assert response.status_code == 200
return response.json()
graph = asyncio.run(scenario())
assert (graph["total_memories"], graph["projected_memories"]) == (7, 2)
assert {node["node_id"] for node in graph["nodes"]} == {
"memory:a",
"memory:b",
"source:cursor",
"hy:t1",
}
assert {edge["relation"] for edge in graph["edges"]} == {
"evidence_source",
"same_conversation",
"derived_from",
}
assert graph["hy_graph_available"] is True
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
from one_memory.imports.storage import EvidenceStore as Evidence
from one_memory.memory_server.durable_queue import DurableWriteDrain, execute_job
from one_memory.retired_authority import RetiredEvidenceStore
path = store.database_path
_insert_evidence(store, "e1")
record = Evidence(path).get("e1")
assert record is not None
queue = DurableWriteQueue(path, sdk_authority=True)
job = queue.submit(
WriteRequest(
internal_session_id="s",
source_id="cursor",
batch_identity=BatchIdentity(internal_session_id="s", first_round=1, last_round=1),
evidence_ids=["e1"],
input_records=(record,),
idempotency_key="k",
)
)
DurableWriteDrain(queue, execute=lambda _job: asyncio.sleep(0)).claim(limit=1)
class Supervisor:
async def process_sdk_job(self, records, *, request_id, before_hy_invocation):
del records, request_id
await before_hy_invocation()
return SimpleNamespace(raw_ids=("raw-1",), memory_ids=("m-1",))
asyncio.run(
execute_job(
job.job_id,
queue=queue,
supervisor=Supervisor(),
evidence=RetiredEvidenceStore(path),
)
)
assert queue.get("k").state == "completed"
+52 -25
View File
@@ -112,9 +112,11 @@ import {
installBundledCliIfNeeded,
installServiceWithDisabledFallback,
isInstalledApplicationsApp,
requireCurrentRuntimeAtLeast,
stableRuntimeErrorCode,
stableRuntimeFailureDetail,
startStableRuntimeRepair,
startupFailureCause,
} from "./stable-runtime";
import { STARTUP_SCREEN_URL, startupScreenHtml } from "./startup-screen";
import {
@@ -1093,7 +1095,7 @@ async function initialize(isQuitting: () => boolean): Promise<void> {
component: "desktop.sdk_migration",
event: "sdk_migration.startup",
outcome:
migration.state === "cutover"
migration.state === "cutover" || migration.state === "complete"
? "succeeded"
: migration.state === "deferred"
? "blocked"
@@ -1158,30 +1160,52 @@ async function initialize(isQuitting: () => boolean): Promise<void> {
const previousOwner = owner;
let runtimeChanged = false;
let transientRuntime = false;
if (owner?.owner !== "cli") {
const installed = await installPackagedRuntime({
resourcesPath: process.resourcesPath,
executablePath: process.execPath,
installRoot,
platform: "darwin",
arch: process.arch,
...(packagedBuildNumber === undefined
? {}
: { buildNumber: packagedBuildNumber }),
...(packagedCommit === undefined
? {}
: { gitCommit: packagedCommit }),
// Only the packaged Desktop path opts into a valid same-build,
// different-commit takeover. CLI installs remain strict.
replaceSameBuild: true,
serviceEnableMarker: runtimeServiceEnableMarkerPath(dataDir),
});
runtimeChanged = !installed.reused_current;
await installBundledCliIfNeeded({
home: app.getPath("home"),
installRoot,
platform: "darwin",
});
// Install whoever owns the service. Owner decides who starts and stops
// it, not which build runs: skipping the install for a CLI owner (also
// the App's own guess when its receipt is missing) left an upgraded App
// on the previous Runtime and, with `current` gone, on none at all.
{
let installed: Awaited<
ReturnType<typeof installPackagedRuntime>
> | null = null;
try {
installed = await installPackagedRuntime({
resourcesPath: process.resourcesPath,
executablePath: process.execPath,
installRoot,
platform: "darwin",
arch: process.arch,
...(packagedBuildNumber === undefined
? {}
: { buildNumber: packagedBuildNumber }),
...(packagedCommit === undefined
? {}
: { gitCommit: packagedCommit }),
// Only the packaged Desktop path opts into a valid same-build,
// different-commit takeover. CLI installs remain strict.
replaceSameBuild: true,
serviceEnableMarker: runtimeServiceEnableMarkerPath(dataDir),
});
} catch (error) {
// `memhub upgrade` may already have installed a newer build.
if (
!(
owner?.owner === "cli" &&
error instanceof Error &&
error.message === "distribution_downgrade_refused"
)
)
throw error;
}
if (installed !== null) {
runtimeChanged = !installed.reused_current;
await installBundledCliIfNeeded({
home: app.getPath("home"),
installRoot,
platform: "darwin",
});
}
await requireCurrentRuntimeAtLeast(installRoot, packagedBuildNumber);
}
if (
!desktopBackgroundLaunch &&
@@ -1485,6 +1509,9 @@ async function initialize(isQuitting: () => boolean): Promise<void> {
outcome: "failed",
error_code: stableRuntimeErrorCode(error),
...diagnosticError(error),
// The code alone is often `desktop_start_failed`; the cause is what
// support needs. Home is shortened and the text bounded.
details: { cause: startupFailureCause(error, app.getPath("home")) },
});
throw error;
}
@@ -15,10 +15,12 @@ import {
installServiceWithDisabledFallback,
isDisabledServiceInstallError,
isInstalledApplicationsApp,
requireCurrentRuntimeAtLeast,
stableRuntimeErrorCode,
stableRuntimeFailureDetail,
stableRuntimeUserMessage,
startStableRuntimeRepair,
startupFailureCause,
} from "./stable-runtime";
it("only treats a packaged macOS executable under Applications as installed", () => {
@@ -312,3 +314,43 @@ describe("startup dialog body", () => {
expect(detail).not.toContain("sdk_migration_disk_space_insufficient");
});
});
describe("packaged runtime build after an App upgrade", () => {
it("refuses to run on a Runtime older than the App, or none", async () => {
const root = await mkdtemp(path.join(os.tmpdir(), "memhub-current-"));
await expect(requireCurrentRuntimeAtLeast(root, 21)).rejects.toThrow(
"packaged_runtime_not_current",
);
await mkdir(path.join(root, "current"));
const manifest = path.join(root, "current", "manifest.json");
await writeFile(manifest, JSON.stringify({ build_number: 19 }));
await expect(requireCurrentRuntimeAtLeast(root, 21)).rejects.toThrow(
"packaged_runtime_not_current",
);
await writeFile(manifest, JSON.stringify({ build_number: 21 }));
await expect(
requireCurrentRuntimeAtLeast(root, 21),
).resolves.toBeUndefined();
await writeFile(manifest, JSON.stringify({ build_number: 22 }));
await expect(
requireCurrentRuntimeAtLeast(root, 21),
).resolves.toBeUndefined();
await expect(
requireCurrentRuntimeAtLeast(root, undefined),
).resolves.toBeUndefined();
expect(
stableRuntimeUserMessage(new Error("packaged_runtime_not_current")),
).toContain("重新安装");
});
it("keeps the cause of a startup failure, without the home path", () => {
const cause = startupFailureCause(
new Error("ENOENT: no such file /Users/someone/Library/x\nmore"),
"/Users/someone",
);
expect(cause).toBe("Error: ENOENT: no such file ~/Library/x more");
expect(startupFailureCause(new Error("x".repeat(500)), "/h").length).toBe(
300,
);
});
});
+31
View File
@@ -129,6 +129,7 @@ const SAFE_STABLE_RUNTIME_ERROR_CODES = new Set([
// Migration runs before the app window exists, so these are the only names
// a first launch can be given. They stay codes here and become sentences in
// STABLE_RUNTIME_USER_MESSAGES: a person reading the dialog never sees them.
"packaged_runtime_not_current",
"sdk_migration_preflight_failed",
"sdk_migration_memory_unreadable",
"sdk_migration_model_profile_required",
@@ -205,6 +206,8 @@ export const STABLE_RUNTIME_USER_MESSAGES: Record<string, string> = {
"已安装的运行环境比这个安装包更新,无法用旧安装包启动。请安装最新版本的 MemHub;你的记忆数据不会受到影响。",
install_current_unmanaged:
"运行环境的安装目录被改动过,无法安全接管。请重新安装 MemHub,你的记忆数据不会受到影响。",
packaged_runtime_not_current:
"新版本的运行环境没有装上,MemHub 不会用旧版本继续运行。请重新安装 MemHub;如果仍然失败,请把这个提示反馈给我们。你的记忆数据不会受到影响。",
sdk_migration_memory_unreadable:
"升级前核对发现,新版本还读不到你的全部记忆,所以没有切换数据源,旧数据保持原样可用。请把这个提示反馈给我们;原有记忆数据不会丢失。",
sdk_migration_model_profile_required:
@@ -252,3 +255,31 @@ export function stableRuntimeFailureDetail(error: unknown): string {
}
return STABLE_RUNTIME_USER_MESSAGES[error.message] ?? error.message;
}
/** The installed Runtime must be at least the build this App carries. */
export async function requireCurrentRuntimeAtLeast(
installRoot: string,
buildNumber: number | undefined,
): Promise<void> {
if (buildNumber === undefined) return;
const manifest = await readFile(
path.join(installRoot, "current", "manifest.json"),
"utf8",
)
.then((text) => JSON.parse(text) as { build_number?: unknown })
.catch(() => null);
const installed = manifest?.build_number;
if (typeof installed !== "number" || installed < buildNumber) {
throw new Error("packaged_runtime_not_current");
}
}
export function startupFailureCause(error: unknown, home: string): string {
const text =
error instanceof Error ? `${error.name}: ${error.message}` : String(error);
return text
.split(home)
.join("~")
.replace(/[\r\n]+/g, " ")
.slice(0, 300);
}
+6 -6
View File
@@ -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 () => {
@@ -0,0 +1,257 @@
// @vitest-environment jsdom
import type {
GovernedMemory,
MemoryGraphResponse,
SdkMemoryRecord,
} from "@one-memory/contracts";
import {
cleanup,
fireEvent,
render,
screen,
waitFor,
} from "@testing-library/react";
import { afterEach, expect, it, vi } from "vitest";
import { MemorySpace } from "./MemorySpace";
import { sdkMapMemory } from "./sdk-memory-map";
const api = vi.hoisted(() => ({
authority: vi.fn(),
legacyLibrary: vi.fn(),
legacyGraph: vi.fn(),
sdkLibrary: vi.fn(),
sdkGraph: vi.fn(),
sdkMemory: vi.fn(),
}));
vi.mock("./api", () => ({
getMemoryAuthority: api.authority,
getMemoryLibrary: api.legacyLibrary,
getMemories: async () => [],
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,
}));
vi.mock("./GraphWidthPrompt", () => ({ GraphWidthPrompt: () => null }));
vi.mock("./MemoryNoteComposer", () => ({ MemoryNoteComposer: () => null }));
vi.mock("./MemoryMap", () => ({
MemoryMap: ({
hidden,
memories,
graph,
loaded,
onOpenMemory,
}: {
hidden: boolean;
memories: GovernedMemory[];
graph: MemoryGraphResponse | null;
loaded: boolean;
onOpenMemory(id: string): void;
}) => (
<div hidden={hidden} data-testid="map">
<span data-testid="map-memories">
{memories.map((m) => `${m.memory_id}:${m.status}`).join(",")}
</span>
<span data-testid="map-nodes">{graph?.nodes.length ?? "none"}</span>
<span data-testid="map-loaded">{String(loaded)}</span>
<button type="button" onClick={() => onOpenMemory("a")}>
open a
</button>
</div>
),
}));
vi.mock("./SdkMemoryLibrary", () => ({
SdkMemoryLibrary: ({
focusedMemoryId,
}: {
focusedMemoryId: string | null;
}) => <div data-testid="sdk-library">{focusedMemoryId ?? ""}</div>,
}));
vi.mock("./MemoryRecallScope", () => ({
MemoryRecallScope: () => null,
memoryProvenanceLabel: () => "原话",
}));
const record = (memory_id: string): SdkMemoryRecord => ({
memory_id,
content: `memory ${memory_id}`,
layer: "l2_fact",
status: "active",
is_latest: true,
supersedes: [],
superseded_by: [],
tags: ["工作"],
owner: null,
source_raw_ids: [],
user_id: "local",
agent_id: "all-apps",
session_id: "s1",
source: "cursor",
memory_at: null,
gmt_created: 1_761_000_000,
});
afterEach(() => {
cleanup();
vi.clearAllMocks();
});
function sdkAuthority() {
api.authority.mockResolvedValue({ authority: "sdk", migration_id: "m" });
api.sdkLibrary.mockResolvedValue({
memories: [record("a"), record("b")],
total: 3018,
limit: 300,
offset: 0,
});
api.sdkGraph.mockResolvedValue({
total_memories: 3018,
projected_memories: 2,
nodes: [
{ node_id: "memory:a", node_type: "memory", label: "a", memory_id: "a" },
{ node_id: "memory:b", node_type: "memory", label: "b", memory_id: "b" },
{ node_id: "source:cursor", node_type: "source", label: "Cursor" },
],
edges: [],
hy_graph_available: true,
});
}
it("draws the map from SDK memories on SDK authority, without legacy reads", async () => {
sdkAuthority();
render(<MemorySpace simplified active />);
await waitFor(() =>
expect(screen.getByTestId("map-loaded").textContent).toBe("true"),
);
expect(screen.getByTestId("map").hidden).toBe(false);
expect(screen.getByTestId("map-memories").textContent).toBe(
"a:confirmed,b:confirmed",
);
expect(screen.getByTestId("map-nodes").textContent).toBe("3");
expect(await screen.findByText(/3,018/)).toBeTruthy();
expect(api.sdkLibrary).toHaveBeenCalledWith(
{ statuses: ["active"], offset: 0, limit: 300 },
expect.any(AbortSignal),
);
expect(api.legacyLibrary).not.toHaveBeenCalled();
expect(api.legacyGraph).not.toHaveBeenCalled();
});
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(() =>
expect(screen.getByTestId("map-loaded").textContent).toBe("true"),
);
fireEvent.click(screen.getByText("open a"));
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 () => {
vi.useFakeTimers({ shouldAdvanceTime: true });
try {
sdkAuthority();
api.authority
.mockRejectedValueOnce(new Error("core_not_ready"))
.mockRejectedValueOnce(new Error("core_not_ready"));
render(<MemorySpace simplified active />);
await vi.advanceTimersByTimeAsync(2_000);
await waitFor(() => expect(api.authority).toHaveBeenCalledTimes(3));
await waitFor(() =>
expect(screen.getByTestId("map-loaded").textContent).toBe("true"),
);
} finally {
vi.useRealTimers();
}
});
it("projects SDK records into the fields the map groups by", () => {
const coding = sdkMapMemory({ ...record("c"), layer: "coding_experience" });
expect(coding).toMatchObject({
memory_id: "c",
kind: "coding",
status: "confirmed",
tags: ["工作"],
deleted_at: null,
});
expect(sdkMapMemory({ ...record("s"), layer: "l6_schema" }).kind).toBe(
"schema",
);
expect(sdkMapMemory({ ...record("x"), status: "superseded" }).status).toBe(
"superseded",
);
});
const node = (
node_id: string,
node_type: MemoryGraphResponse["nodes"][number]["node_type"],
extra: Partial<MemoryGraphResponse["nodes"][number]> = {},
): MemoryGraphResponse["nodes"][number] => ({
node_id,
node_type,
label: node_id,
memory_id: null,
source_id: null,
kind: null,
status: null,
confidence: null,
...extra,
});
const provenance = (
from: string,
to: string,
): MemoryGraphResponse["edges"][number] => ({
edge_id: `${from}->${to}`,
source_node_id: from,
target_node_id: to,
relation: "evidence_source",
weight: null,
origin: "provenance",
});
it("groups and draws SDK memories by source with the real map model", async () => {
const { mapGroups, projectMap } = await import("./memory-map-model");
const memories = [record("a"), record("b")].map(sdkMapMemory);
const graph: MemoryGraphResponse = {
total_memories: 2,
projected_memories: 2,
nodes: [
node("memory:a", "memory", { memory_id: "a" }),
node("memory:b", "memory", { memory_id: "b" }),
node("source:cursor", "source", { source_id: "cursor", label: "Cursor" }),
node("hy:t1", "topic"),
],
edges: [
provenance("source:cursor", "memory:a"),
provenance("source:cursor", "memory:b"),
],
hy_graph_available: true,
};
const groups = mapGroups(memories, graph, "source");
expect(groups.map((group) => [group.key, group.memoryIds])).toEqual([
["source:cursor", ["a", "b"]],
]);
const drawn = projectMap(graph, memories, "source", ["a", "b"]);
expect(
drawn.nodes
.filter((node) => node.node_type === "memory")
.map((n) => n.memory_id),
).toEqual(["a", "b"]);
expect(drawn.edges.length).toBeGreaterThan(0);
});
+282 -54
View File
@@ -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,12 +41,16 @@ import {
getMemoryGraph,
getMemoryProcessing,
getModelProfiles,
getSdkMemory,
getSdkMemoryGraph,
getSdkMemoryLibrary,
manualExtract,
mutateMemory,
retryUnknownMemoryProcessing,
saveMemoryNote,
setExtractionWindow,
syncMemoryIndex,
updateSdkMemory,
} from "./api";
import { ConversationSearch } from "./ConversationSearch";
import { clockStamp } from "./clock-stamp";
@@ -56,6 +62,7 @@ import { subscribePoll } from "./poll-scheduler";
import { R6Button } from "./R6Controls";
import { SdkMemoryLibrary } from "./SdkMemoryLibrary";
import { SdkRawHistory } from "./SdkRawHistory";
import { sdkDetail, sdkLibraryPage, sdkMapMemory } from "./sdk-memory-map";
import { sourceNameOr } from "./source-label";
import { traceUiAction } from "./ui-trace";
import { useMemoryLibrary } from "./use-memory-library";
@@ -168,13 +175,23 @@ export function MemorySpace({
if ((!active && !preload) || typeof getMemoryAuthority !== "function")
return;
let current = true;
void getMemoryAuthority()
.then((result) => {
if (current) setMemoryAuthority(result.authority);
})
.catch(() => {});
let timer: ReturnType<typeof setTimeout> | undefined;
// The app opens before Core is ready, so the first ask usually fails.
// Without a retry the authority stayed unknown and nothing ever loaded.
const ask = (delay: number) => {
void getMemoryAuthority()
.then((result) => {
if (current) setMemoryAuthority(result.authority);
})
.catch(() => {
if (!current) return;
timer = setTimeout(() => ask(Math.min(delay * 2, 10_000)), delay);
});
};
ask(500);
return () => {
current = false;
if (timer !== undefined) clearTimeout(timer);
};
}, [active, preload]);
useEffect(() => {
@@ -283,9 +300,117 @@ 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<{
memories: GovernedMemory[];
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 &&
active &&
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 () => {
controller?.abort();
const request = new AbortController();
controller = request;
try {
const [page, graph] = await Promise.all([
getSdkMemoryLibrary(
{ statuses: ["active"], offset: 0, limit: 300 },
request.signal,
),
getSdkMemoryGraph(request.signal),
]);
if (cancelled) return;
setSdkMap({ memories: page.memories.map(sdkMapMemory), graph });
setSdkMapFailed(false);
} catch {
if (!cancelled && !request.signal.aborted) setSdkMapFailed(true);
}
};
void read();
const timer = setInterval(() => void read(), 30_000);
return () => {
cancelled = true;
clearInterval(timer);
controller?.abort();
};
}, [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);
@@ -969,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 }) {
@@ -1019,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);
@@ -1067,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");
@@ -1273,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");
@@ -1444,33 +1620,51 @@ export function MemorySpace({
{simplified ? t("memory:记忆") : t("memory:记忆空间")}
</h1>
<span className="memory-loaded-count">
{simplified && !management
? mapLibrary.failed
{memoryAuthority === "sdk" && simplified && !management
? sdkMapFailed && !sdkMap
? t("memory:记忆读取失败")
: mapLibrary.stale
? t("memory:正在导入历史会话,记忆将陆续出现")
: mapLibrary.page
: sdkMap
? tn(
"memory:已沉淀 {count} 条{filter} · 当前展示 {shown} 条",
sdkMap.graph.total_memories,
{
filter: "",
shown: new Intl.NumberFormat(locale).format(
sdkLibrary.page?.items.length ??
sdkMap.memories.length,
),
},
)
: t("memory:正在读取…")
: memoryAuthority === "sdk"
? null
: simplified && !management
? mapLibrary.failed
? t("memory:记忆读取失败")
: mapLibrary.stale
? t("memory:正在导入历史会话,记忆将陆续出现")
: mapLibrary.page
? tn(
"memory:已沉淀 {count} 条{filter} · 当前展示 {shown} 条",
mapLibrary.page.total,
{
filter: settledFilterLabel
? t("memory:({filter})", {
filter: settledFilterLabel,
})
: "",
shown: new Intl.NumberFormat(locale).format(
mapLibrary.page.items.length,
),
},
)
: t("memory:正在读取…")
: loaded
? tn(
"memory:已沉淀 {count} 条{filter} · 当前展示 {shown} 条",
mapLibrary.page.total,
{
filter: settledFilterLabel
? t("memory:({filter})", {
filter: settledFilterLabel,
})
: "",
shown: new Intl.NumberFormat(locale).format(
mapLibrary.page.items.length,
),
},
"memory:已加载 {count} 条记忆",
memories.filter((m) => m.status !== "deleted").length,
)
: t("memory:正在读取…")
: loaded
? tn(
"memory:已加载 {count} 条记忆",
memories.filter((m) => m.status !== "deleted").length,
)
: t("memory:正在读取…")}
: t("memory:正在读取…")}
</span>
</div>
<MemoryNoteComposer
@@ -1814,23 +2008,37 @@ export function MemorySpace({
focusDialogue={focusDialogue}
onFocusDialogueConsumed={onFocusDialogueConsumed}
libraryView={
simplified
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)
@@ -1838,19 +2046,39 @@ export function MemorySpace({
.catch((reason) => setError(message(reason, t)));
}}
hidden={!active || displayedMode !== "map"}
memories={simplified ? (mapLibrary.page?.items ?? []) : memories}
graph={simplified && sourceSnapshotStale ? null : graph}
memories={
memoryAuthority === "sdk"
? (sdkMap?.memories ?? [])
: simplified
? (mapLibrary.page?.items ?? [])
: memories
}
graph={
memoryAuthority === "sdk"
? (sdkMap?.graph ?? null)
: simplified && sourceSnapshotStale
? null
: graph
}
sources={sources}
processing={processing}
arrival={memoryArrival}
onViewHistory={simplified ? onConnect : () => setMode("history")}
loaded={simplified ? mapLibrary.page !== null : loaded}
loaded={
memoryAuthority === "sdk"
? sdkMap !== null
: simplified
? mapLibrary.page !== null
: loaded
}
detail={detail}
detailLoad={detailLoad}
overlayBlocked={
!active ||
deleteCount > 0 ||
(simplified && (!mapLibrary.page || sourceSnapshotStale))
(memoryAuthority === "sdk"
? sdkMap === null
: simplified && (!mapLibrary.page || sourceSnapshotStale))
}
draft={draft}
busy={busy || detailReadBlocked}
@@ -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();
});
+43 -85
View File
@@ -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");
});
});
+17
View File
@@ -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,
@@ -1162,6 +1168,17 @@ export async function getMemory(memoryId: string): Promise<MemoryDetail> {
*/
let pendingMemoryGraph: Promise<MemoryGraphResponse> | null = null;
/** The memory map on SDK authority: SDK memories plus the graph overlay. */
export async function getSdkMemoryGraph(
signal?: AbortSignal,
): Promise<MemoryGraphResponse> {
const response = await client.get<MemoryGraphResponse>(
"/api/v2/memories/graph",
{ params: { limit: 300 }, signal },
);
return response.data;
}
export async function getMemoryGraphWith(
transport: { get: typeof client.get } = client,
): Promise<MemoryGraphResponse> {
+112
View File
@@ -0,0 +1,112 @@
import type {
GovernedMemory,
MemoryDetail,
MemoryKind,
MemoryLibraryPage,
SdkMemoryPage,
SdkMemoryRecord,
} from "@one-memory/contracts";
function kindOf(layer: string): MemoryKind {
if (layer === "l6_schema") return "schema";
if (layer === "l7_intention") return "intention";
if (layer.startsWith("coding")) return "coding";
if (layer.startsWith("working")) return "working";
return "chat";
}
function stamp(seconds: number | null | undefined): string {
return typeof seconds === "number" && Number.isFinite(seconds)
? new Date(seconds * 1_000).toISOString()
: "";
}
/** An SDK memory in the shape the memory map draws.
*
* 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);
return {
memory_id: record.memory_id,
user_id: record.user_id,
kind: kindOf(record.layer),
status:
record.status === "active"
? "confirmed"
: record.status === "superseded"
? "superseded"
: "deleted",
content: record.content,
scopes: [],
evidence_ids: [],
origin: "extracted",
tags: [...record.tags],
supersedes: [...record.supersedes],
superseded_by: [...record.superseded_by],
// 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,
};
}
+1 -1
View File
@@ -2,7 +2,7 @@
"name": "one-memory",
"version": "1.0.5",
"release": {
"build_number": 17,
"build_number": 23,
"macos_min_version": "13.0",
"app_update": "manual",
"runtime_manifest_schema": 1,
+5
View File
@@ -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?",
@@ -83,7 +83,7 @@ it("does not stop Runtime when nothing needs migrating", async () => {
expect(fenced).toBe(false);
});
it("treats a finished cleanup as a completed migration", async () => {
it("reports a repair of an already migrated home as complete, not a second cutover", async () => {
const result = await runSdkMigration({
dataDir: "/data",
core,
@@ -95,7 +95,7 @@ it("treats a finished cleanup as a completed migration", async () => {
fence: noFence,
});
expect(result).toEqual({ state: "cutover" });
expect(result).toEqual({ state: "complete" });
});
it("reports an unreadable migration status as deferred", async () => {
@@ -4,7 +4,8 @@ import { withRuntimeMaintenanceFence } from "./runtime-maintenance.js";
const MAX_OUTPUT_BYTES = 1024 * 1024;
export interface SdkMigrationResult {
state: "not_required" | "cutover" | "deferred";
/** `complete`: already on SDK authority; leftovers were repaired. */
state: "not_required" | "cutover" | "complete" | "deferred";
code?: string;
}
@@ -84,8 +85,7 @@ export async function runSdkMigration(
}
if (migrated.exitCode !== 0) return deferred(migrated);
const state = migrated.document.state;
if (state === "cutover" || state === "complete")
return { state: "cutover" };
if (state === "cutover" || state === "complete") return { state };
if (state === "not_required") return { state: "not_required" };
return deferred(migrated);
});