Compare commits

..
Author SHA1 Message Date
feitianxiang e881ad4bf8 Merge pull request #169 from Tencent-Hunyuan/fix/sdk-vector-reattach-and-verify
fix(memhub): reattach stranded SDK vectors, verify readback, remove private retrieval index
2026-10-24 18:32:10 +08:00
49 changed files with 399 additions and 3209 deletions

No files matched your search

-22
View File
@@ -4,28 +4,6 @@ Hy Memory and MemHub are versioned and released independently.
## Unreleased
- The SDK memory page now reads its list, count, source and time labels and
graph from one page response for one source filter, so rows, totals and the
map no longer disagree. Memories with no recorded source are shown and
filterable as "unidentified source"; library search keeps only memories
that contain the words within the SDK's relevance window.
- Metadata repair v2 follows `governed_memory_evidence` across every
pre-cutover bundle, which restores the source of memories the cutover
backfilled without one, and moves impossible future conversation times back
to the write time.
- 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.
- SDK memory search now calls the SDK search contract instead of listing the
entire library in Core. Corrupt empty records are skipped and diagnosed
without mutating data or failing a page, and post-cutover metadata repair
restores source and original times from the verified migration bundle.
- Kept Cursor's WAL change token separate from the file mtime so a hash can no
longer appear as a conversation date centuries in the future.
- A trusted packaged Runtime now repairs a dirty same-build installation by
staging a clean replacement, and `runtime_service_not_ready` is rendered as
user-facing startup guidance instead of an internal code.
- 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
-2
View File
@@ -15,8 +15,6 @@
3. MemHub **不存 memory 实体**;**可以存 `memory_id`** 做组织/回执。原文进入 **SDK raw**。
4. SDK raw 只保留滚动三天;一条 raw 对应一个完整 turn,只含真人 user 与
assistant,tool 不持久化。提取后的高层 memory 不受该窗口影响。
5. `source_raw_ids` 是 SDK 内部溯源关系,不是产品 Evidence 合同;记忆详情不提供
“原始依据”或按 raw ID 展开原文的入口。
生产入口通过共享 Runtime 租约取得公开 `HyMemoryClient`;MemHub 只在调用前执行
模型配置、密钥和 harness/model 门禁,不维护第二套 memory 正文权威。
+2 -4
View File
@@ -3180,8 +3180,7 @@ async function runCommand(
migration.code ?? "sdk_migration_incomplete",
);
}
migrated =
migration.state === "cutover" || migration.state === "complete";
migrated = migration.state === "cutover";
await applyPreparedServiceInstall(preparedService);
serviceApplied = true;
// The receipt exists so a later command can find the data dir without
@@ -3666,8 +3665,7 @@ 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" || migration.state === "complete";
migrated = migration.state === "cutover";
await restartUserService(layout, serviceOptions);
// A restart that returns without error only means launchd/systemd
// accepted the command. O-22: the first `upgrade` implementation
+3 -250
View File
@@ -103,9 +103,7 @@ from one_memory.hy_sdk_types import (
HySdkMemoryRecord,
HySdkRawPage,
HySdkRawRecord,
HySdkReadError,
HySdkUserProfile,
library_keyword_hits,
parse_sdk_search,
)
from one_memory.imports.access import (
@@ -881,28 +879,6 @@ def create_app(
app.state.sdk_data_plane_migrations = SdkDataPlaneMigrationStore(database_path)
app.state.memory_authority = app.state.sdk_data_plane_migrations.authority()
if app.state.memory_authority is MemoryAuthority.SDK:
from one_memory.hy_sdk_migration import (
repair_sdk_metadata_from_bundle,
)
try:
repair = await asyncio.to_thread(
repair_sdk_metadata_from_bundle,
data_dir=runtime_settings.data_dir,
database_path=database_path,
)
if repair.vector_rows or repair.coding_rows:
logger.info(
"sdk_metadata_repair_completed mapped=%d vector=%d coding=%d",
repair.mapped,
repair.vector_rows,
repair.coding_rows,
)
except Exception as error:
logger.warning(
"sdk_metadata_repair_failed error_type=%s",
type(error).__name__,
)
app.state.memory_governance_store = RetiredGovernanceStore(database_path)
app.state.evidence_store = RetiredEvidenceStore(database_path)
else:
@@ -3082,8 +3058,7 @@ def create_app(
offset=0,
order="desc",
)
page, skipped = HySdkMemoryPage.from_sdk_skipping_invalid(raw, limit=1, offset=0)
_log_skipped_sdk_memories(skipped)
page = HySdkMemoryPage.from_sdk(raw, limit=1, offset=0)
except Exception as error:
raise _sdk_http_error(error, "hy_memory_list_failed") from None
return SdkHomeSummary(
@@ -3128,10 +3103,7 @@ def create_app(
if store is None:
store = SdkDataPlaneMigrationStore(_evidence_store(app).database_path)
authority, migration_id = store.authority_state()
return MemoryAuthorityResponse(
authority="sdk" if authority is MemoryAuthority.SDK else "legacy",
migration_id=migration_id,
)
return MemoryAuthorityResponse(authority=authority.value, migration_id=migration_id)
@app.get(
"/api/v2/memory-library",
@@ -3140,221 +3112,22 @@ 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()
search_query = (query or "").strip()
if search_query:
if set(statuses) != {"active"}:
raise HTTPException(
status_code=422,
detail="sdk_search_supports_active_status_only",
)
# SDK search has no count-only contract and, on the local store,
# no keyword channel. Ask for one bounded relevance window with
# no score floor, then keep the rows that contain the words, so
# a word in the library is found and a word in none of it finds
# nothing. The whole library is never listed in Core.
# A larger window made one search take long enough to freeze
# the page; 20 is the bounded window the library searches.
fetch = 20
search_raw = await asyncio.to_thread(
client.search,
search_query,
user_ids=["local"],
agent_ids=[],
include_sources=[source] if source else None,
limit=fetch,
min_score=0.0,
schema_min_score=0.0,
schema_limit=fetch,
# The library lists stored memories; proactive intentions
# are a recall-time lane the list never shows.
intention_limit=0,
scene="normal",
reader=HY_MEMORY_READER,
)
hit_ids, window = library_keyword_hits(search_raw, search_query)
rows = await _get_sdk_rows(client, list(hit_ids[offset : offset + limit]))
memories: list[HySdkMemoryRecord] = []
skipped = 0
for row in rows:
if row is None:
continue
try:
record = HySdkMemoryRecord.from_sdk(row)
except HySdkReadError:
skipped += 1
continue
if record.status == "active":
memories.append(record)
_log_skipped_sdk_memories(skipped)
return HySdkMemoryPage(
memories=tuple(memories),
total=len(hit_ids),
limit=limit,
offset=offset,
search_window=fetch if window >= fetch else None,
)
raw = await asyncio.to_thread(
client.list_memories,
user_id="local",
statuses=list(statuses),
include_sources=[source] if source else None,
offset=offset,
limit=limit,
)
page, skipped = HySdkMemoryPage.from_sdk_skipping_invalid(
raw, limit=limit, offset=offset
)
_log_skipped_sdk_memories(skipped)
return page
except HTTPException:
raise
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),
offset: int = Query(default=0, ge=0),
source: str | None = Query(default=None, min_length=1),
) -> 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()
# The map draws the page the list shows, so it reads the same
# source filter and window rather than the newest memories overall.
raw = await asyncio.to_thread(
client.list_memories,
user_id="local",
include_sources=[source] if source else None,
limit=limit,
offset=offset,
order="desc",
)
page, skipped = HySdkMemoryPage.from_sdk_skipping_invalid(
raw, limit=limit, offset=offset
)
_log_skipped_sdk_memories(skipped)
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,
)
# The SDK fills in "default_session" when a writer gave none (the
# cutover backfill did so for every legacy memory); it names no
# conversation, so it must not chain those memories together.
if memory.session_id and memory.session_id != "default_session":
sessions.setdefault(memory.session_id, []).append(memory_node_id)
if memory.source and memory.source != "default":
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,
@@ -5658,26 +5431,6 @@ def _sdk_client_handle(app: FastAPI) -> SdkClientHandle:
return handle
def _log_skipped_sdk_memories(count: int) -> None:
if count:
logger.warning("sdk_memory_invalid_rows_skipped count=%d", count)
# `client.get` funnels into the SDK's single loop thread, so more workers only
# queue there while holding the default executor every other request needs.
_SDK_GET_CONCURRENCY = 8
async def _get_sdk_rows(client: Any, memory_ids: list[str]) -> list[object]:
gate = asyncio.Semaphore(_SDK_GET_CONCURRENCY)
async def one(memory_id: str) -> object:
async with gate:
return await asyncio.to_thread(client.get, memory_id)
return list(await asyncio.gather(*(one(memory_id) for memory_id in memory_ids)))
async def _get_sdk_memory(
handle: SdkClientHandle,
memory_id: str,
@@ -47,19 +47,6 @@ class ContextSearchRequest(BaseModel):
return self
class SdkContextSearchRequest(ContextSearchRequest):
"""`POST /api/v2/context/search`: the three SDK recall lanes are set apart.
`limit` is the normal lane's top-k and the cap on returned items;
`schema_limit` and `intention_limit` are the SDK's schema (L6) and
proactive intention (L7) lanes. A caller's values are passed through.
"""
limit: int = Field(default=10, ge=1, le=20, strict=True)
schema_limit: int = Field(default=3, ge=0, le=20, strict=True)
intention_limit: int = Field(default=1, ge=0, le=20, strict=True)
class ContextMessage(BaseModel):
model_config = ConfigDict(frozen=True, extra="forbid")
@@ -34,7 +34,6 @@ from one_memory.context_models import (
MemoryContextItem,
RawContextItem,
SdkContextItem,
SdkContextSearchRequest,
SdkContextSearchResponse,
SdkMemoryContextItem,
SdkRawContextItem,
@@ -440,7 +439,7 @@ def sdk_context_router(dependencies: Sequence[Depends]) -> APIRouter:
@router.post("/search", response_model=SdkContextSearchResponse)
async def search(
value: SdkContextSearchRequest,
value: ContextSearchRequest,
request: Request,
response: Response,
) -> SdkContextSearchResponse:
@@ -515,8 +514,8 @@ def sdk_context_router(dependencies: Sequence[Depends]) -> APIRouter:
limit=value.limit,
min_score=sdk_relevance_floor(),
schema_min_score=sdk_relevance_floor(),
schema_limit=value.schema_limit,
intention_limit=value.intention_limit,
schema_limit=value.limit,
intention_limit=value.limit,
scene="normal",
reader=HY_MEMORY_READER,
)
@@ -1,20 +1,13 @@
"""Offline maintenance of the HY SDK data plane for App migrations.
"""Offline snapshot and restore of the HY SDK data plane for the App migration.
Runtime product code must use public ``HyMemoryClient`` methods. The startup
migration boundary is the isolated exception: all writers are stopped, so it
may copy, restore and repair SDK-owned local stores.
migration bundle is the isolated exception: all writers are stopped, so it may
copy and restore the SDK-owned local stores as files.
"""
from __future__ import annotations
import json
import sqlite3
from collections.abc import Iterator
from contextlib import closing
from dataclasses import dataclass, field
from datetime import UTC, datetime, timedelta
from pathlib import Path
from typing import Any
from hy_memory import ( # type: ignore[import-untyped]
DataPlaneRestore,
@@ -23,28 +16,6 @@ from hy_memory import ( # type: ignore[import-untyped]
snapshot_local_data_plane,
)
from one_memory.database_access import main_database
from one_memory.hy_runtime_config import HY_MEMORY_NAMESPACE
# v2 reads the backfill's governed_memory_evidence links; v1 missed them.
_METADATA_REPAIR_VERSION = 2
_BATCH = 500
@dataclass(frozen=True)
class RepairMetadata:
source: str | None
memory_at: int | None
created_at: int | None
@dataclass(frozen=True)
class MetadataRepairResult:
migration_id: str | None
mapped: int
vector_rows: int
coding_rows: int
def snapshot_data_plane(
*,
@@ -74,400 +45,3 @@ def restore_data_plane(
writers_stopped=writers_stopped,
protective_root=protective_root,
)
def repair_sdk_metadata_from_bundle(
*,
data_dir: Path,
database_path: Path,
now: datetime | None = None,
) -> MetadataRepairResult:
"""Restore metadata lost by historical cutovers from their verified bundles."""
migration_id = _sdk_migration_id(database_path)
if migration_id is None:
return MetadataRepairResult(None, 0, 0, 0)
receipt = data_dir / f".sdk-metadata-repair-v{_METADATA_REPAIR_VERSION}-{migration_id}.json"
if receipt.is_file():
document = json.loads(receipt.read_text(encoding="utf-8"))
return MetadataRepairResult(
migration_id,
int(document.get("mapped", 0)),
int(document.get("vector_rows", 0)),
int(document.get("coding_rows", 0)),
)
# A home can carry several cutovers (v1 and then v2). A later bundle may
# have been taken after an earlier cutover had already retired the legacy
# tables, so every bundle is read and their links are pooled.
legacy = sorted(
(data_dir / "backups").glob("*-pre-cutover.sdk-bundle/management/one-memory.sqlite3")
)
if not legacy:
# No bundle yet is not a finished repair: try again on the next start.
return MetadataRepairResult(migration_id, 0, 0, 0)
current = now or datetime.now(UTC)
pools: dict[str, _Pool] = {}
for path in legacy:
_merge_pools(pools, _legacy_pools(path, now=current))
mapping = _decide(pools)
vector_rows = _repair_vector(data_dir, mapping, now=current)
coding_rows = _repair_coding(data_dir, mapping)
result = MetadataRepairResult(
migration_id,
len(mapping),
vector_rows,
coding_rows,
)
temporary = receipt.with_suffix(".tmp")
temporary.write_text(
json.dumps(
{
"schema_version": 1,
"migration_id": migration_id,
"mapped": result.mapped,
"vector_rows": vector_rows,
"coding_rows": coding_rows,
},
sort_keys=True,
)
+ "\n",
encoding="utf-8",
)
temporary.replace(receipt)
return result
def _sdk_migration_id(database_path: Path) -> str | None:
with main_database(database_path).read(row_factory=sqlite3.Row) as database:
row = database.execute(
"SELECT authority,migration_id FROM memory_authority_state WHERE singleton=1"
).fetchone()
if row is None or row["authority"] != "sdk":
return None
value = row["migration_id"]
return str(value) if isinstance(value, str) and value else None
@dataclass
class _Pool:
"""Everything the legacy library knew about one SDK memory."""
sources: set[str] = field(default_factory=set)
times: list[datetime] = field(default_factory=list)
created: list[datetime] = field(default_factory=list)
def _legacy_metadata(path: Path, *, now: datetime) -> dict[str, RepairMetadata]:
return _decide(_legacy_pools(path, now=now))
def _legacy_pools(path: Path, *, now: datetime) -> dict[str, _Pool]:
"""Link each SDK memory id to the legacy evidence it was made from.
Three legacy paths wrote memories the SDK now holds:
- the cutover backfill (`import_exact`) copied every governed memory into the
SDK under its legacy id, or under the id `memory_task_outputs` recorded,
without a source or a conversation time; `governed_memory_evidence` is the
link from that memory to its evidence;
- extraction tasks recorded their evidence as `support_refs`;
- the write queue recorded the SDK ids each job produced.
"""
with closing(sqlite3.connect(f"file:{path}?mode=ro", uri=True)) as database:
database.row_factory = sqlite3.Row
tables = {
str(row[0])
for row in database.execute("SELECT name FROM sqlite_master WHERE type='table'")
}
if "evidence" not in tables:
return {}
links: dict[str, set[str]] = {}
created: dict[str, list[datetime]] = {}
canonical: dict[str, str] = {}
if "memory_task_outputs" in tables:
for row in database.execute("SELECT body FROM memory_task_outputs"):
try:
body = json.loads(str(row["body"]))
except (TypeError, ValueError):
continue
legacy_id = body.get("memory_id")
canonical_id = body.get("hy_memory_id")
refs = body.get("support_refs")
if not isinstance(legacy_id, str) or not isinstance(canonical_id, str):
continue
canonical[legacy_id] = canonical_id
links.setdefault(canonical_id, set()).update(
str(ref["evidence_id"])
for ref in (refs if isinstance(refs, list) else ())
if isinstance(ref, dict) and isinstance(ref.get("evidence_id"), str)
)
if "governed_memories" in tables:
for row in database.execute(
"SELECT memory_id,created_at,updated_at FROM governed_memories"
):
legacy_id = str(row["memory_id"])
canonical_id = canonical.get(legacy_id, legacy_id)
links.setdefault(canonical_id, set())
legacy_created = _plausible_time(
row["created_at"], fallback=row["updated_at"], now=now
)
if legacy_created is not None:
created.setdefault(canonical_id, []).append(legacy_created)
if "governed_memory_evidence" in tables:
for row in database.execute(
"SELECT memory_id,evidence_id FROM governed_memory_evidence"
):
legacy_id = str(row["memory_id"])
links.setdefault(canonical.get(legacy_id, legacy_id), set()).add(
str(row["evidence_id"])
)
if "memory_write_jobs" in tables and "sdk_memory_ids_json" in _columns(
database, "memory_write_jobs"
):
job_evidence: dict[str, set[str]] = {}
if "memory_write_job_evidence" in tables:
for row in database.execute(
"SELECT job_id,evidence_id FROM memory_write_job_evidence"
):
job_evidence.setdefault(str(row["job_id"]), set()).add(str(row["evidence_id"]))
for row in database.execute(
"SELECT job_id,sdk_memory_ids_json FROM memory_write_jobs "
"WHERE sdk_memory_ids_json IS NOT NULL"
):
try:
memory_ids = json.loads(str(row["sdk_memory_ids_json"]))
except (TypeError, ValueError):
continue
for memory_id in memory_ids if isinstance(memory_ids, list) else ():
if isinstance(memory_id, str) and memory_id:
links.setdefault(memory_id, set()).update(
job_evidence.get(str(row["job_id"]), ())
)
evidence: dict[str, tuple[str, datetime | None]] = {}
wanted = sorted({identifier for refs in links.values() for identifier in refs})
for batch in _batches(wanted):
placeholders = ",".join("?" for _ in batch)
for row in database.execute(
"SELECT evidence_id,source_id,created_at,imported_at "
f"FROM evidence WHERE evidence_id IN ({placeholders})",
batch,
):
evidence[str(row["evidence_id"])] = (
str(row["source_id"]),
_plausible_time(row["created_at"], fallback=row["imported_at"], now=now),
)
pools: dict[str, _Pool] = {}
for canonical_id, refs in links.items():
pool = pools.setdefault(canonical_id, _Pool())
for identifier in refs:
if identifier not in evidence:
continue
source, timestamp = evidence[identifier]
if source and source != "default":
pool.sources.add(source)
if timestamp is not None:
pool.times.append(timestamp)
pool.created.extend(created.get(canonical_id, ()))
return pools
def _merge_pools(into: dict[str, _Pool], other: dict[str, _Pool]) -> None:
for canonical_id, pool in other.items():
merged = into.setdefault(canonical_id, _Pool())
merged.sources |= pool.sources
merged.times.extend(pool.times)
merged.created.extend(pool.created)
def _decide(pools: dict[str, _Pool]) -> dict[str, RepairMetadata]:
# Several legacy memories can fold into one SDK memory. Their evidence is
# pooled before deciding, so a disagreeing source stays unknown instead of
# whichever link happened to come last.
return {
canonical_id: RepairMetadata(
source=next(iter(pool.sources)) if len(pool.sources) == 1 else None,
memory_at=int(max(pool.times).timestamp()) if pool.times else None,
created_at=int(min(pool.created).timestamp()) if pool.created else None,
)
for canonical_id, pool in pools.items()
}
def _columns(database: sqlite3.Connection, table: str) -> set[str]:
return {str(row[1]) for row in database.execute(f"PRAGMA table_info({table})")}
def _plausible_time(
value: object,
*,
fallback: object,
now: datetime,
) -> datetime | None:
for candidate in (value, fallback):
if not isinstance(candidate, str) or not candidate:
continue
try:
parsed = datetime.fromisoformat(candidate)
except ValueError:
continue
if parsed.tzinfo is None:
parsed = parsed.replace(tzinfo=UTC)
parsed = parsed.astimezone(UTC)
if parsed <= now + timedelta(days=1):
return parsed
return None
def _repair_vector(
data_dir: Path,
mapping: dict[str, RepairMetadata],
*,
now: datetime,
) -> int:
directory = data_dir / "hy-memory" / HY_MEMORY_NAMESPACE / "vector"
if not (directory / "chroma.sqlite3").is_file():
return 0
import chromadb
from chromadb.config import Settings
client = chromadb.PersistentClient(
path=str(directory),
settings=Settings(anonymized_telemetry=False),
)
# The SDK client is not open yet (startup has not created its handle), so
# this is the only writer; release the store before the SDK takes it.
try:
repaired = 0
ids = tuple(mapping)
for collection in client.list_collections():
for batch in _batches(ids):
page = collection.get(ids=list(batch), include=["metadatas"])
updated_ids: list[str] = []
updated_metadata: list[dict[str, object]] = []
for memory_id, metadata in zip(
page.get("ids") or (),
page.get("metadatas") or (),
strict=True,
):
if not isinstance(metadata, dict):
continue
desired = mapping.get(str(memory_id))
if desired is None:
continue
next_metadata = dict(metadata)
changed = False
if desired.source and next_metadata.get("source") in {
None,
"",
"default",
}:
next_metadata["source"] = desired.source
changed = True
if (
desired.memory_at is not None
and next_metadata.get("memory_at") != desired.memory_at
):
next_metadata["memory_at"] = desired.memory_at
changed = True
if (
desired.created_at is not None
and next_metadata.get("gmt_created") != desired.created_at
):
next_metadata["gmt_created"] = desired.created_at
changed = True
if changed:
updated_ids.append(str(memory_id))
updated_metadata.append(next_metadata)
if updated_ids:
collection.update(ids=updated_ids, metadatas=updated_metadata)
repaired += len(updated_ids)
repaired += _settle_future_times(collection, now=now)
finally:
client.close()
return repaired
def _settle_future_times(collection: Any, *, now: datetime) -> int:
"""Move conversation times that are still in the future back to the write time.
A historical Cursor bug stored a file hash as the message time (years like
2254). The SDK lists newest first by `memory_at`, so those rows hold the
first page. Rows the bundles could date were fixed above; for the rest the
write time is the closest true moment that is known.
"""
cutoff = int((now + timedelta(days=1)).timestamp())
page = collection.get(where={"memory_at": {"$gt": cutoff}}, include=["metadatas"])
updated_ids: list[str] = []
updated_metadata: list[dict[str, object]] = []
for memory_id, metadata in zip(page.get("ids") or (), page.get("metadatas") or (), strict=True):
if not isinstance(metadata, dict):
continue
created = metadata.get("gmt_created")
settled = (
created
if isinstance(created, int) and not isinstance(created, bool) and created <= cutoff
else int(now.timestamp())
)
updated_ids.append(str(memory_id))
updated_metadata.append({**metadata, "memory_at": settled})
if updated_ids:
collection.update(ids=updated_ids, metadatas=updated_metadata)
return len(updated_ids)
def _repair_coding(
data_dir: Path,
mapping: dict[str, RepairMetadata],
) -> int:
root = data_dir / "hy-memory" / HY_MEMORY_NAMESPACE
repaired = 0
for name in ("coding.sqlite3", "coding_cv.sqlite3"):
path = root / name
if not path.is_file():
continue
with closing(sqlite3.connect(path)) as database:
tables = {
str(row[0])
for row in database.execute("SELECT name FROM sqlite_master WHERE type='table'")
}
for table, identifier in (
("coding_memory_meta", "experience_id"),
("working_fact", "fact_id"),
):
if table not in tables:
continue
rows = database.execute(
f"SELECT {identifier},source,created_at FROM {table}"
).fetchall()
for memory_id, source, created_at in rows:
desired = mapping.get(str(memory_id))
if desired is None:
continue
updates: list[str] = []
values: list[object] = []
if desired.source and source in {None, "", "default"}:
updates.append("source=?")
values.append(desired.source)
if desired.created_at is not None:
desired_created_at = (
datetime.fromtimestamp(desired.created_at, tz=UTC)
.replace(tzinfo=None)
.isoformat()
)
if created_at != desired_created_at:
updates.append("created_at=?")
values.append(desired_created_at)
if not updates:
continue
values.append(memory_id)
database.execute(
f"UPDATE {table} SET {','.join(updates)} WHERE {identifier}=?",
values,
)
repaired += 1
database.commit()
return repaired
def _batches[T](values: list[T] | tuple[T, ...]) -> Iterator[tuple[T, ...]]:
for start in range(0, len(values), _BATCH):
yield tuple(values[start : start + _BATCH])
@@ -92,35 +92,9 @@ class HySdkMemoryPage(BaseModel):
total: int = Field(ge=0)
limit: int = Field(ge=1)
offset: int = Field(ge=0)
# Set on a search whose relevance window was full: matches were looked for
# only among this many most relevant memories, so `total` may undercount.
search_window: int | None = None
@classmethod
def from_sdk(cls, raw: object, *, limit: int, offset: int) -> HySdkMemoryPage:
page, _skipped = cls._parse(raw, limit=limit, offset=offset, skip_invalid=False)
return page
@classmethod
def from_sdk_skipping_invalid(
cls, raw: object, *, limit: int, offset: int
) -> tuple[HySdkMemoryPage, int]:
"""Parse a page, leaving out rows outside the public record contract.
Returns the page and how many rows were left out, so the caller can
report them. The rows themselves are never touched.
"""
return cls._parse(raw, limit=limit, offset=offset, skip_invalid=True)
@classmethod
def _parse(
cls,
raw: object,
*,
limit: int,
offset: int,
skip_invalid: bool,
) -> tuple[HySdkMemoryPage, int]:
if not isinstance(raw, Mapping):
raise HySdkReadError("hy_memory_list_shape_unknown")
rows: list[object] = []
@@ -141,27 +115,15 @@ class HySdkMemoryPage(BaseModel):
total += bucket_total
rows.extend(bucket[rows_name])
deduplicated: dict[str, HySdkMemoryRecord] = {}
skipped = 0
for row in rows:
try:
record = HySdkMemoryRecord.from_sdk(row)
except HySdkReadError:
if not skip_invalid:
raise
skipped += 1
continue
record = HySdkMemoryRecord.from_sdk(row)
deduplicated.setdefault(record.memory_id, record)
page = cls(
return cls(
memories=tuple(deduplicated.values()),
# `total` is the SDK's stable result-set count. Subtracting only
# invalid rows observed on this page would make it change as the
# caller paged. Until the SDK exposes a quarantined count, keep the
# authoritative total and skip malformed rows only in this page.
total=total,
limit=limit,
offset=offset,
)
return page, skipped
class HySdkRawRecord(BaseModel):
@@ -327,38 +289,6 @@ def parse_sdk_search(raw: object, *, error_code: str) -> tuple[SemanticMemoryHit
return tuple(found)
def library_keyword_hits(raw: object, query: str) -> tuple[tuple[str, ...], int]:
"""Ids in an SDK search window whose text contains every query term.
The local SDK store has no keyword channel (Chroma's keyword search is
empty), so search is pure vector similarity: a word in the library can
score below the floor, and a word in none of it still scores above. The
library search asks the SDK for its most relevant window and keeps only the
rows that actually contain the words, most relevant first. Returns those ids
and how many distinct rows the window held.
"""
rows = _reply_rows(raw, "hy_memory_search_shape_unknown")
terms = query.casefold().split()
seen: set[str] = set()
matched: list[str] = []
for item in sorted(rows, key=_score, reverse=True):
memory_id = item.get("memory_id") or item.get("id")
if not isinstance(memory_id, str) or not memory_id or memory_id in seen:
continue
seen.add(memory_id)
content = item.get("content")
tags = item.get("tags")
text = " ".join(
[
content if isinstance(content, str) else "",
*(tag for tag in (tags if isinstance(tags, list) else ()) if isinstance(tag, str)),
]
).casefold()
if all(term in text for term in terms):
matched.append(memory_id)
return tuple(matched), len(seen)
def parse_sdk_raw_search(raw: object) -> tuple[HySdkRawHit, ...]:
return tuple(
HySdkRawHit.from_sdk_search(row)
@@ -50,10 +50,6 @@ class AuthorizedSourceFile:
size: int
modified_ns: int
changed_ns: int
# Source-specific change token (for example Cursor's WAL identity). It is
# cursor metadata only; modified_ns remains the real filesystem mtime used
# as a fallback conversation timestamp.
cursor_version: str | None = None
root_identity: str | None = None
requested_root: Path | None = None
access_check: Callable[[], None] | None = field(default=None, repr=False, compare=False)
@@ -644,7 +644,7 @@ def is_unchanged(source_file: AuthorizedSourceFile, cursors: dict[str, SourceCur
return (
previous is not None
and previous.read_to_end
and previous.file_identity == _cursor_file_identity(source_file)
and previous.file_identity == source_file.file_identity
and previous.observed_size == source_file.size
and previous.observed_modified_ns == source_file.modified_ns
# Chmod alone leaves size, mtime and inode untouched, so a file that
@@ -691,7 +691,7 @@ def touched_cursor(
previous = options.cursors.get(source_file.safe_id)
keep = (
previous is not None
and _same_file(previous, source_file)
and previous.file_identity == source_file.file_identity
and previous.observed_size <= source_file.size
)
# A kept cursor means the next scan resumes exactly where this one ended:
@@ -871,7 +871,7 @@ def resume_checkpoint(
previous = options.cursors.get(source_file.safe_id)
if previous is None:
return None
if not _same_file(previous, source_file):
if previous.file_identity != source_file.file_identity:
return None
if source_file.size < previous.observed_size:
return None
@@ -1046,7 +1046,7 @@ def source_cursor(
) -> SourceCursor:
return SourceCursor(
file_key=source_file.safe_id,
file_identity=_cursor_file_identity(source_file),
file_identity=source_file.file_identity,
checkpoint=checkpoint,
checkpoint_anchor=checkpoint_anchor,
observed_size=source_file.size,
@@ -1054,27 +1054,6 @@ def source_cursor(
)
def _cursor_file_identity(source_file: AuthorizedSourceFile) -> str:
return (
f"{source_file.file_identity}{_VERSION_SEPARATOR}{source_file.cursor_version}"
if source_file.cursor_version
else source_file.file_identity
)
_VERSION_SEPARATOR = ":v:"
def _same_file(previous: SourceCursor, source_file: AuthorizedSourceFile) -> bool:
"""Whether a stored cursor still points into this file.
The stored identity may carry a change token (Cursor's WAL version). The
token says the file changed, which `is_unchanged` handles; it does not mean
the file was replaced, so resuming compares the bare `dev:ino` only.
"""
return previous.file_identity.partition(_VERSION_SEPARATOR)[0] == source_file.file_identity
_POSITION = re.compile(r"(?<=:)\d+(?=:|$)")
@@ -533,8 +533,8 @@ def _with_write_ahead_log(database: AuthorizedSourceFile) -> AuthorizedSourceFil
version = hashlib.blake2b(
f"{database.modified_ns}:{journal.st_mtime_ns}:{journal.st_size}:{journal.st_ino}".encode(),
digest_size=8,
).hexdigest()
return replace(database, cursor_version=version)
).digest()
return replace(database, modified_ns=int.from_bytes(version, "big") & ((1 << 63) - 1))
def _live_baseline(listing: _CursorListing) -> dict[str, SourceCursor]:
@@ -22,13 +22,6 @@ 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
@@ -92,15 +92,6 @@ ALLOWED = {
"the memory ids a migrated home must still read back",
opened_with="?mode=ro",
),
("hy_sdk_migration.py", "_legacy_pools"): Allowed(
"each pre-cutover bundle's copy of the legacy database, read-only, to "
"recover the source and times historical cutovers dropped",
opened_with="?mode=ro",
),
("hy_sdk_migration.py", "_repair_coding"): Allowed(
"the SDK's own Coding/Working stores, not the main database: the one-time "
"metadata repair writes them at startup, before the SDK client opens"
),
("sdk_vector_reattach.py", "_stored_names"): Allowed(
"Chroma's own chroma.sqlite3, read-only, to list collection names before "
"the offline migration opens Chroma",
-213
View File
@@ -278,216 +278,3 @@ def test_sdk_user_profile_http_returns_native_assembled_profile(tmp_path):
}
asyncio.run(scenario())
@pytest.mark.fresh_sdk
def test_sdk_memory_library_uses_sdk_search_and_skips_empty_rows_without_mutation(
tmp_path,
caplog,
):
async def scenario():
app = application(tmp_path)
list_calls = []
search_calls = []
deleted = []
records = {
"release": {
"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"],
},
"retired": {
"memory_id": "retired",
"content": "Release plan, first draft",
"layer": "l2_fact",
"status": "superseded",
"user_id": "local",
"agent_id": "all-apps",
"session_id": "s1",
"source": "cursor",
"tags": [],
},
"default": {
"memory_id": "default",
"content": "Unknown source",
"layer": "l2_fact",
"status": "active",
"user_id": "local",
"agent_id": "all-apps",
"session_id": "s3",
"source": "default",
"tags": [],
},
}
class Client:
def list_memories(self, **kwargs):
list_calls.append(kwargs)
rows = [
records["release"],
records["default"],
{
"memory_id": "empty",
"content": "",
"layer": "l2_fact",
"status": "active",
"user_id": "local",
"agent_id": "all-apps",
"session_id": "s2",
"source": "workbuddy",
"tags": [],
},
]
include_sources = kwargs.get("include_sources") or []
if include_sources:
rows = [row for row in rows if row["source"] in include_sources]
return {
"vdb": {
"memories": rows,
"total": len(rows),
"limit": kwargs["limit"],
"offset": kwargs["offset"],
}
}
def search(self, query, **kwargs):
search_calls.append((query, kwargs))
return {
"memories": {
"schema": [],
"proactive": [],
"normal": [
{
"memory_id": "release",
"content": "Release plan",
"score": 0.9,
},
{
"memory_id": "retired",
"content": "Release plan, first draft",
"score": 0.8,
},
],
}
}
def get(self, memory_id):
return records.get(memory_id)
def delete(self, memory_id):
deleted.append(memory_id)
return {"success": True, "deleted_count": 1}
sdk = Client()
class Handle:
async def client(self):
return sdk
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()
# `total` counts the window's keyword matches; the superseded
# hit matches but is not listed as an active memory.
assert body["total"] == 2
assert body["search_window"] is None
assert [item["memory_id"] for item in body["memories"]] == ["release"]
assert search_calls[0][0] == "release"
assert search_calls[0][1]["include_sources"] == ["cursor"]
assert search_calls[0][1]["limit"] == 20
assert search_calls[0][1]["intention_limit"] == 0
assert search_calls[0][1]["min_score"] == 0.0
assert list_calls == []
# The SDK still returns its nearest rows for a word that is in
# none of them; the library reports no match instead.
unmatched = await client.get(
"/api/v2/memory-library", params={"query": "zzz nowhere"}
)
assert unmatched.json()["total"] == 0
assert unmatched.json()["memories"] == []
listed = await client.get(
"/api/v2/memory-library",
params={"source": "workbuddy", "limit": 100},
)
assert listed.status_code == 200
assert listed.json()["memories"] == []
assert listed.json()["total"] == 1
assert deleted == []
assert list_calls[0]["limit"] == 100
assert "sdk_memory_invalid_rows_skipped count=1" in caplog.messages
graph = await client.get("/api/v2/memories/graph")
assert graph.status_code == 200
node_ids = {node["node_id"] for node in graph.json()["nodes"]}
assert "source:cursor" in node_ids
assert "source:default" not in node_ids
asyncio.run(scenario())
@pytest.mark.fresh_sdk
def test_sdk_context_search_sets_each_recall_lane_apart(tmp_path):
"""Normal, schema and intention lanes default to 10 / 3 / 1 and pass through."""
async def scenario():
app = application(tmp_path)
calls = []
class Client:
def search(self, query, **kwargs):
calls.append(kwargs)
return {"memories": {"schema": [], "proactive": [], "normal": []}}
def get(self, memory_id):
return None
sdk = Client()
class Handle:
async def client(self):
return sdk
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:
defaulted = await client.post(
"/api/v2/context/search",
json={"query": "release", "kinds": ["memory"]},
)
chosen = await client.post(
"/api/v2/context/search",
json={
"query": "release",
"kinds": ["memory"],
"limit": 5,
"schema_limit": 0,
"intention_limit": 2,
},
)
assert defaulted.status_code == 200
assert chosen.status_code == 200
assert [
(call["limit"], call["schema_limit"], call["intention_limit"]) for call in calls
] == [(10, 3, 1), (5, 0, 2)]
asyncio.run(scenario())
@@ -136,64 +136,6 @@ def test_cursor_store_timestamp_envelope_keeps_only_the_user_query_text(tmp_path
assert meta["turn_event_at"] == "2026-09-22T23:34:00+00:00"
def test_cursor_wal_change_token_does_not_become_a_future_conversation_time(
tmp_path,
) -> None:
root = tmp_path / ".cursor"
database = root / "chats" / "workspace" / "wal-time" / "store.db"
database.parent.mkdir(parents=True)
connection = sqlite3.connect(database)
connection.execute("PRAGMA journal_mode=WAL")
connection.execute("PRAGMA wal_autocheckpoint=0")
connection.execute("CREATE TABLE blobs (id TEXT, data TEXT)")
connection.execute(
"INSERT INTO blobs VALUES (?, ?)",
("message", json.dumps({"role": "assistant", "content": "answer"})),
)
connection.commit()
stamp = 1_788_220_800
os.utime(database, (stamp, stamp))
try:
records = evidence(
asyncio.run(scan(CursorUnifiedSourceAdapter(grant=grant("cursor", root))))
)
finally:
connection.close()
assert len(records) == 1
assert int(records[0].evidence.created_at.timestamp()) == stamp
def test_cursor_wal_store_resumes_from_its_rowid_after_new_frames(tmp_path) -> None:
"""The WAL change token marks a store changed; it must not reset its checkpoint."""
root = tmp_path / ".cursor"
database = root / "chats" / "workspace" / "wal-resume" / "store.db"
database.parent.mkdir(parents=True)
connection = sqlite3.connect(database)
connection.execute("PRAGMA journal_mode=WAL")
connection.execute("PRAGMA wal_autocheckpoint=0")
connection.execute("CREATE TABLE blobs (id TEXT, data TEXT)")
connection.execute(
"INSERT INTO blobs VALUES (?, ?)",
("old", json.dumps({"id": "old", "role": "user", "content": "old row"})),
)
connection.commit()
adapter = CursorUnifiedSourceAdapter(grant=grant("cursor", root))
try:
baseline = asyncio.run(adapter.live_baseline())
assert evidence(asyncio.run(scan(adapter, cursors=baseline))) == []
connection.execute(
"INSERT INTO blobs VALUES (?, ?)",
("new", json.dumps({"id": "new", "role": "user", "content": "new row"})),
)
connection.commit()
appended = asyncio.run(scan(adapter, cursors=baseline))
finally:
connection.close()
assert [item.evidence.verbatim for item in evidence(appended)] == ["new row"]
def test_cursor_transcript_timestamp_envelope_keeps_only_the_user_query_text(tmp_path) -> None:
root = tmp_path / ".cursor"
transcript = (
@@ -4,8 +4,6 @@ import asyncio
import hashlib
import io
import json
import sqlite3
from contextlib import closing
from datetime import UTC, datetime, timedelta
from pathlib import Path
@@ -17,7 +15,6 @@ from one_memory import cli
from one_memory.api import create_app
from one_memory.config import Settings
from one_memory.database_access import close_main_databases, main_database
from one_memory.hy_sdk_migration import repair_sdk_metadata_from_bundle
from one_memory.hy_sdk_types import HySdkRawPage
from one_memory.imports.storage import EvidenceStore
from one_memory.memory_server.contracts import BatchIdentity, WriteRequest
@@ -70,260 +67,6 @@ def _start(store, migration_id="m1"):
)
def test_post_cutover_metadata_repair_restores_source_and_original_times(store):
data_dir = store.database_path.parent
migration_id = "sdk-authority-v2-source-repair"
with main_database(store.database_path).write() as database, database:
database.execute(
"UPDATE memory_authority_state SET authority='sdk',migration_id=? WHERE singleton=1",
(migration_id,),
)
legacy = (
data_dir
/ "backups"
/ f"{migration_id}-pre-cutover.sdk-bundle"
/ "management"
/ "one-memory.sqlite3"
)
legacy.parent.mkdir(parents=True)
with sqlite3.connect(legacy) as database:
database.executescript(
"""
CREATE TABLE memory_task_outputs (body TEXT);
CREATE TABLE governed_memories (
memory_id TEXT, created_at TEXT, updated_at TEXT
);
CREATE TABLE evidence (
evidence_id TEXT, source_id TEXT, created_at TEXT, imported_at TEXT
);
"""
)
database.execute(
"INSERT INTO governed_memories VALUES (?,?,?)",
("legacy-1", "2026-10-18T00:00:00+00:00", "2026-10-18T01:00:00+00:00"),
)
database.execute(
"INSERT INTO evidence VALUES (?,?,?,?)",
("e1", "workbuddy", "2254-01-01T00:00:00+00:00", "2026-10-17T00:00:00+00:00"),
)
database.execute(
"INSERT INTO memory_task_outputs VALUES (?)",
(
json.dumps(
{
"memory_id": "legacy-1",
"hy_memory_id": "sdk-1",
"support_refs": [{"evidence_id": "e1"}],
}
),
),
)
import chromadb
from chromadb.config import Settings as ChromaSettings
vector = data_dir / "hy-memory" / "governed-v2" / "vector"
collection = chromadb.PersistentClient(
path=str(vector),
settings=ChromaSettings(anonymized_telemetry=False),
).get_or_create_collection("memory")
collection.add(
ids=["sdk-1"],
documents=["remember"],
embeddings=[[0.1, 0.2]],
metadatas=[
{
"source": "default",
"gmt_created": 1,
"memory_at": 8_962_051_200,
}
],
)
coding = data_dir / "hy-memory" / "governed-v2" / "coding_cv.sqlite3"
with sqlite3.connect(coding) as database:
database.execute(
"CREATE TABLE coding_memory_meta ("
"experience_id TEXT,user_id TEXT,agent_id TEXT,solution TEXT,"
"source TEXT,created_at TEXT,updated_at TEXT)"
)
database.execute(
"CREATE TABLE working_fact ("
"fact_id TEXT,user_id TEXT,agent_id TEXT,statement TEXT,"
"source TEXT,created_at TEXT)"
)
database.execute(
"INSERT INTO coding_memory_meta VALUES "
"('sdk-1','local','scope','remember','default','now','now')"
)
result = repair_sdk_metadata_from_bundle(
data_dir=data_dir,
database_path=store.database_path,
now=datetime(2026, 10, 25, tzinfo=UTC),
)
metadata = collection.get(ids=["sdk-1"], include=["metadatas"])["metadatas"][0]
assert result.vector_rows == 1
assert result.coding_rows == 1
assert metadata["source"] == "workbuddy"
assert datetime.fromtimestamp(metadata["memory_at"], tz=UTC) == datetime(
2026, 10, 17, tzinfo=UTC
)
assert datetime.fromtimestamp(metadata["gmt_created"], tz=UTC) == datetime(
2026, 10, 18, tzinfo=UTC
)
with sqlite3.connect(coding) as database:
source, created_at = database.execute(
"SELECT source,created_at FROM coding_memory_meta WHERE experience_id='sdk-1'"
).fetchone()
assert source == "workbuddy"
assert created_at.startswith("2026-10-18T00:00:00")
repeated = repair_sdk_metadata_from_bundle(
data_dir=data_dir,
database_path=store.database_path,
now=datetime(2026, 10, 25, tzinfo=UTC),
)
assert repeated == result
def test_metadata_repair_reaches_memories_the_cutover_backfilled_under_legacy_ids(store):
"""The backfill wrote governed memories with no source; their evidence links them."""
data_dir = store.database_path.parent
migration_id = "sdk-authority-v2-backfill"
with main_database(store.database_path).write() as database, database:
database.execute(
"UPDATE memory_authority_state SET authority='sdk',migration_id=? WHERE singleton=1",
(migration_id,),
)
# A v1 receipt from the earlier, narrower repair must not block this one.
(data_dir / f".sdk-metadata-repair-v1-{migration_id}.json").write_text("{}\n")
# The later bundle was taken after legacy tables were retired; the earlier
# cutover's bundle still carries them.
for bundle, populated in (("sdk-authority-v1-old", True), (migration_id, False)):
legacy = data_dir / "backups" / f"{bundle}-pre-cutover.sdk-bundle" / "management"
legacy.mkdir(parents=True)
with closing(sqlite3.connect(legacy / "one-memory.sqlite3")) as database, database:
database.executescript(
"""
CREATE TABLE evidence (
evidence_id TEXT, source_id TEXT, created_at TEXT, imported_at TEXT
);
"""
)
if not populated:
continue
database.executescript(
"""
CREATE TABLE governed_memories (
memory_id TEXT, created_at TEXT, updated_at TEXT
);
CREATE TABLE governed_memory_evidence (
memory_id TEXT, evidence_id TEXT, active INTEGER
);
"""
)
database.execute(
"INSERT INTO governed_memories VALUES ('legacy-7','2026-09-02T00:00:00+00:00',NULL)"
)
database.execute("INSERT INTO governed_memory_evidence VALUES ('legacy-7','e7',1)")
database.execute(
"INSERT INTO evidence VALUES ('e7','workbuddy','2026-09-01T00:00:00+00:00',NULL)"
)
import chromadb
from chromadb.config import Settings as ChromaSettings
vector = data_dir / "hy-memory" / "governed-v2" / "vector"
client = chromadb.PersistentClient(
path=str(vector), settings=ChromaSettings(anonymized_telemetry=False)
)
collection = client.get_or_create_collection("memory")
imported = int(datetime(2026, 10, 20, tzinfo=UTC).timestamp())
collection.add(
ids=["legacy-7", "unlinked"],
documents=["backfilled", "hash-dated"],
embeddings=[[0.1, 0.2], [0.2, 0.1]],
metadatas=[
{"source": "default", "gmt_created": imported, "memory_at": imported},
{"source": "cursor", "gmt_created": imported, "memory_at": 8_962_051_200},
],
)
client.close()
result = repair_sdk_metadata_from_bundle(
data_dir=data_dir,
database_path=store.database_path,
now=datetime(2026, 10, 25, tzinfo=UTC),
)
client = chromadb.PersistentClient(
path=str(vector), settings=ChromaSettings(anonymized_telemetry=False)
)
rows = client.get_collection("memory").get(ids=["legacy-7", "unlinked"], include=["metadatas"])
client.close()
metadata = dict(zip(rows["ids"], rows["metadatas"], strict=True))
assert result.vector_rows == 2
assert metadata["legacy-7"]["source"] == "workbuddy"
assert metadata["legacy-7"]["memory_at"] == int(datetime(2026, 9, 1, tzinfo=UTC).timestamp())
assert metadata["legacy-7"]["gmt_created"] == int(datetime(2026, 9, 2, tzinfo=UTC).timestamp())
assert metadata["unlinked"]["source"] == "cursor"
assert metadata["unlinked"]["memory_at"] == imported
def test_metadata_repair_keeps_source_unknown_when_outputs_disagree(tmp_path):
"""Two legacy memories folded into one SDK memory pool their evidence."""
from one_memory.hy_sdk_migration import _legacy_metadata
legacy = tmp_path / "one-memory.sqlite3"
with closing(sqlite3.connect(legacy)) as database, database:
database.executescript(
"""
CREATE TABLE memory_task_outputs (body TEXT);
CREATE TABLE governed_memories (
memory_id TEXT, created_at TEXT, updated_at TEXT
);
CREATE TABLE evidence (
evidence_id TEXT, source_id TEXT, created_at TEXT, imported_at TEXT
);
"""
)
database.executemany(
"INSERT INTO governed_memories VALUES (?,?,?)",
[
("legacy-1", "2026-10-18T00:00:00+00:00", None),
("legacy-2", "2026-10-12T00:00:00+00:00", None),
],
)
database.executemany(
"INSERT INTO evidence VALUES (?,?,?,NULL)",
[
("e1", "workbuddy", "2026-10-17T00:00:00+00:00"),
("e2", "cursor", "2026-10-11T00:00:00+00:00"),
],
)
database.executemany(
"INSERT INTO memory_task_outputs VALUES (?)",
[
(
json.dumps(
{
"memory_id": legacy_id,
"hy_memory_id": "sdk-1",
"support_refs": [{"evidence_id": evidence_id}],
}
),
)
for legacy_id, evidence_id in (("legacy-1", "e1"), ("legacy-2", "e2"))
],
)
repaired = _legacy_metadata(legacy, now=datetime(2026, 10, 25, tzinfo=UTC))["sdk-1"]
assert repaired.source is None
assert repaired.memory_at == int(datetime(2026, 10, 17, tzinfo=UTC).timestamp())
assert repaired.created_at == int(datetime(2026, 10, 12, tzinfo=UTC).timestamp())
def _insert_evidence(store, evidence_id, *, conversation="session-1", role="user", seq=1):
verbatim = f"text of {evidence_id}"
with main_database(store.database_path).write() as database, database:
@@ -1123,151 +866,3 @@ 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")
] + [
{
"memory_id": memory_id,
"content": f"backfilled {memory_id}",
"layer": "l2_fact",
"status": "active",
"user_id": "local",
"agent_id": "one-memory",
"session_id": "default_session",
"source": "default",
}
for memory_id in ("c", "d")
]
listed = []
class Client:
def list_memories(self, **kwargs):
listed.append(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",
params={"source": "cursor", "offset": 100, "limit": 100},
)
app.state.sdk_client_handle = original
assert response.status_code == 200
return response.json()
graph = asyncio.run(scenario())
assert listed[0]["include_sources"] == ["cursor"]
assert (listed[0]["offset"], listed[0]["limit"]) == (100, 100)
assert (graph["total_memories"], graph["projected_memories"]) == (7, 4)
assert {node["node_id"] for node in graph["nodes"]} == {
"memory:a",
"memory:b",
"memory:c",
"memory:d",
"source:cursor",
"hy:t1",
}
# "default_session" is the SDK's filler, not a conversation.
assert [
(edge["source_node_id"], edge["target_node_id"])
for edge in graph["edges"]
if edge["relation"] == "same_conversation"
] == [("memory:a", "memory:b")]
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"
+25 -52
View File
@@ -112,11 +112,9 @@ import {
installBundledCliIfNeeded,
installServiceWithDisabledFallback,
isInstalledApplicationsApp,
requireCurrentRuntimeAtLeast,
stableRuntimeErrorCode,
stableRuntimeFailureDetail,
startStableRuntimeRepair,
startupFailureCause,
} from "./stable-runtime";
import { STARTUP_SCREEN_URL, startupScreenHtml } from "./startup-screen";
import {
@@ -1095,7 +1093,7 @@ async function initialize(isQuitting: () => boolean): Promise<void> {
component: "desktop.sdk_migration",
event: "sdk_migration.startup",
outcome:
migration.state === "cutover" || migration.state === "complete"
migration.state === "cutover"
? "succeeded"
: migration.state === "deferred"
? "blocked"
@@ -1160,52 +1158,30 @@ async function initialize(isQuitting: () => boolean): Promise<void> {
const previousOwner = owner;
let runtimeChanged = false;
let transientRuntime = false;
// 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 (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",
});
}
if (
!desktopBackgroundLaunch &&
@@ -1509,9 +1485,6 @@ 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,12 +15,10 @@ import {
installServiceWithDisabledFallback,
isDisabledServiceInstallError,
isInstalledApplicationsApp,
requireCurrentRuntimeAtLeast,
stableRuntimeErrorCode,
stableRuntimeFailureDetail,
stableRuntimeUserMessage,
startStableRuntimeRepair,
startupFailureCause,
} from "./stable-runtime";
it("only treats a packaged macOS executable under Applications as installed", () => {
@@ -313,53 +311,4 @@ describe("startup dialog body", () => {
expect(detail).toContain("磁盘空间不足");
expect(detail).not.toContain("sdk_migration_disk_space_insufficient");
});
it("turns runtime_service_not_ready into a user-facing sentence", () => {
const error = new Error("runtime_service_not_ready");
expect(stableRuntimeErrorCode(error)).toBe("runtime_service_not_ready");
expect(stableRuntimeFailureDetail(error)).toContain("首次启动");
expect(stableRuntimeFailureDetail(error)).not.toContain(
"runtime_service_not_ready",
);
});
});
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,
);
});
});
-34
View File
@@ -129,7 +129,6 @@ 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",
@@ -141,7 +140,6 @@ const SAFE_STABLE_RUNTIME_ERROR_CODES = new Set([
"sdk_migration_response_invalid",
"sdk_migration_status_unavailable",
"sdk_migration_incomplete",
"runtime_service_not_ready",
]);
export function stableRuntimeErrorCode(error: unknown): string {
@@ -207,8 +205,6 @@ 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:
@@ -231,8 +227,6 @@ export const STABLE_RUNTIME_USER_MESSAGES: Record<string, string> = {
"升级过程返回了无法识别的内容,已停止。原有记忆数据不会丢失;请重新启动重试,如果仍然失败,请把这个提示反馈给我们。",
sdk_migration_incomplete:
"记忆数据格式升级没有完成,MemHub 暂时无法打开。请重新启动重试;如果仍然失败,请把这个提示反馈给我们,原有记忆数据不会丢失。",
runtime_service_not_ready:
"记忆服务仍在首次启动,请稍候后重试。你的记忆数据不会受到影响。",
desktop_start_failed:
"MemHub 启动失败。请重新启动;如果仍然打不开,请重新安装,你的记忆数据不会受到影响。",
};
@@ -258,31 +252,3 @@ 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);
}
+10 -108
View File
@@ -177,13 +177,12 @@ 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.getByRole("heading", { name: "第一条" })).toBeTruthy();
expect(screen.getByText("共同原文")).toBeTruthy();
expect(screen.queryByText(/暂不能保存更改/)).toBeNull();
});
it("OBS-19: Enter in the memory filter submits it; submitting it again re-reads", async () => {
it("OBS-19: Enter in the memory filter re-reads, exactly like the refresh button", async () => {
const onRetry = vi.fn();
const onQuery = vi.fn();
const page = {
items: memories,
total: memories.length,
@@ -201,7 +200,7 @@ it("OBS-19: Enter in the memory filter submits it; submitting it again re-reads"
loading: false,
failed: false,
stale: false,
onQuery,
onQuery: vi.fn(),
onSource: vi.fn(),
resetPage: vi.fn(),
onPage: vi.fn(),
@@ -212,39 +211,11 @@ it("OBS-19: Enter in the memory filter submits it; submitting it again re-reads"
await act(async () => {});
await userEvent.click(screen.getByRole("button", { name: "刷新地图记忆" }));
expect(onRetry).toHaveBeenCalledTimes(1);
// Typing alone does not search; Enter submits the words.
const input = screen.getByRole("textbox", { name: "筛选地图记忆" });
await userEvent.type(input, "第一");
expect(onQuery).not.toHaveBeenCalled();
await userEvent.type(input, "{Enter}");
expect(onQuery).toHaveBeenCalledWith("第一");
// Submitting the words already applied re-reads, like the refresh button.
await userEvent.clear(input);
await userEvent.type(input, "{Enter}");
expect(onRetry).toHaveBeenCalledTimes(2);
});
it("searches only when 搜索 is pressed, not on every keystroke", async () => {
const onQuery = vi.fn();
const resetPage = vi.fn();
render(
<Host
simplified
libraryView={{
...pagedView({ items: memories, total: 2, offset: 0, limit: 100 }),
onQuery,
resetPage,
}}
/>,
);
await userEvent.type(
screen.getByRole("textbox", { name: "筛选地图记忆" }),
"版本号",
"第一{Enter}",
);
expect(onQuery).not.toHaveBeenCalled();
await userEvent.click(screen.getByRole("button", { name: "搜索" }));
expect(onQuery).toHaveBeenCalledWith("版本号");
expect(resetPage).toHaveBeenCalled();
expect(onRetry).toHaveBeenCalledTimes(2);
});
it("OBS-15: switching memory shows a read in progress instead of the previous memory", async () => {
@@ -253,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.getByRole("heading", { name: "第一条" })).toBeTruthy();
expect(screen.getByText("共同原文")).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.queryByRole("heading", { name: "第一条" })).toBeNull();
expect(screen.queryByText("共同原文")).toBeNull();
expect(screen.getByRole("status").textContent).toContain("正在读取这条记忆");
});
@@ -276,26 +247,16 @@ it("labels the right-hand empty state as memory details", () => {
expect(screen.queryByText("原始证据")).toBeNull();
});
it("renders SDK-projected memory tags with the existing Workbench chips", () => {
const { container } = render(<Host simplified />);
expect(
Array.from(
container.querySelectorAll(".map-memory-tags .wb-tag"),
(node) => node.textContent,
),
).toEqual(["产品", "产品"]);
});
it("opens the newest listed memory in the detail pane and keeps it closed after dismiss", async () => {
const user = userEvent.setup();
render(<Host simplified />);
const list = screen.getByRole("list", { name: "地图记忆列表" });
const first = within(list).getByRole("button", { name: /第一条/ });
expect(first.getAttribute("aria-pressed")).toBe("true");
expect(screen.getByRole("heading", { name: "第一条" })).toBeTruthy();
expect(screen.queryByText(/原对话/)).toBeNull();
expect(screen.getByText("共同原文")).toBeTruthy();
expect(screen.getByText(/原对话/)).toBeTruthy();
await user.click(screen.getByRole("button", { name: "关闭记忆详情" }));
expect(screen.queryByRole("heading", { name: "第一条" })).toBeNull();
expect(screen.queryByText("共同原文")).toBeNull();
});
it("OBS-14: lists what the conversation settled instead of asking to settle first", async () => {
@@ -540,65 +501,6 @@ it("D96: lists a memory by when its conversation happened, not when it was gener
).toBeTruthy();
});
it("names the SDK's no-source bucket 未识别来源, apart from a source under review", () => {
const items = ["有来源", "无来源"].map((content, i) => ({
...memories[0],
memory_id: `s${i}`,
content,
})) as GovernedMemory[];
render(
<Host
simplified
items={items}
sources={[
{
source_id: "codex",
display_name: "Codex",
authorized: true,
imported_messages: 1,
imported_conversations: 1,
} as SourceResponse,
]}
libraryView={{
...pagedView({
items,
total: 2,
offset: 0,
limit: 100,
source_ids_by_memory: { s0: ["codex"], s1: ["default"] },
search_window: 500,
}),
query: "版本",
unattributedSource: "default",
}}
/>,
);
const list = screen.getByRole("list", { name: "地图记忆列表" });
expect(
within(list).getByRole("button", { name: /有来源/ }).textContent,
).toContain("Codex");
const unattributed = within(list).getByRole("button", { name: /无来源/ });
expect(unattributed.textContent).toContain("未识别来源");
expect(unattributed.textContent).not.toContain("待核对");
expect(screen.getByText("只在最相关的 500 条记忆里查找关键词")).toBeTruthy();
});
it("says a filtered source is empty rather than that nothing matched", () => {
render(
<Host
simplified
items={[]}
libraryView={{
...pagedView({ items: [], total: 0, offset: 0, limit: 100 }),
source: "default",
unattributedSource: "default",
}}
/>,
);
expect(screen.getByText("这个来源下还没有记忆")).toBeTruthy();
expect(screen.queryByText("没有匹配的记忆")).toBeNull();
});
it("D96: with conversations imported and no memory yet, says how many were imported", () => {
render(
<Host
+27 -117
View File
@@ -63,8 +63,6 @@ type Props = Parameters<typeof Workbench>[0] & {
loading: boolean;
failed: boolean;
stale: boolean;
/** The source id the SDK uses for "no source given" (shown as 未识别来源). */
unattributedSource?: string;
onQuery(value: string): void;
onSource(value: string): void;
resetPage(): void;
@@ -170,40 +168,18 @@ export function MemoryMap(props: Props) {
} = props;
const library = props.libraryView;
const serverPaged = library !== undefined;
const unattributedLabel = text("未识别来源");
const graph = useMemo(
() =>
serverPaged
? projectPagedSourceMap(
baseGraph,
library?.page ?? null,
sources,
library?.unattributedSource
? { [library.unattributedSource]: unattributedLabel }
: undefined,
)
? projectPagedSourceMap(baseGraph, library?.page ?? null, sources)
: baseGraph,
[
baseGraph,
serverPaged,
library?.page,
library?.unattributedSource,
sources,
unattributedLabel,
],
[baseGraph, serverPaged, library?.page, sources],
);
const refreshing = !!library?.page && library.loading;
const overlaysSuspended = hidden || !!props.overlayBlocked;
const [axis, setAxis] = useState<MapAxis>("source");
const [groupKey, setGroupKey] = useState("");
const [search, setSearch] = useState("");
// On a server-paged list, typing only edits the words; the search runs when
// the reader submits it (the 搜索 button or Enter), not on every keystroke.
const [searchDraft, setSearchDraft] = useState(library?.query ?? "");
const submittedQuery = library?.query;
useEffect(() => {
if (submittedQuery !== undefined) setSearchDraft(submittedQuery);
}, [submittedQuery]);
const [selectedId, setSelectedId] = useState<string | null>(null);
const [groupNode, setGroupNode] = useState<MemoryGraphNode | null>(null);
const [question, setQuestion] = useState("");
@@ -362,11 +338,7 @@ export function MemoryMap(props: Props) {
const ids = library.page?.source_ids_by_memory?.[memoryId];
return ids?.length
? ids
.map((id) =>
id === library.unattributedSource
? text("未识别来源")
: sourceNameOr(sources, id, text("来源待核对")),
)
.map((id) => sourceNameOr(sources, id, text("来源待核对")))
.join("、")
: text("来源待核对");
}
@@ -584,8 +556,6 @@ export function MemoryMap(props: Props) {
setGroupNode(null);
if (library) {
clear();
// A source picked on the map sets this too; keep both on the one choice.
setGroupKey(key);
library.onSource(key.startsWith("source:") ? key.slice(7) : "");
library.resetPage();
return;
@@ -641,10 +611,6 @@ export function MemoryMap(props: Props) {
(source) =>
source.authorized && groupKey === `source:${source.source_id}`,
)?.display_name ??
(library?.unattributedSource &&
groupKey === `source:${library.unattributedSource}`
? text("未识别来源")
: null) ??
text("全部记忆");
if (
props.simplified &&
@@ -774,7 +740,6 @@ export function MemoryMap(props: Props) {
options={[
{ value: "", label: text("全部应用") },
...(library?.source &&
library.source !== library.unattributedSource &&
!sources.some(
(source) =>
source.source_id === library.source && source.authorized,
@@ -787,22 +752,12 @@ export function MemoryMap(props: Props) {
]
: []),
...(library
? [
...sources
.filter((source) => source.authorized)
.map((source) => ({
value: `source:${source.source_id}`,
label: source.display_name,
})),
...(library.unattributedSource
? [
{
value: `source:${library.unattributedSource}`,
label: text("未识别来源"),
},
]
: []),
]
? sources
.filter((source) => source.authorized)
.map((source) => ({
value: `source:${source.source_id}`,
label: source.display_name,
}))
: groups.map((group) => ({
value: group.key,
label: group.label,
@@ -834,23 +789,7 @@ export function MemoryMap(props: Props) {
))}
</fieldset>
)}
<form
className="map-search"
role="search"
onSubmit={(e) => {
e.preventDefault();
if (!library || libraryPane === "dialogues") return;
const words = searchDraft.trim();
setFilterNotice("");
clear();
if (words === library.query.trim()) {
library.onRetry();
return;
}
library.onQuery(words);
library.resetPage();
}}
>
<div className="map-search">
<Search size={16} aria-hidden="true" />
<input
aria-label={text("筛选地图记忆")}
@@ -866,39 +805,31 @@ export function MemoryMap(props: Props) {
value={
libraryPane === "dialogues"
? dialogueFilter
: library
? searchDraft
: search
: (library?.query ?? search)
}
maxLength={library ? 512 : undefined}
onKeyDown={(e) => {
// Enter that confirms an IME candidate is not a search.
if (e.key === "Enter" && e.nativeEvent.isComposing)
e.preventDefault();
// The refresh button next to this list re-reads the page;
// Enter has to do the same thing, not sit there doing nothing.
if (e.key !== "Enter" || e.nativeEvent.isComposing) return;
e.preventDefault();
library?.onRetry();
}}
onChange={(e) => {
if (libraryPane === "dialogues") {
setDialogueFilter(e.target.value);
return;
}
if (library) {
setSearchDraft(e.target.value);
return;
}
setFilterNotice("");
setSearch(e.target.value);
if (library) {
clear();
library.onQuery(e.target.value);
library.resetPage();
}
}}
/>
{library && libraryPane === "memories" ? (
<button
type="submit"
className="map-search-submit"
disabled={library.loading || hidden}
>
{text("搜索")}
</button>
) : null}
</form>
</div>
{library && libraryPane === "memories" && (
<div className="map-list-heading" aria-live="polite">
<span>
@@ -1057,16 +988,7 @@ export function MemoryMap(props: Props) {
onClick={() => pick(m.memory_id)}
>
<span className={`map-state ${m.status}`} aria-hidden="true" />
<span className="map-memory-content">{m.content}</span>
{m.tags.length ? (
<span className="map-memory-tags wb-tags">
{m.tags.map((tag) => (
<span key={tag} className="wb-tag">
{tag}
</span>
))}
</span>
) : null}
<span>{m.content}</span>
<small>
{props.simplified ? (
<>
@@ -1107,23 +1029,11 @@ export function MemoryMap(props: Props) {
!library.stale &&
!filtered.length && (
<p className="map-empty">
{library.query
{library.query || library.source
? text("没有匹配的记忆")
: library.source
? text("这个来源下还没有记忆")
: groupKey
? text("本页没有匹配的主题记忆")
: text("还没有已生成的记忆")}
</p>
)}
{libraryPane === "memories" &&
library?.page?.search_window &&
!library.loading &&
!library.failed && (
<p className="map-empty">
{text("只在最相关的 {count} 条记忆里查找关键词", {
count: count(library.page.search_window),
})}
: groupKey
? text("本页没有匹配的主题记忆")
: text("还没有已生成的记忆")}
</p>
)}
{libraryPane === "memories" &&
@@ -218,7 +218,7 @@ it("default map search finds an existing 501st memory rather than declaring no m
fireEvent.change(screen.getByLabelText("筛选地图记忆"), {
target: { value: "第501条独有目标" },
});
fireEvent.submit(screen.getByRole("search"));
fireEvent.keyDown(screen.getByLabelText("筛选地图记忆"), { key: "Enter" });
await waitFor(() =>
expect(
within(screen.getByRole("list", { name: "地图记忆列表" })).queryByText(
@@ -310,7 +310,6 @@ it("OBS-13: the settled-count headline names the filter it is counting", async (
fireEvent.change(screen.getByLabelText("筛选地图记忆"), {
target: { value: "第501条独有目标" },
});
fireEvent.submit(screen.getByRole("search"));
await waitFor(() =>
expect(
screen.getByText(
@@ -321,7 +320,6 @@ it("OBS-13: the settled-count headline names the filter it is counting", async (
fireEvent.change(screen.getByLabelText("筛选地图记忆"), {
target: { value: "" },
});
fireEvent.submit(screen.getByRole("search"));
await chooseSource("codex");
await waitFor(() =>
expect(
@@ -475,7 +473,6 @@ it("distinguishes failed search from empty, then retries the same query", async
fireEvent.change(screen.getByLabelText("筛选地图记忆"), {
target: { value: "目标" },
});
fireEvent.submit(screen.getByRole("search"));
await screen.findByText("记忆读取失败,请重试。");
expect(screen.queryByText("没有匹配的记忆")).toBeNull();
expect(screen.queryByText("还没有已生成的记忆")).toBeNull();
@@ -493,7 +490,6 @@ it("distinguishes failed search from empty, then retries the same query", async
fireEvent.change(screen.getByLabelText("筛选地图记忆"), {
target: { value: "确实没有这项" },
});
fireEvent.submit(screen.getByRole("search"));
await screen.findByText("没有匹配的记忆");
expect(screen.queryByText("还没有已生成的记忆")).toBeNull();
expect(
@@ -514,7 +510,6 @@ it("aborts an older query after a source change and never replaces the newer res
fireEvent.change(screen.getByLabelText("筛选地图记忆"), {
target: { value: "旧搜索" },
});
fireEvent.submit(screen.getByRole("search"));
await waitFor(() => expect(resolve).toHaveLength(1));
const signals = vi
.mocked(api.getMemoryLibrary)
@@ -526,7 +521,6 @@ it("aborts an older query after a source change and never replaces the newer res
fireEvent.change(screen.getByLabelText("筛选地图记忆"), {
target: { value: "目标" },
});
fireEvent.submit(screen.getByRole("search"));
await within(screen.getByRole("list", { name: "地图记忆列表" })).findByText(
"第501条独有目标",
);
@@ -559,7 +553,6 @@ it("hidden map aborts page reads and does not accept late body or poll until rea
fireEvent.change(screen.getByLabelText("筛选地图记忆"), {
target: { value: "迟到" },
});
fireEvent.submit(screen.getByRole("search"));
await act(async () => {});
const signals = vi
.mocked(api.getMemoryLibrary)
@@ -618,7 +611,6 @@ it("keeps the populated map while a search is in flight and does not treat it as
fireEvent.change(screen.getByLabelText("筛选地图记忆"), {
target: { value: "onememory" },
});
fireEvent.submit(screen.getByRole("search"));
await act(async () => {});
expect(screen.queryByText("正在读取…")).toBeNull();
expect(screen.getByText("已沉淀 501 条 · 当前展示 100 条")).toBeTruthy();
@@ -708,7 +700,6 @@ it("explicit refresh keeps the search scope and replaces the page with the lates
fireEvent.change(screen.getByLabelText("筛选地图记忆"), {
target: { value: "目标" },
});
fireEvent.submit(screen.getByRole("search"));
const list = screen.getByRole("list", { name: "地图记忆列表" });
await within(list).findByText("第501条独有目标");
vi.mocked(api.getMemoryLibrary).mockResolvedValueOnce({
@@ -1,333 +0,0 @@
// @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,
libraryView,
}: {
hidden: boolean;
memories: GovernedMemory[];
graph: MemoryGraphResponse | null;
loaded: boolean;
onOpenMemory(id: string): void;
libraryView?: {
page: { source_ids_by_memory?: Record<string, string[]> } | null;
unattributedSource?: string;
onSource(value: string): void;
resetPage(): 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>
<span data-testid="map-sources">
{memories
.map((m) =>
(libraryView?.page?.source_ids_by_memory?.[m.memory_id] ?? []).join(
"+",
),
)
.join(",")}
</span>
<span data-testid="map-unattributed">
{libraryView?.unattributedSource ?? ""}
</span>
<button type="button" onClick={() => onOpenMemory("a")}>
open a
</button>
<button
type="button"
onClick={() => {
libraryView?.onSource("workbuddy");
libraryView?.resetPage();
}}
>
pick workbuddy
</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"],
source: undefined,
query: undefined,
offset: 0,
limit: 100,
},
expect.any(AbortSignal),
);
expect(api.sdkGraph).toHaveBeenCalledWith(
{ source: undefined, offset: 0, limit: 100 },
expect.any(AbortSignal),
);
expect(api.legacyLibrary).not.toHaveBeenCalled();
expect(api.legacyGraph).not.toHaveBeenCalled();
});
it("keeps rows, sources, count and graph on the one filtered page", async () => {
sdkAuthority();
render(<MemorySpace simplified active />);
await waitFor(() =>
expect(screen.getByTestId("map-loaded").textContent).toBe("true"),
);
api.sdkLibrary.mockResolvedValue({
memories: [
{ ...record("w1"), source: "workbuddy" },
{ ...record("w2"), source: "default" },
],
total: 566,
limit: 100,
offset: 0,
});
fireEvent.click(screen.getByText("pick workbuddy"));
await waitFor(() =>
expect(screen.getByTestId("map-memories").textContent).toBe(
"w1:confirmed,w2:confirmed",
),
);
expect(screen.getByTestId("map-sources").textContent).toBe(
"workbuddy,default",
);
expect(screen.getByTestId("map-unattributed").textContent).toBe("default");
expect(api.sdkLibrary).toHaveBeenLastCalledWith(
expect.objectContaining({ source: "workbuddy", offset: 0 }),
expect.any(AbortSignal),
);
expect(api.sdkGraph).toHaveBeenLastCalledWith(
{ source: "workbuddy", offset: 0, limit: 100 },
expect.any(AbortSignal),
);
expect(await screen.findByText(/566/)).toBeTruthy();
});
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);
});
+59 -303
View File
@@ -5,7 +5,6 @@ import type {
ImportJob,
MemoryDetail,
MemoryGraphResponse,
MemoryLibraryPage,
MemoryProcessingSummary,
SdkAskMemory,
SourceResponse,
@@ -33,7 +32,6 @@ import {
apiErrorCode,
askMemoriesStream,
correctMemory,
deleteSdkMemory,
digestMemoryGraph,
getMemories,
getMemory,
@@ -41,16 +39,12 @@ 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";
@@ -62,8 +56,7 @@ import { subscribePoll } from "./poll-scheduler";
import { R6Button } from "./R6Controls";
import { SdkMemoryLibrary } from "./SdkMemoryLibrary";
import { SdkRawHistory } from "./SdkRawHistory";
import { sdkDetail, sdkLibraryPage } from "./sdk-memory-map";
import { sourceNameOr, UNATTRIBUTED_SOURCE_ID } from "./source-label";
import { sourceNameOr } from "./source-label";
import { traceUiAction } from "./ui-trace";
import { useMemoryLibrary } from "./use-memory-library";
import { isDefiniteRefusal, writeTraceStatus } from "./write-outcome";
@@ -175,23 +168,13 @@ export function MemorySpace({
if ((!active && !preload) || typeof getMemoryAuthority !== "function")
return;
let current = true;
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);
void getMemoryAuthority()
.then((result) => {
if (current) setMemoryAuthority(result.authority);
})
.catch(() => {});
return () => {
current = false;
if (timer !== undefined) clearTimeout(timer);
};
}, [active, preload]);
useEffect(() => {
@@ -300,125 +283,9 @@ export function MemorySpace({
text: string;
memories: AskMemory[];
} | 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. Like the governed map, the list, its count, its source and
// time labels and the graph are all one page: the library page and the graph
// for that same source and window, read together. Reading the graph from a
// different, unfiltered window made rows, sources and totals disagree.
const [sdkGraph, setSdkGraph] = useState<MemoryGraphResponse | null>(null);
// 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), []);
const [sdkLibrary, setSdkLibrary] = useState<{
page: MemoryLibraryPage | null;
loading: boolean;
failed: boolean;
/** The filter the shown page was read with, for the count above it. */
settledSource: string;
settledQuery: string;
}>({
page: null,
loading: false,
failed: false,
settledSource: "",
settledQuery: "",
});
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 source = sdkLibrarySource || undefined;
const query = sdkLibraryQuery || undefined;
const read = async (first: boolean) => {
controller?.abort();
const request = new AbortController();
controller = request;
if (first) setSdkLibrary((current) => ({ ...current, loading: true }));
try {
const [page, graph] = await Promise.all([
getSdkMemoryLibrary(
{
statuses: ["active"],
source,
query,
offset: sdkLibraryOffset,
limit: 100,
},
request.signal,
),
// The overlay is decoration: the list stands without it.
getSdkMemoryGraph(
{ source, offset: query ? 0 : sdkLibraryOffset, limit: 100 },
request.signal,
).catch(() => null),
]);
if (cancelled) return;
setSdkLibrary({
page: sdkLibraryPage(page),
loading: false,
failed: false,
settledSource: sdkLibrarySource,
settledQuery: sdkLibraryQuery,
});
setSdkGraph(graph);
} catch {
if (cancelled || request.signal.aborted) return;
setSdkLibrary((current) => ({
...current,
loading: false,
failed: true,
}));
}
};
void read(true);
const timer = setInterval(() => void read(false), 30_000);
return () => {
cancelled = true;
clearInterval(timer);
controller?.abort();
};
}, [
sdkMapVisible,
sdkMapRevision,
sdkLibraryOffset,
sdkLibraryQuery,
sdkLibrarySource,
]);
const sdkFilterLabel = [
sdkLibrary.settledSource
? `${t("memory:来源")} ${
sdkLibrary.settledSource === UNATTRIBUTED_SOURCE_ID
? t("memory:未识别来源")
: sourceNameOr(
sources,
sdkLibrary.settledSource,
sdkLibrary.settledSource,
)
}`
: "",
sdkLibrary.settledQuery.trim()
? t("memory:关键词“{query}”", {
query: sdkLibrary.settledQuery.trim(),
})
: "",
]
.filter(Boolean)
.join(" · ");
const [sdkFocusedMemoryId, setSdkFocusedMemoryId] = useState<string | null>(
null,
);
const [draft, setDraft] = useState("");
const [selection, setSelection] = useState<Set<string>>(new Set());
const bulkInFlight = useRef(false);
@@ -1102,9 +969,8 @@ export function MemorySpace({
open(memory.memory_id);
return;
}
// 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);
setSdkFocusedMemoryId(memory.memory_id);
setMode("library");
}
async function saveAnswer(draft: { content: string }) {
@@ -1153,13 +1019,7 @@ export function MemorySpace({
setDetailFreshness(null);
setDetailLoad({ memoryId, status: "loading" });
try {
// 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);
const result = await getMemory(memoryId);
if (epoch !== detailEpoch.current || result.memory.memory_id !== memoryId)
return;
setDetail(result);
@@ -1207,28 +1067,6 @@ 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");
@@ -1435,28 +1273,6 @@ 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");
@@ -1628,62 +1444,39 @@ export function MemorySpace({
{simplified ? t("memory:记忆") : t("memory:记忆空间")}
</h1>
<span className="memory-loaded-count">
{memoryAuthority === "sdk" && simplified && !management
? sdkLibrary.page
? tn(
"memory:已沉淀 {count} 条{filter} · 当前展示 {shown} 条",
sdkLibrary.page.total,
{
filter: sdkFilterLabel
? t("memory:({filter})", { filter: sdkFilterLabel })
: "",
shown: new Intl.NumberFormat(locale).format(
sdkLibrary.page.items.length,
),
},
)
: // The list below says a failed read once, with its retry.
sdkLibrary.failed
? null
: 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
{simplified && !management
? mapLibrary.failed
? t("memory:记忆读取失败")
: mapLibrary.stale
? t("memory:正在导入历史会话,记忆将陆续出现")
: mapLibrary.page
? tn(
"memory:已加载 {count} 条记忆",
memories.filter((m) => m.status !== "deleted").length,
"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:正在读取…")}
: t("memory:正在读取…")
: loaded
? tn(
"memory:已加载 {count} 条记忆",
memories.filter((m) => m.status !== "deleted").length,
)
: t("memory:正在读取…")}
</span>
</div>
<MemoryNoteComposer
suspended={!active}
onPendingChange={setPendingNote}
onSaved={() => {
// A finished note is a new SDK memory: re-read the page now
// rather than leave it for the next poll.
if (memoryAuthority === "sdk") bumpSdkMap();
void refresh(query).catch((reason) => setError(message(reason, t)));
}}
onOpenMemory={(id) => {
@@ -1861,11 +1654,9 @@ export function MemorySpace({
<ModelPrompt
processing={processing}
hasMemories={
memoryAuthority === "sdk"
? (sdkLibrary.page?.total ?? 0) > 0
: simplified
? knownMapTotal.current > 0 || (mapLibrary.page?.total ?? 0) > 0
: memories.length > 0
simplified
? knownMapTotal.current > 0 || (mapLibrary.page?.total ?? 0) > 0
: memories.length > 0
}
onConfigureModel={onConfigureModel}
/>
@@ -2023,38 +1814,23 @@ export function MemorySpace({
focusDialogue={focusDialogue}
onFocusDialogueConsumed={onFocusDialogueConsumed}
libraryView={
simplified && memoryAuthority === "sdk"
simplified
? {
query: sdkLibraryQuery,
source: sdkLibrarySource,
page: sdkLibrary.page,
loading: sdkLibrary.loading,
failed: sdkLibrary.failed,
stale: false,
unattributedSource: UNATTRIBUTED_SOURCE_ID,
onQuery: setSdkLibraryQuery,
onSource: setSdkLibrarySource,
resetPage: () => setSdkLibraryOffset(0),
onPage: (offset) => setSdkLibraryOffset(offset),
onRetry: bumpSdkMap,
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(() => {});
},
}
: 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
: undefined
}
onMutationComplete={(id) => {
void refresh(query)
@@ -2062,39 +1838,19 @@ export function MemorySpace({
.catch((reason) => setError(message(reason, t)));
}}
hidden={!active || displayedMode !== "map"}
memories={
memoryAuthority === "sdk"
? (sdkLibrary.page?.items ?? [])
: simplified
? (mapLibrary.page?.items ?? [])
: memories
}
graph={
memoryAuthority === "sdk"
? sdkGraph
: simplified && sourceSnapshotStale
? null
: graph
}
memories={simplified ? (mapLibrary.page?.items ?? []) : memories}
graph={simplified && sourceSnapshotStale ? null : graph}
sources={sources}
processing={processing}
arrival={memoryArrival}
onViewHistory={simplified ? onConnect : () => setMode("history")}
loaded={
memoryAuthority === "sdk"
? sdkLibrary.page !== null
: simplified
? mapLibrary.page !== null
: loaded
}
loaded={simplified ? mapLibrary.page !== null : loaded}
detail={detail}
detailLoad={detailLoad}
overlayBlocked={
!active ||
deleteCount > 0 ||
(memoryAuthority === "sdk"
? sdkLibrary.page === null
: simplified && (!mapLibrary.page || sourceSnapshotStale))
(simplified && (!mapLibrary.page || sourceSnapshotStale))
}
draft={draft}
busy={busy || detailReadBlocked}
@@ -1,6 +1,6 @@
// @vitest-environment jsdom
import type { MemoryDetail, SourceResponse } from "@one-memory/contracts";
import type { MemoryDetail } 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 memory details without exposing the retired evidence surface", async () => {
it("localizes informational details and dates while preserving evidence and memory", async () => {
delete window.oneMemory;
localStorage.setItem(LANGUAGE_STORAGE_KEY, "es");
render(
@@ -29,9 +29,9 @@ it("localizes memory details without exposing the retired evidence surface", asy
);
expect(await screen.findByText("Guardada · Versión 2")).toBeTruthy();
expect(
screen.queryByRole("heading", { name: "Evidencia original" }),
).toBeNull();
expect(screen.queryByText("这次先确认范围。")).toBeNull();
screen.getByRole("heading", { name: "Evidencia original" }),
).toBeTruthy();
expect(screen.getByText("这次先确认范围。")).toBeTruthy();
expect(correct).not.toHaveBeenCalled();
expect(remove).not.toHaveBeenCalled();
localStorage.clear();
@@ -145,72 +145,6 @@ 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("splits long memory text into a bounded heading and readable body", () => {
const content = "长".repeat(100);
render(
<SelectedMemoryDetail
detail={{
...detail,
memory: { ...detail.memory, content },
evidence: [],
sdk_status: "active",
}}
sources={[]}
onChanged={vi.fn()}
hideActions
/>,
);
expect(screen.getByRole("heading", { name: "长".repeat(72) })).toBeTruthy();
expect(screen.getByText("长".repeat(28)).className).toContain(
"selected-memory-body",
);
});
it.each([
[
"a late sentence end",
`${"长".repeat(80)}。${"尾".repeat(10)}`,
"长".repeat(72),
],
["words", `${"word ".repeat(20)}end`, "word ".repeat(14).trim()],
])("keeps the heading within the limit for %s", (_case, content, heading) => {
render(
<SelectedMemoryDetail
detail={{
...detail,
memory: { ...detail.memory, content },
evidence: [],
sdk_status: "active",
}}
sources={[]}
onChanged={vi.fn()}
hideActions
/>,
);
expect(screen.getByRole("heading", { name: heading })).toBeTruthy();
});
it("opens full editor and saves content without rewriting scope", async () => {
const changed = vi.fn();
render(
@@ -270,7 +204,27 @@ it("keeps draft after conflict until latest revision is explicitly acknowledged"
),
);
});
it.each(["编辑", "删除记忆"])(
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(["编辑", "查看原文", "删除记忆"])(
"returns focus to %s without scrolling the background",
(name) => {
render(
@@ -321,6 +275,7 @@ it.each(["escape", "cancel"])(
"value",
"尚未保存的草稿",
);
expect(screen.getByText("这次先确认范围。")).toBeTruthy();
expect(correct).not.toHaveBeenCalled();
confirm.mockReturnValue(true);
close();
@@ -580,7 +535,7 @@ it("suspends the editor without losing its draft or leaving the background inert
);
});
it("stamps today's source and conversation time without an evidence page", () => {
it("stamps today's source time the same way as 06A and 06E", () => {
render(
<SelectedMemoryDetail
detail={{
@@ -597,6 +552,8 @@ it("stamps today's source and conversation time without an evidence page", () =>
/>,
);
expect(screen.getByText(/来源 workbuddy · 今天 /)).toBeTruthy();
expect(screen.getByText(/对话时间 今天 /)).toBeTruthy();
expect(screen.queryByRole("button", { name: "查看原文" })).toBeNull();
expect(screen.getByText(/原对话 今天 /)).toBeTruthy();
fireEvent.click(screen.getByRole("button", { name: "查看原文" }));
expect(screen.getByRole("heading", { name: "原始对话" })).toBeTruthy();
expect(screen.getByText(/^workbuddy · 今天 /)).toBeTruthy();
});
+96 -65
View File
@@ -1,22 +1,16 @@
import type {
MemoryDetail,
SdkMemoryStatus,
SourceResponse,
} from "@one-memory/contracts";
import type { MemoryDetail, SourceResponse } from "@one-memory/contracts";
import { useEffect, useRef, useState } from "react";
import { createPortal } from "react-dom";
import { apiErrorCode, correctMemory, getMemory, mutateMemory } from "./api";
import { clockStamp } from "./clock-stamp";
import { useGuiLanguage } from "./gui-language";
import { R6Button } from "./R6Controls";
import { sourceNameOr, UNATTRIBUTED_SOURCE_ID } from "./source-label";
import { sourceNameOr } from "./source-label";
import { traceUiAction } from "./ui-trace";
import { isDefiniteRefusal, writeTraceStatus } from "./write-outcome";
import "./selected-memory.css";
type Page = "read" | "edit" | "delete";
const HEADING_LIMIT = 72;
type Page = "read" | "edit" | "original" | "delete";
export function SelectedMemoryDetail({
detail,
@@ -56,28 +50,19 @@ export function SelectedMemoryDetail({
}, []);
const memory = detail.memory;
const evidence = detail.evidence[0];
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"
const statusLabel =
memory.status === "confirmed"
? t("selectedMemory.status.confirmed")
: memory.status === "deleted"
? t("selectedMemory.status.deleted")
: memory.status === "superseded"
? t("selectedMemory.status.superseded")
: t("selectedMemory.status.unknown");
const sourceId = detail.source_id ?? evidence?.source_id;
const source =
detail.sdk_status && sourceId === UNATTRIBUTED_SOURCE_ID
? t("selectedMemory.sourceUnattributed")
: sourceNameOr(sources, sourceId, t("selectedMemory.sourceUnknown"));
const source = sourceNameOr(
sources,
evidence?.source_id,
t("selectedMemory.sourceUnknown"),
);
const dirty = page === "edit" && draft !== memory.content;
useEffect(() => {
if (page === "edit" && memory.revision > base) setConflictFound(true);
@@ -236,22 +221,20 @@ export function SelectedMemoryDetail({
if (alive.current) setBusy(false);
}
}
// The heading is the first sentence when it fits in HEADING_LIMIT
// characters; otherwise the text breaks at the last space within the limit
// (CJK text has none, so it breaks at the limit itself).
const punctuation = memory.content.search(/[。!?!?\n]/);
const space = memory.content.lastIndexOf(" ", HEADING_LIMIT);
const split =
punctuation > 0 && punctuation < HEADING_LIMIT
? punctuation + 1
: memory.content.length > HEADING_LIMIT
? space > HEADING_LIMIT / 2
? space
: HEADING_LIMIT
: -1;
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 ? memory.content.slice(0, split).trim() : memory.content;
const body = split > 0 ? memory.content.slice(split).trim() : "";
split > 0 && split < 85
? memory.content.slice(0, split + 1)
: memory.content;
const body =
split > 0 && split < 85 ? memory.content.slice(split + 1).trim() : "";
return (
<>
<article
@@ -259,37 +242,23 @@ export function SelectedMemoryDetail({
data-hide-actions={hideActions ? "true" : "false"}
>
<p className="selected-memory-status">
{statusLabel}
{!detail.sdk_status &&
` · ${t("selectedMemory.version", {
revision: new Intl.NumberFormat(locale).format(memory.revision),
})}`}
{statusLabel} ·{" "}
{t("selectedMemory.version", {
revision: new Intl.NumberFormat(locale).format(memory.revision),
})}
</p>
<h3>{heading}</h3>
{body && <p className="selected-memory-body">{body}</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-source">
{t("selectedMemory.sourceLine", { source, time: timestamp })}
</p>
<p className="selected-memory-times">
{t("selectedMemory.timestamps", {
conversation: clockStamp(
conversationAt?.at ??
detail.sdk_memory_at ??
evidence?.created_at,
locale,
),
created: clockStamp(
detail.sdk_created_at ?? memory.updated_at,
conversationAt?.at ?? evidence?.created_at,
locale,
),
created: clockStamp(memory.updated_at, locale),
})}
</p>
{!hideActions && (
@@ -307,6 +276,27 @@ 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")}
@@ -355,7 +345,9 @@ export function SelectedMemoryDetail({
aria-label={
page === "edit"
? t("selectedMemory.editTitle")
: t("selectedMemory.delete")
: page === "original"
? t("selectedMemory.originalTitle")
: t("selectedMemory.delete")
}
onKeyDown={(event) => {
if (event.key === "Tab") {
@@ -388,8 +380,13 @@ export function SelectedMemoryDetail({
<h1 ref={focus} tabIndex={-1}>
{page === "edit"
? t("selectedMemory.editTitle")
: t("selectedMemory.delete")}
: page === "original"
? t("selectedMemory.originalTitle")
: t("selectedMemory.delete")}
</h1>
{page === "original" && (
<R6Button onClick={close}>{t("selectedMemory.back")}</R6Button>
)}
</header>
{page === "edit" ? (
<div className="selected-editor">
@@ -446,6 +443,40 @@ 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,8 +68,6 @@ describe("paged memory library client", () => {
expect(
await getSdkMemoryLibrary({
statuses: ["active", "superseded"],
source: "cursor",
query: "release",
offset: 0,
limit: 25,
}),
@@ -78,7 +76,5 @@ 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");
});
});
-26
View File
@@ -891,14 +891,10 @@ 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;
} = {},
@@ -909,8 +905,6 @@ 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,
@@ -1168,26 +1162,6 @@ 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. */
/** The SDK map for one list page: the same source and window the list reads. */
export async function getSdkMemoryGraph(
{
source,
offset = 0,
limit = 100,
}: { source?: string; offset?: number; limit?: number } = {},
signal?: AbortSignal,
): Promise<MemoryGraphResponse> {
const response = await client.get<MemoryGraphResponse>(
"/api/v2/memories/graph",
{
params: { limit, offset, ...(source ? { source } : {}) },
signal,
},
);
return response.data;
}
export async function getMemoryGraphWith(
transport: { get: typeof client.get } = client,
): Promise<MemoryGraphResponse> {
-6
View File
@@ -54,12 +54,6 @@ describe("clockStamp", () => {
expect(clockStamp(value, "es")).toContain("19");
});
it("names the year of a date in another year", () => {
expect(clockStamp("2254-01-13T13:54:00Z", "zh-CN")).toContain("2254");
expect(clockStamp("2025-12-31T12:00:00Z", "es")).toContain("2025");
expect(clockStamp("2026-08-03T15:07:00Z", "zh-CN")).not.toContain("2026");
});
it("formats another date with month and day, without a today word", () => {
const value = "2026-08-03T15:07:00Z";
const es = clockStamp(value, "es");
-3
View File
@@ -34,10 +34,7 @@ export function clockStamp(
date.getDate() === now.getDate()
)
return translate(locale, "clock.today", { clock });
// Another year carries its year, as the memory list's day stamp does: a
// bare "01/13" for 2254-01-13 reads as this January.
return new Intl.DateTimeFormat(locale, {
...(date.getFullYear() !== now.getFullYear() ? { year: "numeric" } : {}),
month: "2-digit",
day: "2-digit",
hour: "2-digit",
-17
View File
@@ -689,23 +689,6 @@ button.rail-button.active::before {
background: transparent;
font-size: 12px;
}
.map-search input {
flex: 1;
}
.map-search-submit {
flex: none;
padding: 4px 10px;
border: 0;
border-radius: 6px;
color: #fff;
background: #3d5a70;
font-size: 12px;
cursor: pointer;
}
.map-search-submit:disabled {
opacity: 0.5;
cursor: default;
}
.map-search:focus-within,
.map-question:focus-within {
outline: 2px solid #7892a6;
+1 -3
View File
@@ -23,8 +23,6 @@ export function projectPagedSourceMap(
graph: MemoryGraphResponse | null,
page: MemoryLibraryPage | null,
sources: SourceResponse[],
/** Names for source ids that are not in the catalog (e.g. 未识别来源). */
labels: Record<string, string> = {},
): MemoryGraphResponse {
const nodes = new Map<string, MemoryGraphNode>();
const edges = new Map<string, MemoryGraphEdge>();
@@ -95,7 +93,7 @@ export function projectPagedSourceMap(
node_type: "source",
memory_id: null,
source_id: id,
label: catalog.get(id)?.display_name || labels[id] || id,
label: catalog.get(id)?.display_name || id,
kind: null,
status: null,
confidence: null,
@@ -1,87 +0,0 @@
import type { SdkMemoryRecord } from "@one-memory/contracts";
import { afterEach, beforeEach, expect, it, vi } from "vitest";
import { projectPagedSourceMap } from "./memory-map-model";
import { sdkDetail, sdkLibraryPage } from "./sdk-memory-map";
const NOW = Date.UTC(2026, 9, 25) / 1_000;
beforeEach(() => {
vi.useFakeTimers();
vi.setSystemTime(NOW * 1_000);
});
afterEach(() => vi.useRealTimers());
const record = (extra: Partial<SdkMemoryRecord>): SdkMemoryRecord => ({
memory_id: "m",
content: "memory",
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: NOW - 86_400,
...extra,
});
it("keeps the SDK's no-source bucket as a source the list and map can name", () => {
const page = sdkLibraryPage({
memories: [record({ memory_id: "d", source: "default" })],
total: 1,
limit: 100,
offset: 0,
});
expect(page.source_ids_by_memory).toEqual({ d: ["default"] });
expect(sdkDetail(record({ source: "default" })).source_id).toBe("default");
const drawn = projectPagedSourceMap(null, page, [], {
default: "未识别来源",
});
expect(
drawn.nodes.find((node) => node.node_id === "source:default")?.label,
).toBe("未识别来源");
});
it("does not present an impossible conversation time as when it happened", () => {
const future = Date.UTC(2254, 0, 13) / 1_000;
const page = sdkLibraryPage({
memories: [record({ memory_id: "f", memory_at: future })],
total: 1,
limit: 100,
offset: 0,
});
expect(page.source_time_by_memory?.f).toEqual({
at: new Date((NOW - 86_400) * 1_000).toISOString(),
basis: "import",
});
expect(sdkDetail(record({ memory_at: future })).sdk_memory_at).toBeNull();
const real = NOW - 3 * 86_400;
expect(
sdkLibraryPage({
memories: [record({ memory_id: "r", memory_at: real })],
total: 1,
limit: 100,
offset: 0,
}).source_time_by_memory?.r.basis,
).toBe("conversation");
});
it("carries a full search window through to the list", () => {
expect(
sdkLibraryPage({
memories: [],
total: 0,
limit: 100,
offset: 0,
search_window: 500,
}).search_window,
).toBe(500);
});
-136
View File
@@ -1,136 +0,0 @@
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()
: "";
}
const EARLIEST_PLAUSIBLE = Date.UTC(2000, 0, 1) / 1_000;
/** A conversation time that can be true: not before 2000, not tomorrow or later.
*
* A historical Cursor bug stored a file hash as the message time (years like
* 2254). Such a value is not shown as when the conversation happened; the
* import time, or "time unknown", says what is actually known.
*/
function plausible(seconds: number | null | undefined): number | null {
return typeof seconds === "number" &&
Number.isFinite(seconds) &&
seconds >= EARLIEST_PLAUSIBLE &&
seconds <= Date.now() / 1_000 + 86_400
? seconds
: null;
}
/** The SDK writes "default" when no source was given; keep it as that bucket. */
function sourceOf(record: SdkMemoryRecord): string {
return record.source || "default";
}
/** 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: sourceOf(record),
sdk_memory_at: stamp(plausible(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) {
sourceIds[record.memory_id] = [sourceOf(record)];
const conversation = plausible(record.memory_at);
const at = conversation ?? plausible(record.gmt_created);
if (at === null) continue;
sourceTime[record.memory_id] = {
at: new Date(at * 1_000).toISOString(),
basis: conversation !== null ? "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,
search_window: page.search_window ?? null,
};
}
+72 -12
View File
@@ -138,19 +138,13 @@
gap: 8px;
font-family: "Noto Sans SC", "PingFang SC", sans-serif;
}
.r6-shell .r6-memory .map-memory-item > .map-memory-content {
.r6-shell .r6-memory .map-memory-item > span:not(.map-state) {
min-height: 40px;
display: -webkit-box;
-webkit-line-clamp: 2;
-webkit-box-orient: vertical;
overflow: hidden;
}
.r6-shell .r6-memory .map-memory-tags.wb-tags {
grid-column: 2;
min-height: 0;
margin: 0;
padding: 0;
}
.r6-shell .r6-memory .map-memory-item small {
font-size: 11px;
line-height: 19px;
@@ -218,19 +212,15 @@
margin: 0 0 32px;
}
.selected-memory-detail > h3 {
display: block;
font-size: 22px;
line-height: 38px;
font-weight: 500;
letter-spacing: normal;
margin: 0 0 18px;
overflow-wrap: anywhere;
}
.selected-memory-body {
display: block;
font-size: 17px;
line-height: 29px;
letter-spacing: normal;
margin: 0;
white-space: pre-wrap;
overflow-wrap: anywhere;
@@ -247,6 +237,36 @@
color: #5e6e7a;
line-height: 21px;
}
.selected-memory-evidence {
border-top: 1px solid #c7d6e0;
margin: 24px 0 16px;
padding-top: 22px;
}
.selected-memory-evidence h4 {
font-size: 16px;
line-height: 28px;
margin: 0 0 18px;
font-weight: 400;
}
.selected-memory-evidence small {
font-size: 12px;
color: #5e6e7a;
}
.selected-memory-evidence blockquote {
font-size: 15px;
line-height: 26px;
margin: 18px 0 44px;
border: 0;
padding: 0;
background: none;
white-space: pre-wrap;
overflow-wrap: anywhere;
}
.selected-memory-detail[data-hide-actions="true"]
.selected-memory-evidence
blockquote {
margin-bottom: 0;
}
.selected-memory-more {
font-size: 12px;
padding: 16px 0;
@@ -284,7 +304,8 @@
align-items: center;
gap: 24px;
}
.selected-memory-page > header .r6-button {
.selected-memory-page > header .r6-button,
.selected-original > .r6-button {
min-width: 150px;
}
.selected-memory-page h1 {
@@ -373,6 +394,40 @@
border-left: 2px solid #8da4b3;
white-space: pre-wrap;
}
.selected-original {
padding: 60px 46px;
min-height: 0;
overflow-y: auto;
overscroll-behavior: contain;
scrollbar-width: thin;
}
.selected-original > small {
font-size: 14px;
color: #5e6e7a;
}
.selected-original > p {
margin-top: 70px;
font-size: 14px;
}
.selected-original blockquote {
margin: 24px 0 96px;
font-size: 24px;
line-height: 40px;
white-space: pre-wrap;
overflow-wrap: anywhere;
}
.selected-original section {
margin-top: 24px;
}
.selected-original section > small {
font-size: 13px;
color: #5e6e7a;
}
.selected-original > h2 {
font-size: 21px;
font-weight: 400;
margin: 24px 0 32px;
}
.selected-delete {
padding: 130px 66px 0;
min-height: 0;
@@ -444,6 +499,10 @@
.selected-delete > p {
margin: 12px 0;
}
.selected-original {
padding-top: 24px;
padding-bottom: 24px;
}
}
@media (max-width: 1250px) {
.r6-shell .view.r6-memory {
@@ -477,6 +536,7 @@
.selected-memory-page {
padding: 24px;
}
.selected-original,
.selected-delete {
padding-left: 0;
padding-right: 0;
-7
View File
@@ -9,13 +9,6 @@ const INTERNAL_SOURCE_NAMES: Record<string, string> = {
"memory-content-export": "导入的已有记忆",
};
/**
* The source the SDK records when a writer named none. The legacy-to-SDK
* backfill wrote every legacy memory this way, so it is a real, filterable
* bucket ("未识别来源"), not a source still being checked.
*/
export const UNATTRIBUTED_SOURCE_ID = "default";
type NamedSource = Pick<SourceResponse, "source_id" | "display_name">;
/** The name to show for a source id, falling back to the id itself. */
+1 -1
View File
@@ -2,7 +2,7 @@
"name": "one-memory",
"version": "1.0.5",
"release": {
"build_number": 22,
"build_number": 17,
"macos_min_version": "13.0",
"app_update": "manual",
"runtime_manifest_schema": 1,
-9
View File
@@ -470,9 +470,6 @@ export interface MemoryLibraryPage {
* import): a partial snapshot, not an authoritative view. Show "importing"
* and retry; never render this state as a failure. */
stale?: boolean;
/** Set on a search whose relevance window was full: matches were looked for
* only among this many most relevant memories, so `total` may undercount. */
search_window?: number | null;
}
export interface MemoryLibraryQuery {
@@ -521,11 +518,6 @@ 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;
@@ -563,7 +555,6 @@ export interface SdkMemoryPage {
total: number;
limit: number;
offset: number;
search_window?: number | null;
}
export interface SdkUserProfileResponse {
@@ -317,39 +317,6 @@ it("selects SDK Context v2 after cutover and passes native fields unchanged", as
);
});
it.each([
[10, 200],
[11, 502],
])(
"accepts up to Core's v2 default of 10 items when no limit is sent (%i items)",
async (count, status) => {
const f = await fixture();
const items = Array.from({ length: count }, (_, index) => ({
kind: "memory",
memory_id: `memory-${index}`,
content: "m",
layer: "l2_fact",
status: "active",
source: "codex",
updated_at: "2026-10-21T00:00:00Z",
score: 0.5,
truncated: false,
}));
f.setAuthority("sdk");
f.respond({
schema_version: 2,
request_id: "sdk-request",
state: "grounded",
items,
used_characters: count,
truncated: false,
total_ms: 1,
});
expect((await f.post({ query: "original" })).status).toBe(status);
},
);
it("does not fall back to legacy context when the selected SDK response is invalid", async () => {
const f = await fixture();
f.setAuthority("sdk");
@@ -74,7 +74,7 @@ export const CONTEXT_INPUT_SCHEMA = {
description:
"Filters raw only, within already granted sources; does not change memory scope.",
},
limit: { type: "integer", minimum: 1, maximum: 20, default: 10 },
limit: { type: "integer", minimum: 1, maximum: 20, default: 8 },
character_budget: {
type: "integer",
minimum: 200,
@@ -285,10 +285,8 @@ export function isContextResponseForRequest(
): value is HostContextSearchResponse {
if (!isHostContextSearchResponse(value)) return false;
const kinds = request.kinds ?? ["raw", "memory"];
// Core's default item cap: 8 on v1, 10 on SDK-native v2.
const limit = request.limit ?? (isSdkContextSearchResponse(value) ? 10 : 8);
return (
value.items.length <= limit &&
value.items.length <= (request.limit ?? 8) &&
value.used_characters <= (request.character_budget ?? 4000) &&
value.items.every(
(item) =>
@@ -74,16 +74,10 @@ 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.sourceUnattributed": "未识别来源",
"selectedMemory.discardDraft": "放弃尚未保存的修改?",
"selectedMemory.newVersionReadFailed": "暂时无法读取新版本,草稿已保留。",
"selectedMemory.updateRejected":
@@ -214,16 +208,10 @@ 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.sourceUnattributed": "Unidentified source",
"selectedMemory.discardDraft": "Discard unsaved changes?",
"selectedMemory.newVersionReadFailed":
"Could not load the new version. Your draft is retained.",
@@ -368,16 +356,10 @@ 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.sourceUnattributed": "Origen no identificado",
"selectedMemory.discardDraft": "¿Descartar los cambios sin guardar?",
"selectedMemory.newVersionReadFailed":
"No se pudo leer la nueva versión. Tu borrador se conserva.",
@@ -1571,10 +1571,6 @@ const baseEnglishMessages = {
"memory:全部记忆": "All memories",
"memory:来源待核对": "Source needs review",
"memory:所选来源待核对": "Selected source needs review",
"memory:未识别来源": "Unidentified source",
"memory:这个来源下还没有记忆": "No memories from this source yet",
"memory:只在最相关的 {count} 条记忆里查找关键词":
"Keywords are matched only among the {count} most relevant memories",
"memory:未知来源": "Unknown source",
"memory:记忆读取失败": "Could not load memories",
"memory:正在读取…": "Loading…",
@@ -146,10 +146,6 @@ export const memoryChineseMessages = {
"memory:全部记忆": "全部记忆",
"memory:来源待核对": "来源待核对",
"memory:所选来源待核对": "所选来源待核对",
"memory:未识别来源": "未识别来源",
"memory:这个来源下还没有记忆": "这个来源下还没有记忆",
"memory:只在最相关的 {count} 条记忆里查找关键词":
"只在最相关的 {count} 条记忆里查找关键词",
"memory:未知来源": "未知来源",
"memory:记忆读取失败": "记忆读取失败",
"memory:正在读取…": "正在读取…",
@@ -542,10 +538,6 @@ export const memorySpanishMessages: Record<
"memory:全部记忆": "Todas las memorias",
"memory:来源待核对": "Origen sin verificar",
"memory:所选来源待核对": "Origen seleccionado sin verificar",
"memory:未识别来源": "Origen no identificado",
"memory:这个来源下还没有记忆": "Aún no hay recuerdos de este origen",
"memory:只在最相关的 {count} 条记忆里查找关键词":
"Las palabras clave solo se buscan entre los {count} recuerdos más relevantes",
"memory:未知来源": "Origen desconocido",
"memory:记忆读取失败": "Error al leer las memorias",
"memory:正在读取…": "Leyendo…",
@@ -711,7 +711,7 @@ describe("local Runtime installation", () => {
expect(replacement.version_directory).not.toBe(firstPlan.version_directory);
});
it("repairs different bytes from the same commit with the packaged opt-in", async () => {
it("refuses different bytes from the same commit even with the opt-in", async () => {
if (process.platform !== "darwin") return;
const commit = "a".repeat(40);
const first = await distribution("1.0.0", {
@@ -738,17 +738,18 @@ describe("local Runtime installation", () => {
.digest("hex");
await writeFile(manifestPath, JSON.stringify(manifest));
const plan = await planLocalInstall({
source: rebuilt.source,
installRoot: first.installRoot,
platform: process.platform,
arch: process.arch,
allowSameBuildReplace: true,
});
expect(plan.same_build_replace).toBe(true);
await expect(
planLocalInstall({
source: rebuilt.source,
installRoot: first.installRoot,
platform: process.platform,
arch: process.arch,
allowSameBuildReplace: true,
}),
).rejects.toThrow("installed_version_conflict");
});
it("replaces a dirty selected runtime from the trusted packaged payload", async () => {
it("refuses a tampered selected runtime instead of taking it over", async () => {
if (process.platform !== "darwin") return;
const first = await distribution("1.0.0", {
buildNumber: 5,
@@ -770,15 +771,15 @@ describe("local Runtime installation", () => {
gitCommit: "b".repeat(40),
});
const plan = await planLocalInstall({
source: rebuilt.source,
installRoot: first.installRoot,
platform: process.platform,
arch: process.arch,
allowSameBuildReplace: true,
});
expect(plan.same_build_replace).toBe(true);
expect(plan.version_directory).not.toBe(firstPlan.version_directory);
await expect(
planLocalInstall({
source: rebuilt.source,
installRoot: first.installRoot,
platform: process.platform,
arch: process.arch,
allowSameBuildReplace: true,
}),
).rejects.toThrow("distribution_hash_mismatch");
});
it("does not let the same-build opt-in weaken downgrade refusal", async () => {
@@ -804,32 +805,6 @@ describe("local Runtime installation", () => {
).rejects.toThrow("distribution_downgrade_refused");
});
it("does not let the same-build opt-in replace a tree whose build it cannot read", async () => {
if (process.platform !== "darwin") return;
const first = await distribution("1.0.0", { buildNumber: 5 });
const firstPlan = await planLocalInstall({
source: first.source,
installRoot: first.installRoot,
platform: process.platform,
arch: process.arch,
});
await applyLocalInstall(firstPlan);
await writeFile(
path.join(firstPlan.version_directory, "manifest.json"),
"{",
);
const older = await distribution("0.9.0", { buildNumber: 4 });
await expect(
planLocalInstall({
source: older.source,
installRoot: first.installRoot,
platform: process.platform,
arch: process.arch,
allowSameBuildReplace: true,
}),
).rejects.toThrow("distribution_manifest_invalid");
});
it("accepts an equal build_number when the bytes are identical (idempotent reinstall)", async () => {
if (process.platform !== "darwin") return;
const first = await distribution("1.0.0", { buildNumber: 5 });
@@ -367,23 +367,13 @@ async function inspectBuildNumberTransition(options: {
if (relative.startsWith("..") || path.isAbsolute(relative)) {
throw new Error("install_current_unmanaged");
}
let selectedVerified = true;
let selected = await readManifest(target).catch(() => null);
if (options.allowSameBuildReplace) {
// The opt-in may replace a dirty tree, but only one whose build it can
// still read: without a manifest the downgrade check below has nothing to
// compare, and a newer runtime would be overwritten by an older one.
if (selected === null) throw new Error("distribution_manifest_invalid");
try {
selected = await verifyDistribution(
const selected = options.allowSameBuildReplace
? await verifyDistribution(
target,
options.incoming.platform,
options.incoming.arch,
);
} catch {
selectedVerified = false;
}
}
)
: await readManifest(target).catch(() => null);
if (selected === null) return { sameBuildReplace: false };
// Pre-contract packages have no `build_number`. Comparing them against an
// invented ordinal would either break the hourly chain or manufacture a
@@ -395,9 +385,6 @@ async function inspectBuildNumberTransition(options: {
if (options.incoming.build_number > selected.build_number)
return { sameBuildReplace: false };
if (options.incoming.build_number === selected.build_number) {
if (!selectedVerified && options.allowSameBuildReplace) {
return { sameBuildReplace: true };
}
// Same build: an idempotent reinstall is fine only if the bytes match. The
// incoming source is verified, so compare it against the selected tree.
try {
@@ -408,8 +395,11 @@ async function inspectBuildNumberTransition(options: {
}
if (
!isReleaseCommit(selected.git_commit) ||
!isReleaseCommit(options.incoming.git_commit)
!isReleaseCommit(options.incoming.git_commit) ||
selected.git_commit === options.incoming.git_commit
) {
// Same commit with different bytes is not a new build: it is a
// non-reproducible or corrupted artifact and must stay rejected.
throw new Error("installed_version_conflict");
}
return { sameBuildReplace: true };
@@ -422,7 +422,7 @@ describe("installPackagedRuntime", () => {
).toBe("rebuilt-body");
});
it("repairs same-commit drift when the trusted bundle opts in", async () => {
it("refuses same-commit different bytes even when the bundle opts in", async () => {
const { root, resources, executable, installRoot } = await fixture();
const { selected } = await installFormalCurrent({
root,
@@ -434,43 +434,18 @@ describe("installPackagedRuntime", () => {
await writeFile(path.join(selected, "web", "index.html"), "unexpected");
await rewriteManifestDigest(selected, "web/index.html");
const receipt = await installPackagedRuntime({
resourcesPath: resources,
executablePath: executable,
installRoot,
platform: "darwin",
arch: "arm64",
buildNumber: 3757,
gitCommit: "a".repeat(40),
replaceSameBuild: true,
});
expect(receipt.reused_current).toBe(false);
expect(receipt.version_directory).not.toBe(selected);
});
it("repairs an extra file left in the selected runtime tree", async () => {
const { root, resources, executable, installRoot } = await fixture();
const { selected } = await installFormalCurrent({
root,
resources,
executable,
installRoot,
gitCommit: "a".repeat(40),
});
await writeFile(path.join(selected, "left-by-ditto"), "dirty");
const receipt = await installPackagedRuntime({
resourcesPath: resources,
executablePath: executable,
installRoot,
platform: "darwin",
arch: "arm64",
buildNumber: 3757,
gitCommit: "a".repeat(40),
replaceSameBuild: true,
});
expect(receipt.reused_current).toBe(false);
expect(receipt.version_directory).not.toBe(selected);
await expect(
installPackagedRuntime({
resourcesPath: resources,
executablePath: executable,
installRoot,
platform: "darwin",
arch: "arm64",
buildNumber: 3757,
gitCommit: "a".repeat(40),
replaceSameBuild: true,
}),
).rejects.toThrow("installed_version_conflict");
});
it.each(["missing"])(
@@ -508,31 +483,6 @@ describe("installPackagedRuntime", () => {
},
);
it("refuses to repair a selected tree whose manifest is unreadable", async () => {
const { root, resources, executable, installRoot } = await fixture();
const { selected } = await installFormalCurrent({
root,
resources,
executable,
installRoot,
buildNumber: 3758,
});
await writeFile(path.join(selected, "manifest.json"), "{");
await expect(
installPackagedRuntime({
resourcesPath: resources,
executablePath: executable,
installRoot,
platform: "darwin",
arch: "arm64",
buildNumber: 3757,
gitCommit: "a".repeat(40),
replaceSameBuild: true,
}),
).rejects.toThrow("distribution_manifest_invalid");
});
it("preserves downgrade refusal when selected current is newer", async () => {
const { root, resources, executable, installRoot } = await fixture();
await installFormalCurrent({
@@ -123,9 +123,9 @@ export async function installPackagedRuntime(options: {
*
* Off by default, which preserves `installed_version_conflict`. Desktop turns
* it on: the bundle it carries has passed notarization, so its payload is
* already trusted. It may need to replace a rebuilt release or repair a
* selected tree polluted by an interrupted copy. Refusing either case leaves
* an affected machine with an app that exits on every launch, which is worse
* already trusted, and the same build number is re-released whenever a
* release is rebuilt from a new commit. Refusing that case leaves an affected
* machine with an app that exits on every launch, which is a worse outcome
* than replacing the runtime. The superseded tree is kept, not edited. */
replaceSameBuild?: boolean;
gitCommit?: string;
@@ -307,36 +307,11 @@ async function reuseSelectedSameBuild(options: {
) {
throw new Error("install_current_unmanaged");
}
let selectedManifest: DistributionManifest;
try {
selectedManifest = await verifyInstalledDistribution(
selected,
options.platform,
options.arch,
);
} catch (error) {
if (!options.replaceSameBuild) throw error;
// A notarized App bundle is the repair source. A dirty selected tree must
// reach the normal staged install path instead of failing before the
// same-build replacement policy can run.
const declared = await readFile(
path.join(selected, "manifest.json"),
"utf8",
)
.then((value) => JSON.parse(value) as DistributionManifest)
.catch(() => null);
// Without a readable build the downgrade check cannot run; fail closed
// rather than let an older bundle replace a newer runtime.
if (declared === null) throw new Error("distribution_manifest_invalid");
if (
declared.build_number !== undefined &&
options.buildNumber !== undefined &&
declared.build_number > options.buildNumber
) {
throw new Error("distribution_downgrade_refused");
}
return null;
}
const selectedManifest = await verifyInstalledDistribution(
selected,
options.platform,
options.arch,
);
if (selectedManifest.build_number !== options.buildNumber) return null;
// The bundle's own payload, read in place. It has to agree with the selected
// tree file for file; there is no cheaper signal that is also exact, and a
@@ -83,7 +83,7 @@ it("does not stop Runtime when nothing needs migrating", async () => {
expect(fenced).toBe(false);
});
it("reports a repair of an already migrated home as complete, not a second cutover", async () => {
it("treats a finished cleanup as a completed migration", async () => {
const result = await runSdkMigration({
dataDir: "/data",
core,
@@ -95,7 +95,7 @@ it("reports a repair of an already migrated home as complete, not a second cutov
fence: noFence,
});
expect(result).toEqual({ state: "complete" });
expect(result).toEqual({ state: "cutover" });
});
it("reports an unreadable migration status as deferred", async () => {
@@ -4,8 +4,7 @@ import { withRuntimeMaintenanceFence } from "./runtime-maintenance.js";
const MAX_OUTPUT_BYTES = 1024 * 1024;
export interface SdkMigrationResult {
/** `complete`: already on SDK authority; leftovers were repaired. */
state: "not_required" | "cutover" | "complete" | "deferred";
state: "not_required" | "cutover" | "deferred";
code?: string;
}
@@ -85,7 +84,8 @@ export async function runSdkMigration(
}
if (migrated.exitCode !== 0) return deferred(migrated);
const state = migrated.document.state;
if (state === "cutover" || state === "complete") return { state };
if (state === "cutover" || state === "complete")
return { state: "cutover" };
if (state === "not_required") return { state: "not_required" };
return deferred(migrated);
});