Compare commits

...
Author SHA1 Message Date
kristinhe 686c14ed62 chore(release): bump MemHub build number to 24 2026-10-25 20:19:43 +08:00
feitianxiang fcb1bcd866 Merge pull request #174 from Tencent-Hunyuan/chore/bump-build-22
chore(release): bump MemHub build number to 22
2026-10-25 15:32:06 +08:00
alvinfeiandClaude Opus 5.5 6305e4cea8 chore(release): bump MemHub build number to 22
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-10-25 15:31:24 +08:00
feitianxiang a4b0969248 Merge pull request #173 from Tencent-Hunyuan/fix/memory-library-build23-acceptance
fix(memhub): repair Build 23 memory library issues
2026-10-25 15:28:12 +08:00
alvinfeiandClaude Opus 5.5 e0920311a1 fix(memhub): close gaps in the Build 23 memory library repair
- Cursor: resume WAL-backed stores from their rowid. The cursor identity
  now carries the WAL change token, but resume_checkpoint/touched_cursor
  still compared the bare dev:ino, so every change re-read the store from
  rowid 0 and reported a rewind.
- Library search: leave the proactive intention lane off, list only
  active hits, bound the per-hit client.get fan-out, and count skipped
  rows from the single page parse instead of re-validating the page.
- Metadata repair: pool evidence per SDK memory so disagreeing sources
  stay unknown instead of last-output-wins; close the legacy, coding and
  Chroma handles; register both openings in the main-database gate,
  which the previous commit left failing.
- Runtime: the same-build opt-in fails closed when the selected tree's
  manifest is unreadable, so it cannot bypass downgrade refusal.
- Detail: keep the heading within 72 characters and break English text
  at a word boundary.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-10-25 15:21:31 +08:00
alvinfeiandCursor e6426f6fd0 fix(memhub): repair SDK memory library acceptance issues
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-10-25 14:53:38 +08:00
feitianxiang 99e3c8d9d7 Merge pull request #172 from Tencent-Hunyuan/fix/sdk-memory-native-detail
fix(memhub): reuse the memory map for SDK-native details
2026-10-25 09:54:03 +08:00
alvinfeiandCursor 896a9c6c83 fix(memhub): render SDK memory details natively
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-10-25 09:46:34 +08:00
kristinhe 193fabeeb4 fix(memhub): open an SDK memory where the map put it
On SDK authority, clicking a memory switched the whole page to the SDK
library — a second screen made of status tabs and a ledger, where the memory
page's design has one list, one graph and one inspector. Those three already
exist: they are the components the mock-data screen was built from. This wires
the real SDK reads into them instead of standing a second screen beside them.

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

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

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

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

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

No files matched your search

+13
View File
@@ -4,6 +4,19 @@ Hy Memory and MemHub are versioned and released independently.
## Unreleased
- Reused the existing three-column memory map for SDK-authority list/detail
views and removed the retired "original evidence" surface. SDK-native status,
source, conversation time and creation time now render without fabricated
revisions; source and text filters are applied by the paged Core endpoint.
- 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,6 +15,8 @@
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 正文权威。
+4 -2
View File
@@ -3180,7 +3180,8 @@ async function runCommand(
migration.code ?? "sdk_migration_incomplete",
);
}
migrated = migration.state === "cutover";
migrated =
migration.state === "cutover" || migration.state === "complete";
await applyPreparedServiceInstall(preparedService);
serviceApplied = true;
// The receipt exists so a later command can find the data dir without
@@ -3665,7 +3666,8 @@ async function runCommand(
}
// An older Runtime refuses a migrated home, so from here a failure
// keeps the new version instead of rolling back to one that cannot start.
migrated = migration.state === "cutover";
migrated =
migration.state === "cutover" || migration.state === "complete";
await restartUserService(layout, serviceOptions);
// A restart that returns without error only means launchd/systemd
// accepted the command. O-22: the first `upgrade` implementation
+235 -3
View File
@@ -103,6 +103,7 @@ from one_memory.hy_sdk_types import (
HySdkMemoryRecord,
HySdkRawPage,
HySdkRawRecord,
HySdkReadError,
HySdkUserProfile,
parse_sdk_search,
)
@@ -879,6 +880,28 @@ 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:
@@ -3058,7 +3081,8 @@ def create_app(
offset=0,
order="desc",
)
page = HySdkMemoryPage.from_sdk(raw, limit=1, offset=0)
page, skipped = HySdkMemoryPage.from_sdk_skipping_invalid(raw, limit=1, offset=0)
_log_skipped_sdk_memories(skipped)
except Exception as error:
raise _sdk_http_error(error, "hy_memory_list_failed") from None
return SdkHomeSummary(
@@ -3103,7 +3127,10 @@ def create_app(
if store is None:
store = SdkDataPlaneMigrationStore(_evidence_store(app).database_path)
authority, migration_id = store.authority_state()
return MemoryAuthorityResponse(authority=authority.value, migration_id=migration_id)
return MemoryAuthorityResponse(
authority="sdk" if authority is MemoryAuthority.SDK else "legacy",
migration_id=migration_id,
)
@app.get(
"/api/v2/memory-library",
@@ -3112,22 +3139,207 @@ 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. Fetch one bounded
# relevance window so pagination can expose more than the
# first page without listing the whole library in Core.
fetch = 500
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=sdk_relevance_floor(),
schema_min_score=sdk_relevance_floor(),
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,
)
hits = parse_sdk_search(
search_raw,
error_code="hy_memory_search_shape_unknown",
)
rows = await _get_sdk_rows(
client, [hit.memory_id for hit in hits[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(hits),
limit=limit,
offset=offset,
)
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,
)
return HySdkMemoryPage.from_sdk(raw, limit=limit, offset=offset)
page, skipped = HySdkMemoryPage.from_sdk_skipping_invalid(
raw, limit=limit, offset=offset
)
_log_skipped_sdk_memories(skipped)
return page
except HTTPException:
raise
except Exception as error:
raise _sdk_http_error(error, "hy_memory_list_failed") from None
@app.get(
"/api/v2/memories/graph",
response_model=MemoryGraphResponse,
dependencies=protected,
)
async def sdk_memory_graph(
limit: int = Query(default=160, ge=1, le=300),
) -> MemoryGraphResponse:
"""The memory map on SDK authority: SDK memories plus the Kuzu overlay.
Memories come from the public client; only the overlay reads Kuzu
(`hy_graph_projection`, the visualization exception). Shape matches
the legacy map so the same view draws both.
"""
try:
client = await _sdk_client_handle(app).client()
raw = await asyncio.to_thread(
client.list_memories, user_id="local", limit=limit, offset=0, order="desc"
)
page, skipped = HySdkMemoryPage.from_sdk_skipping_invalid(raw, limit=limit, offset=0)
_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,
)
if memory.session_id:
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,
@@ -5431,6 +5643,26 @@ 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,
@@ -1,12 +1,18 @@
"""Offline snapshot and restore of the HY SDK data plane for the App migration.
"""Offline maintenance of the HY SDK data plane for App migrations.
Runtime product code must use public ``HyMemoryClient`` methods. The startup
migration bundle is the isolated exception: all writers are stopped, so it may
copy and restore the SDK-owned local stores as files.
migration boundary is the isolated exception: all writers are stopped, so it
may copy, restore and repair SDK-owned local stores.
"""
from __future__ import annotations
import json
import sqlite3
from collections.abc import Iterator
from contextlib import closing
from dataclasses import dataclass
from datetime import UTC, datetime, timedelta
from pathlib import Path
from hy_memory import ( # type: ignore[import-untyped]
@@ -16,6 +22,27 @@ 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
_METADATA_REPAIR_VERSION = 1
_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(
*,
@@ -45,3 +72,301 @@ 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 bundle."""
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)),
)
legacy = (
data_dir
/ "backups"
/ f"{migration_id}-pre-cutover.sdk-bundle"
/ "management"
/ "one-memory.sqlite3"
)
if not legacy.is_file():
return MetadataRepairResult(migration_id, 0, 0, 0)
mapping = _legacy_metadata(legacy, now=now or datetime.now(UTC))
vector_rows = _repair_vector(data_dir, mapping)
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
def _legacy_metadata(path: Path, *, now: datetime) -> dict[str, RepairMetadata]:
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 not {"memory_task_outputs", "governed_memories", "evidence"} <= tables:
return {}
outputs: list[tuple[str, str, tuple[str, ...]]] = []
evidence_ids: set[str] = set()
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
identifiers = (
tuple(
str(ref["evidence_id"])
for ref in refs
if isinstance(ref, dict) and isinstance(ref.get("evidence_id"), str)
)
if isinstance(refs, list)
else ()
)
outputs.append((legacy_id, canonical_id, identifiers))
evidence_ids.update(identifiers)
evidence: dict[str, tuple[str, datetime | None]] = {}
for batch in _batches(sorted(evidence_ids)):
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,
),
)
governed = {
str(row["memory_id"]): _plausible_time(
row["created_at"],
fallback=row["updated_at"],
now=now,
)
for row in database.execute(
"SELECT memory_id,created_at,updated_at FROM governed_memories"
)
}
# Several legacy memories can fold into one SDK memory. Pool their evidence
# before deciding, so a disagreeing source cannot be overwritten by whichever
# output happened to come last.
pooled: dict[str, tuple[set[str], list[datetime], list[datetime]]] = {}
for legacy_id, canonical_id, refs in outputs:
sources, times, created = pooled.setdefault(canonical_id, (set(), [], []))
for identifier in refs:
if identifier not in evidence:
continue
source, timestamp = evidence[identifier]
if source and source != "default":
sources.add(source)
if timestamp is not None:
times.append(timestamp)
legacy_created = governed.get(legacy_id)
if legacy_created is not None:
created.append(legacy_created)
return {
canonical_id: RepairMetadata(
source=next(iter(sources)) if len(sources) == 1 else None,
memory_at=int(max(times).timestamp()) if times else None,
created_at=int(min(created).timestamp()) if created else None,
)
for canonical_id, (sources, times, created) in pooled.items()
}
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],
) -> int:
directory = data_dir / "hy-memory" / HY_MEMORY_NAMESPACE / "vector"
if not mapping or 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)
finally:
client.close()
return repaired
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])
@@ -95,6 +95,29 @@ class HySdkMemoryPage(BaseModel):
@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] = []
@@ -115,15 +138,27 @@ class HySdkMemoryPage(BaseModel):
total += bucket_total
rows.extend(bucket[rows_name])
deduplicated: dict[str, HySdkMemoryRecord] = {}
skipped = 0
for row in rows:
record = HySdkMemoryRecord.from_sdk(row)
try:
record = HySdkMemoryRecord.from_sdk(row)
except HySdkReadError:
if not skip_invalid:
raise
skipped += 1
continue
deduplicated.setdefault(record.memory_id, record)
return cls(
page = 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):
@@ -50,6 +50,10 @@ 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 == source_file.file_identity
and previous.file_identity == _cursor_file_identity(source_file)
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 previous.file_identity == source_file.file_identity
and _same_file(previous, source_file)
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 previous.file_identity != source_file.file_identity:
if not _same_file(previous, source_file):
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=source_file.file_identity,
file_identity=_cursor_file_identity(source_file),
checkpoint=checkpoint,
checkpoint_anchor=checkpoint_anchor,
observed_size=source_file.size,
@@ -1054,6 +1054,27 @@ 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,
).digest()
return replace(database, modified_ns=int.from_bytes(version, "big") & ((1 << 63) - 1))
).hexdigest()
return replace(database, cursor_version=version)
def _live_baseline(listing: _CursorListing) -> dict[str, SourceCursor]:
@@ -22,6 +22,13 @@ class RetiredEvidenceStore:
del limit
return 0
def release_sdk_staging(self, evidence_ids: tuple[str, ...]) -> int:
# The write drain releases staged Evidence after every SDK write. On
# SDK authority nothing is staged; without this, `__getattr__` raised
# past `getattr(..., None)` and every completed write logged an error.
del evidence_ids
return 0
def get(self, _identifier: str) -> None:
return None
@@ -92,6 +92,15 @@ ALLOWED = {
"the memory ids a migrated home must still read back",
opened_with="?mode=ro",
),
("hy_sdk_migration.py", "_legacy_metadata"): Allowed(
"the 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",
+152
View File
@@ -278,3 +278,155 @@ 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 bounded relevance window; the superseded
# hit is in it but is not listed as an active memory.
assert body["total"] == 2
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"] == 500
assert search_calls[0][1]["intention_limit"] == 0
assert list_calls == []
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())
@@ -136,6 +136,64 @@ 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,6 +4,8 @@ import asyncio
import hashlib
import io
import json
import sqlite3
from contextlib import closing
from datetime import UTC, datetime, timedelta
from pathlib import Path
@@ -15,6 +17,7 @@ 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
@@ -67,6 +70,176 @@ 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_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:
@@ -866,3 +1039,124 @@ def test_a_legacy_home_reports_legacy_authority(tmp_path) -> None:
assert document == {"authority": "legacy", "migration_id": None}
assert gate == "legacy"
@pytest.mark.integration
@pytest.mark.fresh_sdk
def test_the_sdk_memory_map_draws_sdk_memories_without_legacy_tables(tmp_path) -> None:
"""Build 21: the memory map had no SDK read and waited forever."""
from one_memory.hy_graph_projection import HyGraphEdge, HyGraphNode, HyGraphSnapshot
rows = [
{
"memory_id": memory_id,
"content": f"memory {memory_id}",
"layer": "l2_fact",
"status": "active",
"is_latest": True,
"user_id": "local",
"agent_id": "all-apps",
"session_id": "s1",
"source": "cursor",
}
for memory_id in ("a", "b")
]
class Client:
def list_memories(self, **_kwargs):
return {"vdb": {"memories": rows, "total": 7}}
class Handle:
async def client(self):
return Client()
async def close(self):
return None
async def scenario():
app = create_app(_app_settings(tmp_path), secret_store=InMemorySecretStore())
async with (
app.router.lifespan_context(app),
AsyncClient(
transport=ASGITransport(app=app),
base_url="http://test",
headers={"Authorization": "Bearer authority-test"},
) as client,
):
original = app.state.sdk_client_handle
app.state.sdk_client_handle = Handle()
async def graph_snapshot(*, limit):
del limit
return HyGraphSnapshot(
nodes=(HyGraphNode(node_id="t1", content="topic", layer="TOPIC"),),
edges=(
HyGraphEdge(
source_node_id="t1", target_node_id="a", relation="derived_from"
),
),
)
app.state.sdk_memory_runtime.graph_snapshot = graph_snapshot
assert (await client.get("/api/v1/memories/graph")).status_code == 409
response = await client.get("/api/v2/memories/graph")
app.state.sdk_client_handle = original
assert response.status_code == 200
return response.json()
graph = asyncio.run(scenario())
assert (graph["total_memories"], graph["projected_memories"]) == (7, 2)
assert {node["node_id"] for node in graph["nodes"]} == {
"memory:a",
"memory:b",
"source:cursor",
"hy:t1",
}
assert {edge["relation"] for edge in graph["edges"]} == {
"evidence_source",
"same_conversation",
"derived_from",
}
assert graph["hy_graph_available"] is True
def test_a_completed_sdk_write_does_not_touch_retired_evidence(store) -> None:
"""Build 21 logged RetiredAuthorityAccess after every successful write."""
from types import SimpleNamespace
from one_memory.imports.storage import EvidenceStore as Evidence
from one_memory.memory_server.durable_queue import DurableWriteDrain, execute_job
from one_memory.retired_authority import RetiredEvidenceStore
path = store.database_path
_insert_evidence(store, "e1")
record = Evidence(path).get("e1")
assert record is not None
queue = DurableWriteQueue(path, sdk_authority=True)
job = queue.submit(
WriteRequest(
internal_session_id="s",
source_id="cursor",
batch_identity=BatchIdentity(internal_session_id="s", first_round=1, last_round=1),
evidence_ids=["e1"],
input_records=(record,),
idempotency_key="k",
)
)
DurableWriteDrain(queue, execute=lambda _job: asyncio.sleep(0)).claim(limit=1)
class Supervisor:
async def process_sdk_job(self, records, *, request_id, before_hy_invocation):
del records, request_id
await before_hy_invocation()
return SimpleNamespace(raw_ids=("raw-1",), memory_ids=("m-1",))
asyncio.run(
execute_job(
job.job_id,
queue=queue,
supervisor=Supervisor(),
evidence=RetiredEvidenceStore(path),
)
)
assert queue.get("k").state == "completed"
+52 -25
View File
@@ -112,9 +112,11 @@ import {
installBundledCliIfNeeded,
installServiceWithDisabledFallback,
isInstalledApplicationsApp,
requireCurrentRuntimeAtLeast,
stableRuntimeErrorCode,
stableRuntimeFailureDetail,
startStableRuntimeRepair,
startupFailureCause,
} from "./stable-runtime";
import { STARTUP_SCREEN_URL, startupScreenHtml } from "./startup-screen";
import {
@@ -1093,7 +1095,7 @@ async function initialize(isQuitting: () => boolean): Promise<void> {
component: "desktop.sdk_migration",
event: "sdk_migration.startup",
outcome:
migration.state === "cutover"
migration.state === "cutover" || migration.state === "complete"
? "succeeded"
: migration.state === "deferred"
? "blocked"
@@ -1158,30 +1160,52 @@ async function initialize(isQuitting: () => boolean): Promise<void> {
const previousOwner = owner;
let runtimeChanged = false;
let transientRuntime = false;
if (owner?.owner !== "cli") {
const installed = await installPackagedRuntime({
resourcesPath: process.resourcesPath,
executablePath: process.execPath,
installRoot,
platform: "darwin",
arch: process.arch,
...(packagedBuildNumber === undefined
? {}
: { buildNumber: packagedBuildNumber }),
...(packagedCommit === undefined
? {}
: { gitCommit: packagedCommit }),
// Only the packaged Desktop path opts into a valid same-build,
// different-commit takeover. CLI installs remain strict.
replaceSameBuild: true,
serviceEnableMarker: runtimeServiceEnableMarkerPath(dataDir),
});
runtimeChanged = !installed.reused_current;
await installBundledCliIfNeeded({
home: app.getPath("home"),
installRoot,
platform: "darwin",
});
// Install whoever owns the service. Owner decides who starts and stops
// it, not which build runs: skipping the install for a CLI owner (also
// the App's own guess when its receipt is missing) left an upgraded App
// on the previous Runtime and, with `current` gone, on none at all.
{
let installed: Awaited<
ReturnType<typeof installPackagedRuntime>
> | null = null;
try {
installed = await installPackagedRuntime({
resourcesPath: process.resourcesPath,
executablePath: process.execPath,
installRoot,
platform: "darwin",
arch: process.arch,
...(packagedBuildNumber === undefined
? {}
: { buildNumber: packagedBuildNumber }),
...(packagedCommit === undefined
? {}
: { gitCommit: packagedCommit }),
// Only the packaged Desktop path opts into a valid same-build,
// different-commit takeover. CLI installs remain strict.
replaceSameBuild: true,
serviceEnableMarker: runtimeServiceEnableMarkerPath(dataDir),
});
} catch (error) {
// `memhub upgrade` may already have installed a newer build.
if (
!(
owner?.owner === "cli" &&
error instanceof Error &&
error.message === "distribution_downgrade_refused"
)
)
throw error;
}
if (installed !== null) {
runtimeChanged = !installed.reused_current;
await installBundledCliIfNeeded({
home: app.getPath("home"),
installRoot,
platform: "darwin",
});
}
await requireCurrentRuntimeAtLeast(installRoot, packagedBuildNumber);
}
if (
!desktopBackgroundLaunch &&
@@ -1485,6 +1509,9 @@ async function initialize(isQuitting: () => boolean): Promise<void> {
outcome: "failed",
error_code: stableRuntimeErrorCode(error),
...diagnosticError(error),
// The code alone is often `desktop_start_failed`; the cause is what
// support needs. Home is shortened and the text bounded.
details: { cause: startupFailureCause(error, app.getPath("home")) },
});
throw error;
}
@@ -15,10 +15,12 @@ import {
installServiceWithDisabledFallback,
isDisabledServiceInstallError,
isInstalledApplicationsApp,
requireCurrentRuntimeAtLeast,
stableRuntimeErrorCode,
stableRuntimeFailureDetail,
stableRuntimeUserMessage,
startStableRuntimeRepair,
startupFailureCause,
} from "./stable-runtime";
it("only treats a packaged macOS executable under Applications as installed", () => {
@@ -311,4 +313,53 @@ 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,6 +129,7 @@ const SAFE_STABLE_RUNTIME_ERROR_CODES = new Set([
// Migration runs before the app window exists, so these are the only names
// a first launch can be given. They stay codes here and become sentences in
// STABLE_RUNTIME_USER_MESSAGES: a person reading the dialog never sees them.
"packaged_runtime_not_current",
"sdk_migration_preflight_failed",
"sdk_migration_memory_unreadable",
"sdk_migration_model_profile_required",
@@ -140,6 +141,7 @@ 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 {
@@ -205,6 +207,8 @@ export const STABLE_RUNTIME_USER_MESSAGES: Record<string, string> = {
"已安装的运行环境比这个安装包更新,无法用旧安装包启动。请安装最新版本的 MemHub;你的记忆数据不会受到影响。",
install_current_unmanaged:
"运行环境的安装目录被改动过,无法安全接管。请重新安装 MemHub,你的记忆数据不会受到影响。",
packaged_runtime_not_current:
"新版本的运行环境没有装上,MemHub 不会用旧版本继续运行。请重新安装 MemHub;如果仍然失败,请把这个提示反馈给我们。你的记忆数据不会受到影响。",
sdk_migration_memory_unreadable:
"升级前核对发现,新版本还读不到你的全部记忆,所以没有切换数据源,旧数据保持原样可用。请把这个提示反馈给我们;原有记忆数据不会丢失。",
sdk_migration_model_profile_required:
@@ -227,6 +231,8 @@ export const STABLE_RUNTIME_USER_MESSAGES: Record<string, string> = {
"升级过程返回了无法识别的内容,已停止。原有记忆数据不会丢失;请重新启动重试,如果仍然失败,请把这个提示反馈给我们。",
sdk_migration_incomplete:
"记忆数据格式升级没有完成,MemHub 暂时无法打开。请重新启动重试;如果仍然失败,请把这个提示反馈给我们,原有记忆数据不会丢失。",
runtime_service_not_ready:
"记忆服务仍在首次启动,请稍候后重试。你的记忆数据不会受到影响。",
desktop_start_failed:
"MemHub 启动失败。请重新启动;如果仍然打不开,请重新安装,你的记忆数据不会受到影响。",
};
@@ -252,3 +258,31 @@ export function stableRuntimeFailureDetail(error: unknown): string {
}
return STABLE_RUNTIME_USER_MESSAGES[error.message] ?? error.message;
}
/** The installed Runtime must be at least the build this App carries. */
export async function requireCurrentRuntimeAtLeast(
installRoot: string,
buildNumber: number | undefined,
): Promise<void> {
if (buildNumber === undefined) return;
const manifest = await readFile(
path.join(installRoot, "current", "manifest.json"),
"utf8",
)
.then((text) => JSON.parse(text) as { build_number?: unknown })
.catch(() => null);
const installed = manifest?.build_number;
if (typeof installed !== "number" || installed < buildNumber) {
throw new Error("packaged_runtime_not_current");
}
}
export function startupFailureCause(error: unknown, home: string): string {
const text =
error instanceof Error ? `${error.name}: ${error.message}` : String(error);
return text
.split(home)
.join("~")
.replace(/[\r\n]+/g, " ")
.slice(0, 300);
}
+16 -6
View File
@@ -177,7 +177,7 @@ it("OBS-22: the graph inspector has no editing entry and never says changes cann
await act(async () => {});
// The detail pane is open and the page is being re-read, which used to be
// the exact condition that printed the warning.
expect(screen.getByText("共同原文")).toBeTruthy();
expect(screen.getByRole("heading", { name: "第一条" })).toBeTruthy();
expect(screen.queryByText(/暂不能保存更改/)).toBeNull();
});
@@ -224,11 +224,11 @@ it("OBS-15: switching memory shows a read in progress instead of the previous me
// screen while it is open.
render(<Host simplified deferMemoryId="m1" />);
const list = screen.getByRole("list", { name: "地图记忆列表" });
expect(screen.getByText("共同原文")).toBeTruthy();
expect(screen.getByRole("heading", { name: "第一条" })).toBeTruthy();
await user.click(within(list).getByRole("button", { name: /第二条/ }));
// The first memory's evidence must not still be presented as if it
// belonged to the selection that is now the second one.
expect(screen.queryByText("共同原文")).toBeNull();
expect(screen.queryByRole("heading", { name: "第一条" })).toBeNull();
expect(screen.getByRole("status").textContent).toContain("正在读取这条记忆");
});
@@ -247,16 +247,26 @@ 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.getByText("共同原文")).toBeTruthy();
expect(screen.getByText(/原对话/)).toBeTruthy();
expect(screen.getByRole("heading", { name: "第一条" })).toBeTruthy();
expect(screen.queryByText(/原对话/)).toBeNull();
await user.click(screen.getByRole("button", { name: "关闭记忆详情" }));
expect(screen.queryByText("共同原文")).toBeNull();
expect(screen.queryByRole("heading", { name: "第一条" })).toBeNull();
});
it("OBS-14: lists what the conversation settled instead of asking to settle first", async () => {
+10 -1
View File
@@ -988,7 +988,16 @@ export function MemoryMap(props: Props) {
onClick={() => pick(m.memory_id)}
>
<span className={`map-state ${m.status}`} aria-hidden="true" />
<span>{m.content}</span>
<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}
<small>
{props.simplified ? (
<>
@@ -0,0 +1,257 @@
// @vitest-environment jsdom
import type {
GovernedMemory,
MemoryGraphResponse,
SdkMemoryRecord,
} from "@one-memory/contracts";
import {
cleanup,
fireEvent,
render,
screen,
waitFor,
} from "@testing-library/react";
import { afterEach, expect, it, vi } from "vitest";
import { MemorySpace } from "./MemorySpace";
import { sdkMapMemory } from "./sdk-memory-map";
const api = vi.hoisted(() => ({
authority: vi.fn(),
legacyLibrary: vi.fn(),
legacyGraph: vi.fn(),
sdkLibrary: vi.fn(),
sdkGraph: vi.fn(),
sdkMemory: vi.fn(),
}));
vi.mock("./api", () => ({
getMemoryAuthority: api.authority,
getMemoryLibrary: api.legacyLibrary,
getMemories: async () => [],
getMemoryGraph: api.legacyGraph,
getSdkMemoryLibrary: api.sdkLibrary,
getSdkMemoryGraph: api.sdkGraph,
getSdkMemory: api.sdkMemory,
updateSdkMemory: vi.fn(),
deleteSdkMemory: vi.fn(),
getMemoryProcessing: async () => null,
getModelProfiles: async () => [],
apiErrorCode: () => null,
}));
vi.mock("./GraphWidthPrompt", () => ({ GraphWidthPrompt: () => null }));
vi.mock("./MemoryNoteComposer", () => ({ MemoryNoteComposer: () => null }));
vi.mock("./MemoryMap", () => ({
MemoryMap: ({
hidden,
memories,
graph,
loaded,
onOpenMemory,
}: {
hidden: boolean;
memories: GovernedMemory[];
graph: MemoryGraphResponse | null;
loaded: boolean;
onOpenMemory(id: string): void;
}) => (
<div hidden={hidden} data-testid="map">
<span data-testid="map-memories">
{memories.map((m) => `${m.memory_id}:${m.status}`).join(",")}
</span>
<span data-testid="map-nodes">{graph?.nodes.length ?? "none"}</span>
<span data-testid="map-loaded">{String(loaded)}</span>
<button type="button" onClick={() => onOpenMemory("a")}>
open a
</button>
</div>
),
}));
vi.mock("./SdkMemoryLibrary", () => ({
SdkMemoryLibrary: ({
focusedMemoryId,
}: {
focusedMemoryId: string | null;
}) => <div data-testid="sdk-library">{focusedMemoryId ?? ""}</div>,
}));
vi.mock("./MemoryRecallScope", () => ({
MemoryRecallScope: () => null,
memoryProvenanceLabel: () => "原话",
}));
const record = (memory_id: string): SdkMemoryRecord => ({
memory_id,
content: `memory ${memory_id}`,
layer: "l2_fact",
status: "active",
is_latest: true,
supersedes: [],
superseded_by: [],
tags: ["工作"],
owner: null,
source_raw_ids: [],
user_id: "local",
agent_id: "all-apps",
session_id: "s1",
source: "cursor",
memory_at: null,
gmt_created: 1_761_000_000,
});
afterEach(() => {
cleanup();
vi.clearAllMocks();
});
function sdkAuthority() {
api.authority.mockResolvedValue({ authority: "sdk", migration_id: "m" });
api.sdkLibrary.mockResolvedValue({
memories: [record("a"), record("b")],
total: 3018,
limit: 300,
offset: 0,
});
api.sdkGraph.mockResolvedValue({
total_memories: 3018,
projected_memories: 2,
nodes: [
{ node_id: "memory:a", node_type: "memory", label: "a", memory_id: "a" },
{ node_id: "memory:b", node_type: "memory", label: "b", memory_id: "b" },
{ node_id: "source:cursor", node_type: "source", label: "Cursor" },
],
edges: [],
hy_graph_available: true,
});
}
it("draws the map from SDK memories on SDK authority, without legacy reads", async () => {
sdkAuthority();
render(<MemorySpace simplified active />);
await waitFor(() =>
expect(screen.getByTestId("map-loaded").textContent).toBe("true"),
);
expect(screen.getByTestId("map").hidden).toBe(false);
expect(screen.getByTestId("map-memories").textContent).toBe(
"a:confirmed,b:confirmed",
);
expect(screen.getByTestId("map-nodes").textContent).toBe("3");
expect(await screen.findByText(/3,018/)).toBeTruthy();
expect(api.sdkLibrary).toHaveBeenCalledWith(
{ statuses: ["active"], offset: 0, limit: 300 },
expect.any(AbortSignal),
);
expect(api.legacyLibrary).not.toHaveBeenCalled();
expect(api.legacyGraph).not.toHaveBeenCalled();
});
it("opens an SDK memory in place instead of switching away from the map", async () => {
sdkAuthority();
api.sdkMemory.mockResolvedValue(record("a"));
render(<MemorySpace simplified active />);
await waitFor(() =>
expect(screen.getByTestId("map-loaded").textContent).toBe("true"),
);
fireEvent.click(screen.getByText("open a"));
await waitFor(() => expect(api.sdkMemory).toHaveBeenCalledWith("a"));
// The inspector stays on the map; the reader is never moved to a second list.
expect(screen.getByTestId("map").hidden).toBe(false);
expect(screen.getByTestId("sdk-library").textContent).toBe("");
});
it("keeps asking for the authority until Core answers", async () => {
vi.useFakeTimers({ shouldAdvanceTime: true });
try {
sdkAuthority();
api.authority
.mockRejectedValueOnce(new Error("core_not_ready"))
.mockRejectedValueOnce(new Error("core_not_ready"));
render(<MemorySpace simplified active />);
await vi.advanceTimersByTimeAsync(2_000);
await waitFor(() => expect(api.authority).toHaveBeenCalledTimes(3));
await waitFor(() =>
expect(screen.getByTestId("map-loaded").textContent).toBe("true"),
);
} finally {
vi.useRealTimers();
}
});
it("projects SDK records into the fields the map groups by", () => {
const coding = sdkMapMemory({ ...record("c"), layer: "coding_experience" });
expect(coding).toMatchObject({
memory_id: "c",
kind: "coding",
status: "confirmed",
tags: ["工作"],
deleted_at: null,
});
expect(sdkMapMemory({ ...record("s"), layer: "l6_schema" }).kind).toBe(
"schema",
);
expect(sdkMapMemory({ ...record("x"), status: "superseded" }).status).toBe(
"superseded",
);
});
const node = (
node_id: string,
node_type: MemoryGraphResponse["nodes"][number]["node_type"],
extra: Partial<MemoryGraphResponse["nodes"][number]> = {},
): MemoryGraphResponse["nodes"][number] => ({
node_id,
node_type,
label: node_id,
memory_id: null,
source_id: null,
kind: null,
status: null,
confidence: null,
...extra,
});
const provenance = (
from: string,
to: string,
): MemoryGraphResponse["edges"][number] => ({
edge_id: `${from}->${to}`,
source_node_id: from,
target_node_id: to,
relation: "evidence_source",
weight: null,
origin: "provenance",
});
it("groups and draws SDK memories by source with the real map model", async () => {
const { mapGroups, projectMap } = await import("./memory-map-model");
const memories = [record("a"), record("b")].map(sdkMapMemory);
const graph: MemoryGraphResponse = {
total_memories: 2,
projected_memories: 2,
nodes: [
node("memory:a", "memory", { memory_id: "a" }),
node("memory:b", "memory", { memory_id: "b" }),
node("source:cursor", "source", { source_id: "cursor", label: "Cursor" }),
node("hy:t1", "topic"),
],
edges: [
provenance("source:cursor", "memory:a"),
provenance("source:cursor", "memory:b"),
],
hy_graph_available: true,
};
const groups = mapGroups(memories, graph, "source");
expect(groups.map((group) => [group.key, group.memoryIds])).toEqual([
["source:cursor", ["a", "b"]],
]);
const drawn = projectMap(graph, memories, "source", ["a", "b"]);
expect(
drawn.nodes
.filter((node) => node.node_type === "memory")
.map((n) => n.memory_id),
).toEqual(["a", "b"]);
expect(drawn.edges.length).toBeGreaterThan(0);
});
+282 -54
View File
@@ -5,6 +5,7 @@ import type {
ImportJob,
MemoryDetail,
MemoryGraphResponse,
MemoryLibraryPage,
MemoryProcessingSummary,
SdkAskMemory,
SourceResponse,
@@ -32,6 +33,7 @@ import {
apiErrorCode,
askMemoriesStream,
correctMemory,
deleteSdkMemory,
digestMemoryGraph,
getMemories,
getMemory,
@@ -39,12 +41,16 @@ import {
getMemoryGraph,
getMemoryProcessing,
getModelProfiles,
getSdkMemory,
getSdkMemoryGraph,
getSdkMemoryLibrary,
manualExtract,
mutateMemory,
retryUnknownMemoryProcessing,
saveMemoryNote,
setExtractionWindow,
syncMemoryIndex,
updateSdkMemory,
} from "./api";
import { ConversationSearch } from "./ConversationSearch";
import { clockStamp } from "./clock-stamp";
@@ -56,6 +62,7 @@ import { subscribePoll } from "./poll-scheduler";
import { R6Button } from "./R6Controls";
import { SdkMemoryLibrary } from "./SdkMemoryLibrary";
import { SdkRawHistory } from "./SdkRawHistory";
import { sdkDetail, sdkLibraryPage, sdkMapMemory } from "./sdk-memory-map";
import { sourceNameOr } from "./source-label";
import { traceUiAction } from "./ui-trace";
import { useMemoryLibrary } from "./use-memory-library";
@@ -168,13 +175,23 @@ export function MemorySpace({
if ((!active && !preload) || typeof getMemoryAuthority !== "function")
return;
let current = true;
void getMemoryAuthority()
.then((result) => {
if (current) setMemoryAuthority(result.authority);
})
.catch(() => {});
let timer: ReturnType<typeof setTimeout> | undefined;
// The app opens before Core is ready, so the first ask usually fails.
// Without a retry the authority stayed unknown and nothing ever loaded.
const ask = (delay: number) => {
void getMemoryAuthority()
.then((result) => {
if (current) setMemoryAuthority(result.authority);
})
.catch(() => {
if (!current) return;
timer = setTimeout(() => ask(Math.min(delay * 2, 10_000)), delay);
});
};
ask(500);
return () => {
current = false;
if (timer !== undefined) clearTimeout(timer);
};
}, [active, preload]);
useEffect(() => {
@@ -283,9 +300,117 @@ export function MemorySpace({
text: string;
memories: AskMemory[];
} | null>(null);
const [sdkFocusedMemoryId, setSdkFocusedMemoryId] = useState<string | null>(
null,
);
// The SDK library is no longer where a memory opens from the map; the id is
// kept so the panel still has a focus target for its own tests.
const [sdkFocusedMemoryId] = useState<string | null>(null);
// The map on SDK authority. Legacy reads (library page, governed graph)
// are refused there, so it draws SDK memories and the v2 graph instead.
const [sdkMap, setSdkMap] = useState<{
memories: GovernedMemory[];
graph: MemoryGraphResponse;
} | null>(null);
const [sdkMapFailed, setSdkMapFailed] = useState(false);
// A write on SDK authority has no governed refresh to ride on; bumping this
// re-reads the SDK page and graph after an edit or a delete.
const [sdkMapRevision, setSdkMapRevision] = useState(0);
const bumpSdkMap = useCallback(() => setSdkMapRevision((v) => v + 1), []);
// The map's left list pages through the SDK library the same way it pages
// through the governed one: the list is always one page, and the count above
// it is the library's total, not the page's length.
const [sdkLibrary, setSdkLibrary] = useState<{
page: MemoryLibraryPage | null;
loading: boolean;
failed: boolean;
}>({ page: null, loading: false, failed: false });
const [sdkLibraryQuery, setSdkLibraryQuery] = useState("");
const [sdkLibrarySource, setSdkLibrarySource] = useState("");
const [sdkLibraryOffset, setSdkLibraryOffset] = useState(0);
const sdkMapVisible =
memoryAuthority === "sdk" &&
simplified &&
active &&
displayedMode === "map";
useEffect(() => {
if (!sdkMapVisible) return;
// A write bumps the revision; reading it here re-runs the read at once
// instead of waiting for the 30s poll.
void sdkMapRevision;
let cancelled = false;
let controller: AbortController | null = null;
const read = async () => {
controller?.abort();
const request = new AbortController();
controller = request;
try {
const [page, graph] = await Promise.all([
getSdkMemoryLibrary(
{ statuses: ["active"], offset: 0, limit: 300 },
request.signal,
),
getSdkMemoryGraph(request.signal),
]);
if (cancelled) return;
setSdkMap({ memories: page.memories.map(sdkMapMemory), graph });
setSdkMapFailed(false);
} catch {
if (!cancelled && !request.signal.aborted) setSdkMapFailed(true);
}
};
void read();
const timer = setInterval(() => void read(), 30_000);
return () => {
cancelled = true;
clearInterval(timer);
controller?.abort();
};
}, [sdkMapVisible, sdkMapRevision]);
useEffect(() => {
if (memoryAuthority !== "sdk" || !simplified || !active) return;
if (displayedMode !== "map") return;
void sdkMapRevision;
let cancelled = false;
const controller = new AbortController();
setSdkLibrary((current) => ({ ...current, loading: true }));
void getSdkMemoryLibrary(
{
statuses: ["active"],
source: sdkLibrarySource || undefined,
query: sdkLibraryQuery || undefined,
offset: sdkLibraryOffset,
limit: 100,
},
controller.signal,
)
.then((page) => {
if (cancelled) return;
setSdkLibrary({
page: sdkLibraryPage(page),
loading: false,
failed: false,
});
})
.catch(() => {
if (cancelled || controller.signal.aborted) return;
setSdkLibrary((current) => ({
...current,
loading: false,
failed: true,
}));
});
return () => {
cancelled = true;
controller.abort();
};
}, [
memoryAuthority,
simplified,
active,
displayedMode,
sdkLibraryOffset,
sdkLibraryQuery,
sdkLibrarySource,
sdkMapRevision,
]);
const [draft, setDraft] = useState("");
const [selection, setSelection] = useState<Set<string>>(new Set());
const bulkInFlight = useRef(false);
@@ -969,8 +1094,9 @@ export function MemorySpace({
open(memory.memory_id);
return;
}
setSdkFocusedMemoryId(memory.memory_id);
setMode("library");
// An SDK answer cites SDK memories; they open in the inspector like any
// other rather than switching the reader to a second list.
void open(memory.memory_id);
}
async function saveAnswer(draft: { content: string }) {
@@ -1019,7 +1145,13 @@ export function MemorySpace({
setDetailFreshness(null);
setDetailLoad({ memoryId, status: "loading" });
try {
const result = await getMemory(memoryId);
// Governed detail is refused on SDK authority, so the inspector reads the
// same memory through v2 and renders it in place. Staying here keeps the
// map's list, graph and detail where the design put them.
const result =
memoryAuthority === "sdk"
? sdkDetail(await getSdkMemory(memoryId))
: await getMemory(memoryId);
if (epoch !== detailEpoch.current || result.memory.memory_id !== memoryId)
return;
setDetail(result);
@@ -1067,6 +1199,28 @@ export function MemorySpace({
return;
setBusy(true);
setError(null);
if (memoryAuthority === "sdk") {
// An SDK memory is removed outright: there is no revision to undo.
if (actionName !== "delete") {
setBusy(false);
return;
}
const trace = traceUiAction("memory", "confirm");
try {
await deleteSdkMemory(detail.memory.memory_id);
trace.done("succeeded");
closeDetail();
bumpSdkMap();
} catch (reason) {
trace.done(writeTraceStatus(reason), {
reason: "memory_action_failed",
});
setError(message(reason, t));
} finally {
setBusy(false);
}
return;
}
const intent = `${detail.memory.memory_id}:${actionName}:${detail.memory.revision}`;
const requestId = requestIdFor(requestIds.current, intent);
const trace = traceUiAction("memory", "confirm");
@@ -1273,6 +1427,28 @@ export function MemorySpace({
return;
setBusy(true);
setError(null);
if (memoryAuthority === "sdk") {
// A rewrite replaces the SDK row instead of adding a revision, so there
// is no revision to send and nothing to reconcile afterwards.
const trace = traceUiAction("memory", "submit");
try {
const saved = await updateSdkMemory(detail.memory.memory_id, draft);
trace.done("succeeded");
setDetail(sdkDetail(saved));
setDraft(saved.content);
bumpSdkMap();
} catch (reason) {
trace.done(writeTraceStatus(reason), {
reason: isDefiniteRefusal(reason)
? "memory_correct_failed"
: "memory_correct_result_unknown",
});
setError(message(reason, t));
} finally {
setBusy(false);
}
return;
}
const intent = `${detail.memory.memory_id}:correct:${detail.memory.revision}`;
const requestId = requestIdFor(requestIds.current, intent);
const trace = traceUiAction("memory", "submit");
@@ -1444,33 +1620,51 @@ export function MemorySpace({
{simplified ? t("memory:记忆") : t("memory:记忆空间")}
</h1>
<span className="memory-loaded-count">
{simplified && !management
? mapLibrary.failed
{memoryAuthority === "sdk" && simplified && !management
? sdkMapFailed && !sdkMap
? t("memory:记忆读取失败")
: mapLibrary.stale
? t("memory:正在导入历史会话,记忆将陆续出现")
: mapLibrary.page
: sdkMap
? tn(
"memory:已沉淀 {count} 条{filter} · 当前展示 {shown} 条",
sdkMap.graph.total_memories,
{
filter: "",
shown: new Intl.NumberFormat(locale).format(
sdkLibrary.page?.items.length ??
sdkMap.memories.length,
),
},
)
: t("memory:正在读取…")
: memoryAuthority === "sdk"
? null
: simplified && !management
? mapLibrary.failed
? t("memory:记忆读取失败")
: mapLibrary.stale
? t("memory:正在导入历史会话,记忆将陆续出现")
: mapLibrary.page
? tn(
"memory:已沉淀 {count} 条{filter} · 当前展示 {shown} 条",
mapLibrary.page.total,
{
filter: settledFilterLabel
? t("memory:({filter})", {
filter: settledFilterLabel,
})
: "",
shown: new Intl.NumberFormat(locale).format(
mapLibrary.page.items.length,
),
},
)
: t("memory:正在读取…")
: loaded
? tn(
"memory:已沉淀 {count} 条{filter} · 当前展示 {shown} 条",
mapLibrary.page.total,
{
filter: settledFilterLabel
? t("memory:({filter})", {
filter: settledFilterLabel,
})
: "",
shown: new Intl.NumberFormat(locale).format(
mapLibrary.page.items.length,
),
},
"memory:已加载 {count} 条记忆",
memories.filter((m) => m.status !== "deleted").length,
)
: t("memory:正在读取…")
: loaded
? tn(
"memory:已加载 {count} 条记忆",
memories.filter((m) => m.status !== "deleted").length,
)
: t("memory:正在读取…")}
: t("memory:正在读取…")}
</span>
</div>
<MemoryNoteComposer
@@ -1814,23 +2008,37 @@ export function MemorySpace({
focusDialogue={focusDialogue}
onFocusDialogueConsumed={onFocusDialogueConsumed}
libraryView={
simplified
simplified && memoryAuthority === "sdk"
? {
query: mapQuery,
source: mapSource,
page: mapLibrary.page,
loading: mapLibrary.loading,
failed: mapLibrary.failed,
stale: sourceSnapshotStale,
onQuery: setMapQuery,
onSource: setMapSource,
resetPage: () => mapLibrary.go(0),
onPage: mapLibrary.go,
onRetry: () => {
void mapLibrary.refresh().catch(() => {});
},
query: sdkLibraryQuery,
source: sdkLibrarySource,
page: sdkLibrary.page,
loading: sdkLibrary.loading,
failed: sdkLibrary.failed,
stale: false,
onQuery: setSdkLibraryQuery,
onSource: setSdkLibrarySource,
resetPage: () => setSdkLibraryOffset(0),
onPage: (offset) => setSdkLibraryOffset(offset),
onRetry: bumpSdkMap,
}
: undefined
: simplified
? {
query: mapQuery,
source: mapSource,
page: mapLibrary.page,
loading: mapLibrary.loading,
failed: mapLibrary.failed,
stale: sourceSnapshotStale,
onQuery: setMapQuery,
onSource: setMapSource,
resetPage: () => mapLibrary.go(0),
onPage: mapLibrary.go,
onRetry: () => {
void mapLibrary.refresh().catch(() => {});
},
}
: undefined
}
onMutationComplete={(id) => {
void refresh(query)
@@ -1838,19 +2046,39 @@ export function MemorySpace({
.catch((reason) => setError(message(reason, t)));
}}
hidden={!active || displayedMode !== "map"}
memories={simplified ? (mapLibrary.page?.items ?? []) : memories}
graph={simplified && sourceSnapshotStale ? null : graph}
memories={
memoryAuthority === "sdk"
? (sdkMap?.memories ?? [])
: simplified
? (mapLibrary.page?.items ?? [])
: memories
}
graph={
memoryAuthority === "sdk"
? (sdkMap?.graph ?? null)
: simplified && sourceSnapshotStale
? null
: graph
}
sources={sources}
processing={processing}
arrival={memoryArrival}
onViewHistory={simplified ? onConnect : () => setMode("history")}
loaded={simplified ? mapLibrary.page !== null : loaded}
loaded={
memoryAuthority === "sdk"
? sdkMap !== null
: simplified
? mapLibrary.page !== null
: loaded
}
detail={detail}
detailLoad={detailLoad}
overlayBlocked={
!active ||
deleteCount > 0 ||
(simplified && (!mapLibrary.page || sourceSnapshotStale))
(memoryAuthority === "sdk"
? sdkMap === null
: simplified && (!mapLibrary.page || sourceSnapshotStale))
}
draft={draft}
busy={busy || detailReadBlocked}
@@ -1,6 +1,6 @@
// @vitest-environment jsdom
import type { MemoryDetail } from "@one-memory/contracts";
import type { MemoryDetail, SourceResponse } from "@one-memory/contracts";
import {
act,
cleanup,
@@ -14,7 +14,7 @@ import { afterEach, beforeEach, expect, it, vi } from "vitest";
import { LANGUAGE_STORAGE_KEY, LanguageProvider } from "./gui-language";
import { SelectedMemoryDetail } from "./SelectedMemoryDetail";
it("localizes informational details and dates while preserving evidence and memory", async () => {
it("localizes memory details without exposing the retired evidence surface", async () => {
delete window.oneMemory;
localStorage.setItem(LANGUAGE_STORAGE_KEY, "es");
render(
@@ -29,9 +29,9 @@ it("localizes informational details and dates while preserving evidence and memo
);
expect(await screen.findByText("Guardada · Versión 2")).toBeTruthy();
expect(
screen.getByRole("heading", { name: "Evidencia original" }),
).toBeTruthy();
expect(screen.getByText("这次先确认范围。")).toBeTruthy();
screen.queryByRole("heading", { name: "Evidencia original" }),
).toBeNull();
expect(screen.queryByText("这次先确认范围。")).toBeNull();
expect(correct).not.toHaveBeenCalled();
expect(remove).not.toHaveBeenCalled();
localStorage.clear();
@@ -145,6 +145,72 @@ 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(
@@ -204,27 +270,7 @@ it("keeps draft after conflict until latest revision is explicitly acknowledged"
),
);
});
it("original page never substitutes unavailable evidence with stale text", () => {
const view = render(
<SelectedMemoryDetail detail={detail} sources={[]} onChanged={() => {}} />,
);
fireEvent.click(screen.getByRole("button", { name: "查看原文" }));
expect(screen.getAllByText("这次先确认范围。").length).toBeGreaterThan(0);
view.rerender(
<SelectedMemoryDetail
detail={{
...detail,
evidence: [{ ...detail.evidence[0], available: false }],
}}
sources={[]}
onChanged={() => {}}
/>,
);
expect(screen.queryByText("这次先确认范围。")).toBeNull();
expect(screen.getByText("来源权限已变化或原文不可用。")).toBeTruthy();
});
it.each(["编辑", "查看原文", "删除记忆"])(
it.each(["编辑", "删除记忆"])(
"returns focus to %s without scrolling the background",
(name) => {
render(
@@ -275,7 +321,6 @@ it.each(["escape", "cancel"])(
"value",
"尚未保存的草稿",
);
expect(screen.getByText("这次先确认范围。")).toBeTruthy();
expect(correct).not.toHaveBeenCalled();
confirm.mockReturnValue(true);
close();
@@ -535,7 +580,7 @@ it("suspends the editor without losing its draft or leaving the background inert
);
});
it("stamps today's source time the same way as 06A and 06E", () => {
it("stamps today's source and conversation time without an evidence page", () => {
render(
<SelectedMemoryDetail
detail={{
@@ -552,8 +597,6 @@ it("stamps today's source time the same way as 06A and 06E", () => {
/>,
);
expect(screen.getByText(/来源 workbuddy · 今天 /)).toBeTruthy();
expect(screen.getByText(/原对话 今天 /)).toBeTruthy();
fireEvent.click(screen.getByRole("button", { name: "查看原文" }));
expect(screen.getByRole("heading", { name: "原始对话" })).toBeTruthy();
expect(screen.getByText(/^workbuddy · 今天 /)).toBeTruthy();
expect(screen.getByText(/对话时间 今天 /)).toBeTruthy();
expect(screen.queryByRole("button", { name: "查看原文" })).toBeNull();
});
+60 -91
View File
@@ -1,4 +1,8 @@
import type { MemoryDetail, SourceResponse } from "@one-memory/contracts";
import type {
MemoryDetail,
SdkMemoryStatus,
SourceResponse,
} from "@one-memory/contracts";
import { useEffect, useRef, useState } from "react";
import { createPortal } from "react-dom";
import { apiErrorCode, correctMemory, getMemory, mutateMemory } from "./api";
@@ -10,7 +14,9 @@ import { traceUiAction } from "./ui-trace";
import { isDefiniteRefusal, writeTraceStatus } from "./write-outcome";
import "./selected-memory.css";
type Page = "read" | "edit" | "original" | "delete";
type Page = "read" | "edit" | "delete";
const HEADING_LIMIT = 72;
export function SelectedMemoryDetail({
detail,
@@ -50,8 +56,17 @@ export function SelectedMemoryDetail({
}, []);
const memory = detail.memory;
const evidence = detail.evidence[0];
const statusLabel =
memory.status === "confirmed"
const sdkStatusLabels: Record<SdkMemoryStatus, Parameters<typeof t>[0]> = {
active: "selectedMemory.status.active",
superseded: "selectedMemory.status.superseded",
shadow: "selectedMemory.status.shadow",
negated: "selectedMemory.status.negated",
conflicted: "selectedMemory.status.conflicted",
archived: "selectedMemory.status.archived",
};
const statusLabel = detail.sdk_status
? t(sdkStatusLabels[detail.sdk_status])
: memory.status === "confirmed"
? t("selectedMemory.status.confirmed")
: memory.status === "deleted"
? t("selectedMemory.status.deleted")
@@ -60,7 +75,7 @@ export function SelectedMemoryDetail({
: t("selectedMemory.status.unknown");
const source = sourceNameOr(
sources,
evidence?.source_id,
detail.source_id ?? evidence?.source_id,
t("selectedMemory.sourceUnknown"),
);
const dirty = page === "edit" && draft !== memory.content;
@@ -221,20 +236,22 @@ export function SelectedMemoryDetail({
if (alive.current) setBusy(false);
}
}
const author =
evidence?.written_by === "person" || evidence?.written_by === "user"
? t("selectedMemory.author.you")
: evidence?.written_by === "agent" || evidence?.written_by === "assistant"
? t("selectedMemory.author.assistant")
: t("selectedMemory.author.unknown");
const timestamp = clockStamp(evidence?.created_at, locale);
const split = memory.content.search(/[。\n]/);
// 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 heading =
split > 0 && split < 85
? memory.content.slice(0, split + 1)
: memory.content;
const body =
split > 0 && split < 85 ? memory.content.slice(split + 1).trim() : "";
split > 0 ? memory.content.slice(0, split).trim() : memory.content;
const body = split > 0 ? memory.content.slice(split).trim() : "";
return (
<>
<article
@@ -242,23 +259,37 @@ export function SelectedMemoryDetail({
data-hide-actions={hideActions ? "true" : "false"}
>
<p className="selected-memory-status">
{statusLabel} ·{" "}
{t("selectedMemory.version", {
revision: new Intl.NumberFormat(locale).format(memory.revision),
})}
{statusLabel}
{!detail.sdk_status &&
` · ${t("selectedMemory.version", {
revision: new Intl.NumberFormat(locale).format(memory.revision),
})}`}
</p>
<h3>{heading}</h3>
{body && <p className="selected-memory-body">{body}</p>}
<p className="selected-memory-source">
{t("selectedMemory.sourceLine", { source, time: timestamp })}
</p>
{detail.source_id || detail.evidence.length ? (
<p className="selected-memory-source">
{t("selectedMemory.sourceLine", {
source,
time: clockStamp(
detail.sdk_memory_at ?? evidence?.created_at,
locale,
),
})}
</p>
) : null}
<p className="selected-memory-times">
{t("selectedMemory.timestamps", {
conversation: clockStamp(
conversationAt?.at ?? evidence?.created_at,
conversationAt?.at ??
detail.sdk_memory_at ??
evidence?.created_at,
locale,
),
created: clockStamp(
detail.sdk_created_at ?? memory.updated_at,
locale,
),
created: clockStamp(memory.updated_at, locale),
})}
</p>
{!hideActions && (
@@ -276,27 +307,6 @@ export function SelectedMemoryDetail({
{t("selectedMemory.edit")}
</R6Button>
)}
<section className="selected-memory-evidence">
<h4>{t("selectedMemory.evidence")}</h4>
<small>
{t("selectedMemory.evidenceLine", { author, time: timestamp })}
</small>
<blockquote>
{evidence?.available
? evidence.verbatim
: t("selectedMemory.evidenceUnavailable")}
</blockquote>
{!hideActions && (
<R6Button
onClick={(event) => {
trigger.current = event.currentTarget;
setPage("original");
}}
>
{t("selectedMemory.viewOriginal")}
</R6Button>
)}
</section>
{!hideActions && (
<R6Button variant="quiet" onClick={() => setShowHistory((v) => !v)}>
{t("selectedMemory.history")}
@@ -345,9 +355,7 @@ export function SelectedMemoryDetail({
aria-label={
page === "edit"
? t("selectedMemory.editTitle")
: page === "original"
? t("selectedMemory.originalTitle")
: t("selectedMemory.delete")
: t("selectedMemory.delete")
}
onKeyDown={(event) => {
if (event.key === "Tab") {
@@ -380,13 +388,8 @@ export function SelectedMemoryDetail({
<h1 ref={focus} tabIndex={-1}>
{page === "edit"
? t("selectedMemory.editTitle")
: page === "original"
? t("selectedMemory.originalTitle")
: t("selectedMemory.delete")}
: t("selectedMemory.delete")}
</h1>
{page === "original" && (
<R6Button onClick={close}>{t("selectedMemory.back")}</R6Button>
)}
</header>
{page === "edit" ? (
<div className="selected-editor">
@@ -443,40 +446,6 @@ export function SelectedMemoryDetail({
</R6Button>
</div>
</div>
) : page === "original" ? (
<div className="selected-original">
<small>
{source} · {timestamp}
</small>
<p>{author}</p>
{detail.evidence.map((item) => (
<section key={item.evidence_id}>
<small>
{sourceNameOr(
sources,
item.source_id,
t("selectedMemory.sourceUnknown"),
)}{" "}
·{" "}
{item.written_by === "person"
? t("selectedMemory.author.user")
: item.written_by === "agent"
? t("selectedMemory.author.agent")
: t("selectedMemory.author.unknown")}
</small>
<blockquote>
{item.available
? item.verbatim
: t("selectedMemory.sourceUnavailable")}
</blockquote>
</section>
))}
<small>{t("selectedMemory.related")}</small>
<h2>{heading}</h2>
<R6Button onClick={close}>
{t("selectedMemory.viewMemory")}
</R6Button>
</div>
) : (
<div className="selected-delete">
<h2>{t("selectedMemory.deleteTitle")}</h2>
@@ -68,6 +68,8 @@ describe("paged memory library client", () => {
expect(
await getSdkMemoryLibrary({
statuses: ["active", "superseded"],
source: "cursor",
query: "release",
offset: 0,
limit: 25,
}),
@@ -76,5 +78,7 @@ describe("paged memory library client", () => {
const params = get.mock.calls[0][1].params as URLSearchParams;
expect(params.getAll("statuses")).toEqual(["active", "superseded"]);
expect(params.get("limit")).toBe("25");
expect(params.get("source")).toBe("cursor");
expect(params.get("query")).toBe("release");
});
});
+17
View File
@@ -891,10 +891,14 @@ export async function getMemoryLibrary(
export async function getSdkMemoryLibrary(
{
statuses = ["active"],
source,
query,
offset = 0,
limit = 100,
}: {
statuses?: SdkMemoryStatus[];
source?: string;
query?: string;
offset?: number;
limit?: number;
} = {},
@@ -905,6 +909,8 @@ export async function getSdkMemoryLibrary(
limit: String(limit),
});
for (const status of statuses) params.append("statuses", status);
if (source) params.set("source", source);
if (query?.trim()) params.set("query", query.trim());
const response = await client.get<SdkMemoryPage>("/api/v2/memory-library", {
params,
signal,
@@ -1162,6 +1168,17 @@ export async function getMemory(memoryId: string): Promise<MemoryDetail> {
*/
let pendingMemoryGraph: Promise<MemoryGraphResponse> | null = null;
/** The memory map on SDK authority: SDK memories plus the graph overlay. */
export async function getSdkMemoryGraph(
signal?: AbortSignal,
): Promise<MemoryGraphResponse> {
const response = await client.get<MemoryGraphResponse>(
"/api/v2/memories/graph",
{ params: { limit: 300 }, signal },
);
return response.data;
}
export async function getMemoryGraphWith(
transport: { get: typeof client.get } = client,
): Promise<MemoryGraphResponse> {
+114
View File
@@ -0,0 +1,114 @@
import type {
GovernedMemory,
MemoryDetail,
MemoryKind,
MemoryLibraryPage,
SdkMemoryPage,
SdkMemoryRecord,
} from "@one-memory/contracts";
function kindOf(layer: string): MemoryKind {
if (layer === "l6_schema") return "schema";
if (layer === "l7_intention") return "intention";
if (layer.startsWith("coding")) return "coding";
if (layer.startsWith("working")) return "working";
return "chat";
}
function stamp(seconds: number | null | undefined): string {
return typeof seconds === "number" && Number.isFinite(seconds)
? new Date(seconds * 1_000).toISOString()
: "";
}
/** An SDK memory in the shape the memory map draws.
*
* The map groups and labels by id, content, kind, status and tags only, so the
* governed-only fields are inert placeholders here. Opening, editing and
* deleting still go through the SDK (v2) endpoints — `sdkDetail()` below wraps
* the same projection for the map's inspector, which is why the inspector can
* stay where the design put it instead of sending the reader to another view.
*/
export function sdkMapMemory(record: SdkMemoryRecord): GovernedMemory {
const created = stamp(record.gmt_created ?? record.memory_at);
return {
memory_id: record.memory_id,
user_id: record.user_id,
kind: kindOf(record.layer),
status:
record.status === "active"
? "confirmed"
: record.status === "superseded"
? "superseded"
: "deleted",
content: record.content,
scopes: [],
evidence_ids: [],
origin: "extracted",
tags: [...record.tags],
supersedes: [...record.supersedes],
superseded_by: [...record.superseded_by],
// GovernedMemory still requires a revision for the shared map model. SDK
// has no revision contract; the inspector recognises sdk_status and never
// renders this placeholder.
revision: 0,
created_at: created,
updated_at: created,
deleted_at: null,
};
}
/** One SDK memory as the map's inspector reads it.
*
* The inspector was built for a governed detail. SDK-native memory has no
* product revision or "original evidence" section; source_raw_ids remain an
* internal SDK provenance relation and are deliberately not projected as UI
* evidence.
*/
export function sdkDetail(record: SdkMemoryRecord): MemoryDetail {
return {
memory: sdkMapMemory(record),
evidence: [],
history: [],
index_sync: null,
sdk_status: record.status,
source_id:
record.source && record.source !== "default" ? record.source : null,
sdk_memory_at: stamp(record.memory_at) || null,
sdk_created_at: stamp(record.gmt_created) || null,
};
}
/** One SDK page as the map's left list reads it.
*
* The list asks a library page for its rows, its total and — per row — when
* the cited conversation happened. The SDK page carries the row itself, so the
* time is taken from the memory: `memory_at` is the moment the conversation
* happened, `gmt_created` only the moment it was imported. Naming which one it
* is keeps "对话于" from silently becoming "沉淀于".
*/
export function sdkLibraryPage(page: SdkMemoryPage): MemoryLibraryPage {
const sourceTime: Record<
string,
{ at: string; basis: "conversation" | "import" }
> = {};
const sourceIds: Record<string, string[]> = {};
for (const record of page.memories) {
if (record.source && record.source !== "default")
sourceIds[record.memory_id] = [record.source];
const at = record.memory_at ?? record.gmt_created;
if (typeof at !== "number" || !Number.isFinite(at)) continue;
sourceTime[record.memory_id] = {
at: new Date(at * 1_000).toISOString(),
basis: record.memory_at ? "conversation" : "import",
};
}
return {
items: page.memories.map(sdkMapMemory),
total: page.total,
offset: page.offset,
limit: page.limit,
source_ids_by_memory: sourceIds,
source_time_by_memory: sourceTime,
};
}
+12 -72
View File
@@ -138,13 +138,19 @@
gap: 8px;
font-family: "Noto Sans SC", "PingFang SC", sans-serif;
}
.r6-shell .r6-memory .map-memory-item > span:not(.map-state) {
.r6-shell .r6-memory .map-memory-item > .map-memory-content {
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;
@@ -212,15 +218,19 @@
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;
@@ -237,36 +247,6 @@
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;
@@ -304,8 +284,7 @@
align-items: center;
gap: 24px;
}
.selected-memory-page > header .r6-button,
.selected-original > .r6-button {
.selected-memory-page > header .r6-button {
min-width: 150px;
}
.selected-memory-page h1 {
@@ -394,40 +373,6 @@
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;
@@ -499,10 +444,6 @@
.selected-delete > p {
margin: 12px 0;
}
.selected-original {
padding-top: 24px;
padding-bottom: 24px;
}
}
@media (max-width: 1250px) {
.r6-shell .view.r6-memory {
@@ -536,7 +477,6 @@
.selected-memory-page {
padding: 24px;
}
.selected-original,
.selected-delete {
padding-left: 0;
padding-right: 0;
+1 -1
View File
@@ -2,7 +2,7 @@
"name": "one-memory",
"version": "1.0.5",
"release": {
"build_number": 17,
"build_number": 24,
"macos_min_version": "13.0",
"app_update": "manual",
"runtime_manifest_schema": 1,
+5
View File
@@ -518,6 +518,11 @@ export interface MemoryDetail {
memory: GovernedMemory;
evidence: MemoryEvidenceExcerpt[];
history: MemoryVersion[];
/** SDK-native display metadata. Legacy governed details leave these absent. */
sdk_status?: SdkMemoryStatus;
source_id?: string | null;
sdk_memory_at?: string | null;
sdk_created_at?: string | null;
// Null for a memory nobody has rewritten: there is no index to be behind,
// and the interface should not explain a state the user never caused.
index_sync: MemoryIndexSync | null;
@@ -74,8 +74,13 @@ export const currentChineseMessages = {
"memorySpace.tabs.workbench": "工作台",
"selectedMemory.status.confirmed": "已沉淀",
"selectedMemory.status.active": "已生效",
"selectedMemory.status.deleted": "已删除",
"selectedMemory.status.superseded": "已被取代",
"selectedMemory.status.shadow": "已隐藏",
"selectedMemory.status.negated": "已否定",
"selectedMemory.status.conflicted": "有冲突",
"selectedMemory.status.archived": "已归档",
"selectedMemory.status.unknown": "状态待核对",
"selectedMemory.sourceUnknown": "来源待核对",
"selectedMemory.discardDraft": "放弃尚未保存的修改?",
@@ -208,8 +213,13 @@ export const currentEnglishMessages: Record<
"memorySpace.tabs.workbench": "Workbench",
"selectedMemory.status.confirmed": "Created",
"selectedMemory.status.active": "Active",
"selectedMemory.status.deleted": "Deleted",
"selectedMemory.status.superseded": "Superseded",
"selectedMemory.status.shadow": "Hidden",
"selectedMemory.status.negated": "Negated",
"selectedMemory.status.conflicted": "Conflicted",
"selectedMemory.status.archived": "Archived",
"selectedMemory.status.unknown": "Status needs review",
"selectedMemory.sourceUnknown": "Source needs review",
"selectedMemory.discardDraft": "Discard unsaved changes?",
@@ -356,8 +366,13 @@ export const currentSpanishMessages: Record<
"memorySpace.tabs.workbench": "Espacio de trabajo",
"selectedMemory.status.confirmed": "Guardada",
"selectedMemory.status.active": "Activa",
"selectedMemory.status.deleted": "Eliminada",
"selectedMemory.status.superseded": "Sustituida",
"selectedMemory.status.shadow": "Oculta",
"selectedMemory.status.negated": "Negada",
"selectedMemory.status.conflicted": "En conflicto",
"selectedMemory.status.archived": "Archivada",
"selectedMemory.status.unknown": "Estado pendiente de comprobar",
"selectedMemory.sourceUnknown": "Origen sin verificar",
"selectedMemory.discardDraft": "¿Descartar los cambios sin guardar?",
@@ -711,7 +711,7 @@ describe("local Runtime installation", () => {
expect(replacement.version_directory).not.toBe(firstPlan.version_directory);
});
it("refuses different bytes from the same commit even with the opt-in", async () => {
it("repairs different bytes from the same commit with the packaged opt-in", async () => {
if (process.platform !== "darwin") return;
const commit = "a".repeat(40);
const first = await distribution("1.0.0", {
@@ -738,18 +738,17 @@ describe("local Runtime installation", () => {
.digest("hex");
await writeFile(manifestPath, JSON.stringify(manifest));
await expect(
planLocalInstall({
source: rebuilt.source,
installRoot: first.installRoot,
platform: process.platform,
arch: process.arch,
allowSameBuildReplace: true,
}),
).rejects.toThrow("installed_version_conflict");
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);
});
it("refuses a tampered selected runtime instead of taking it over", async () => {
it("replaces a dirty selected runtime from the trusted packaged payload", async () => {
if (process.platform !== "darwin") return;
const first = await distribution("1.0.0", {
buildNumber: 5,
@@ -771,15 +770,15 @@ describe("local Runtime installation", () => {
gitCommit: "b".repeat(40),
});
await expect(
planLocalInstall({
source: rebuilt.source,
installRoot: first.installRoot,
platform: process.platform,
arch: process.arch,
allowSameBuildReplace: true,
}),
).rejects.toThrow("distribution_hash_mismatch");
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);
});
it("does not let the same-build opt-in weaken downgrade refusal", async () => {
@@ -805,6 +804,32 @@ 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,13 +367,23 @@ async function inspectBuildNumberTransition(options: {
if (relative.startsWith("..") || path.isAbsolute(relative)) {
throw new Error("install_current_unmanaged");
}
const selected = options.allowSameBuildReplace
? await verifyDistribution(
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(
target,
options.incoming.platform,
options.incoming.arch,
)
: await readManifest(target).catch(() => null);
);
} catch {
selectedVerified = false;
}
}
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
@@ -385,6 +395,9 @@ 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 {
@@ -395,11 +408,8 @@ async function inspectBuildNumberTransition(options: {
}
if (
!isReleaseCommit(selected.git_commit) ||
!isReleaseCommit(options.incoming.git_commit) ||
selected.git_commit === options.incoming.git_commit
!isReleaseCommit(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("refuses same-commit different bytes even when the bundle opts in", async () => {
it("repairs same-commit drift when the trusted bundle opts in", async () => {
const { root, resources, executable, installRoot } = await fixture();
const { selected } = await installFormalCurrent({
root,
@@ -434,18 +434,43 @@ describe("installPackagedRuntime", () => {
await writeFile(path.join(selected, "web", "index.html"), "unexpected");
await rewriteManifestDigest(selected, "web/index.html");
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");
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);
});
it.each(["missing"])(
@@ -483,6 +508,31 @@ 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, 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
* 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
* than replacing the runtime. The superseded tree is kept, not edited. */
replaceSameBuild?: boolean;
gitCommit?: string;
@@ -307,11 +307,36 @@ async function reuseSelectedSameBuild(options: {
) {
throw new Error("install_current_unmanaged");
}
const selectedManifest = await verifyInstalledDistribution(
selected,
options.platform,
options.arch,
);
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;
}
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("treats a finished cleanup as a completed migration", async () => {
it("reports a repair of an already migrated home as complete, not a second cutover", async () => {
const result = await runSdkMigration({
dataDir: "/data",
core,
@@ -95,7 +95,7 @@ it("treats a finished cleanup as a completed migration", async () => {
fence: noFence,
});
expect(result).toEqual({ state: "cutover" });
expect(result).toEqual({ state: "complete" });
});
it("reports an unreadable migration status as deferred", async () => {
@@ -4,7 +4,8 @@ import { withRuntimeMaintenanceFence } from "./runtime-maintenance.js";
const MAX_OUTPUT_BYTES = 1024 * 1024;
export interface SdkMigrationResult {
state: "not_required" | "cutover" | "deferred";
/** `complete`: already on SDK authority; leftovers were repaired. */
state: "not_required" | "cutover" | "complete" | "deferred";
code?: string;
}
@@ -84,8 +85,7 @@ export async function runSdkMigration(
}
if (migrated.exitCode !== 0) return deferred(migrated);
const state = migrated.document.state;
if (state === "cutover" || state === "complete")
return { state: "cutover" };
if (state === "cutover" || state === "complete") return { state };
if (state === "not_required") return { state: "not_required" };
return deferred(migrated);
});