Compare commits

..
Author SHA1 Message Date
alvinfeiandClaude Opus 5.5 2fed7adf6b fix(memhub): set the SDK context recall lanes apart
POST /api/v2/context/search passed the caller's `limit` as the normal,
schema and proactive intention top-k alike, so limit=5 ran three full
recall lanes including the intention lane the SDK keeps off by default.
The v2 request now takes `limit` (default 10), `schema_limit` (default
3) and `intention_limit` (default 1) and passes each through. The host
broker's default item cap follows v2's default of 10.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-10-26 15:48:48 +08:00
alvinfeiandClaude Opus 5.5 7308ed9df2 fix(memhub): search the memory list on submit, over 20 results
- The map's memory search no longer queries on every keystroke: typing
  edits the words and the search runs on the 搜索 button or Enter.
  Submitting the words already applied re-reads the page.
- Core asks SDK search for a 20-memory relevance window instead of 500;
  the larger window made a single search slow enough to freeze the page.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-10-26 15:27:20 +08:00
feitianxiang 1c0cbf6d05 Merge pull request #176 from Tencent-Hunyuan/fix/memory-page-sdk-consistency
fix(memhub): make the SDK memory page one consistent page
2026-10-26 09:10:22 +08:00
alvinfeiandClaude Opus 5.5 71854a3510 fix(memhub): make the SDK memory page one consistent page
Root cause: the page read its rows from an unfiltered limit=300 window
while its count, source labels and filter came from the paged library,
and the cutover backfill (import_exact) had written every legacy memory
with source "default" and session "default_session".

- Web: on SDK authority the list, count, source/time labels and graph
  all come from one library page and the graph for the same source and
  window, read together and polled together. "default" is a named,
  filterable 未识别来源 bucket; impossible conversation times are not
  shown as when a conversation happened; detail timestamps carry their
  year; empty states tell "no match" from "empty source"; a finished
  memory note re-reads the page at once.
- Core: library search keeps the window's rows that contain the query
  words (the Chroma store has no keyword channel), reports when the
  window was full, and the graph endpoint takes the list's source and
  offset and no longer chains "default_session" memories together.
- Repair v2 links SDK memories to legacy evidence through
  governed_memory_evidence and write-job ids across every pre-cutover
  bundle, and settles remaining future memory_at values to gmt_created.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-10-25 23:23:33 +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
44 changed files with 2433 additions and 395 deletions

No files matched your search

+22
View File
@@ -4,6 +4,28 @@ Hy Memory and MemHub are versioned and released independently.
## Unreleased
- The SDK memory page now reads its list, count, source and time labels and
graph from one page response for one source filter, so rows, totals and the
map no longer disagree. Memories with no recorded source are shown and
filterable as "unidentified source"; library search keeps only memories
that contain the words within the SDK's relevance window.
- Metadata repair v2 follows `governed_memory_evidence` across every
pre-cutover bundle, which restores the source of memories the cutover
backfilled without one, and moves impossible future conversation times back
to the write time.
- Reused the existing three-column memory map for SDK-authority list/detail
views and removed the retired "original evidence" surface. SDK-native status,
source, conversation time and creation time now render without fabricated
revisions; source and text filters are applied by the paged Core endpoint.
- SDK memory search now calls the SDK search contract instead of listing the
entire library in Core. Corrupt empty records are skipped and diagnosed
without mutating data or failing a page, and post-cutover metadata repair
restores source and original times from the verified migration bundle.
- Kept Cursor's WAL change token separate from the file mtime so a hash can no
longer appear as a conversation date centuries in the future.
- A trusted packaged Runtime now repairs a dirty same-build installation by
staging a clean replacement, and `runtime_service_not_ready` is rendered as
user-facing startup guidance instead of an internal code.
- Fixed legacy-to-SDK startup migration to adopt native Coding Experience and
Working Fact IDs instead of importing them as Chat layers. Raw migration now
rebuilds bounded user/assistant turns from the latest three days, excludes
+2
View File
@@ -15,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 正文权威。
+129 -9
View File
@@ -103,7 +103,9 @@ from one_memory.hy_sdk_types import (
HySdkMemoryRecord,
HySdkRawPage,
HySdkRawRecord,
HySdkReadError,
HySdkUserProfile,
library_keyword_hits,
parse_sdk_search,
)
from one_memory.imports.access import (
@@ -879,6 +881,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 +3082,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(
@@ -3115,19 +3140,81 @@ def create_app(
)
async def sdk_memory_library(
statuses: list[str] = Query(default=["active"]), # noqa: B008
source: str | None = Query(default=None, min_length=1),
query: str | None = Query(default=None, max_length=512),
offset: int = Query(default=0, ge=0),
limit: int = Query(default=100, ge=1, le=500),
) -> HySdkMemoryPage:
try:
client = await _sdk_client_handle(app).client()
search_query = (query or "").strip()
if search_query:
if set(statuses) != {"active"}:
raise HTTPException(
status_code=422,
detail="sdk_search_supports_active_status_only",
)
# SDK search has no count-only contract and, on the local store,
# no keyword channel. Ask for one bounded relevance window with
# no score floor, then keep the rows that contain the words, so
# a word in the library is found and a word in none of it finds
# nothing. The whole library is never listed in Core.
# A larger window made one search take long enough to freeze
# the page; 20 is the bounded window the library searches.
fetch = 20
search_raw = await asyncio.to_thread(
client.search,
search_query,
user_ids=["local"],
agent_ids=[],
include_sources=[source] if source else None,
limit=fetch,
min_score=0.0,
schema_min_score=0.0,
schema_limit=fetch,
# The library lists stored memories; proactive intentions
# are a recall-time lane the list never shows.
intention_limit=0,
scene="normal",
reader=HY_MEMORY_READER,
)
hit_ids, window = library_keyword_hits(search_raw, search_query)
rows = await _get_sdk_rows(client, list(hit_ids[offset : offset + limit]))
memories: list[HySdkMemoryRecord] = []
skipped = 0
for row in rows:
if row is None:
continue
try:
record = HySdkMemoryRecord.from_sdk(row)
except HySdkReadError:
skipped += 1
continue
if record.status == "active":
memories.append(record)
_log_skipped_sdk_memories(skipped)
return HySdkMemoryPage(
memories=tuple(memories),
total=len(hit_ids),
limit=limit,
offset=offset,
search_window=fetch if window >= fetch else None,
)
raw = await asyncio.to_thread(
client.list_memories,
user_id="local",
statuses=list(statuses),
include_sources=[source] if source else None,
offset=offset,
limit=limit,
)
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
@@ -3138,6 +3225,8 @@ def create_app(
)
async def sdk_memory_graph(
limit: int = Query(default=160, ge=1, le=300),
offset: int = Query(default=0, ge=0),
source: str | None = Query(default=None, min_length=1),
) -> MemoryGraphResponse:
"""The memory map on SDK authority: SDK memories plus the Kuzu overlay.
@@ -3147,10 +3236,20 @@ def create_app(
"""
try:
client = await _sdk_client_handle(app).client()
# The map draws the page the list shows, so it reads the same
# source filter and window rather than the newest memories overall.
raw = await asyncio.to_thread(
client.list_memories, user_id="local", limit=limit, offset=0, order="desc"
client.list_memories,
user_id="local",
include_sources=[source] if source else None,
limit=limit,
offset=offset,
order="desc",
)
page = HySdkMemoryPage.from_sdk(raw, limit=limit, offset=0)
page, skipped = HySdkMemoryPage.from_sdk_skipping_invalid(
raw, limit=limit, offset=offset
)
_log_skipped_sdk_memories(skipped)
except Exception as error:
raise _sdk_http_error(error, "hy_memory_list_failed") from None
vdb = raw.get("vdb") if isinstance(raw, dict) else None
@@ -3170,17 +3269,18 @@ def create_app(
kind=memory.layer,
status=memory.status,
)
if memory.session_id:
# The SDK fills in "default_session" when a writer gave none (the
# cutover backfill did so for every legacy memory); it names no
# conversation, so it must not chain those memories together.
if memory.session_id and memory.session_id != "default_session":
sessions.setdefault(memory.session_id, []).append(memory_node_id)
if memory.source:
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
),
label=(registration.descriptor.display_name if registration else memory.source),
source_id=memory.source,
)
edges.append(
@@ -5558,6 +5658,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,
@@ -47,6 +47,19 @@ class ContextSearchRequest(BaseModel):
return self
class SdkContextSearchRequest(ContextSearchRequest):
"""`POST /api/v2/context/search`: the three SDK recall lanes are set apart.
`limit` is the normal lane's top-k and the cap on returned items;
`schema_limit` and `intention_limit` are the SDK's schema (L6) and
proactive intention (L7) lanes. A caller's values are passed through.
"""
limit: int = Field(default=10, ge=1, le=20, strict=True)
schema_limit: int = Field(default=3, ge=0, le=20, strict=True)
intention_limit: int = Field(default=1, ge=0, le=20, strict=True)
class ContextMessage(BaseModel):
model_config = ConfigDict(frozen=True, extra="forbid")
@@ -34,6 +34,7 @@ from one_memory.context_models import (
MemoryContextItem,
RawContextItem,
SdkContextItem,
SdkContextSearchRequest,
SdkContextSearchResponse,
SdkMemoryContextItem,
SdkRawContextItem,
@@ -439,7 +440,7 @@ def sdk_context_router(dependencies: Sequence[Depends]) -> APIRouter:
@router.post("/search", response_model=SdkContextSearchResponse)
async def search(
value: ContextSearchRequest,
value: SdkContextSearchRequest,
request: Request,
response: Response,
) -> SdkContextSearchResponse:
@@ -514,8 +515,8 @@ def sdk_context_router(dependencies: Sequence[Depends]) -> APIRouter:
limit=value.limit,
min_score=sdk_relevance_floor(),
schema_min_score=sdk_relevance_floor(),
schema_limit=value.limit,
intention_limit=value.limit,
schema_limit=value.schema_limit,
intention_limit=value.intention_limit,
scene="normal",
reader=HY_MEMORY_READER,
)
@@ -1,13 +1,20 @@
"""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, field
from datetime import UTC, datetime, timedelta
from pathlib import Path
from typing import Any
from hy_memory import ( # type: ignore[import-untyped]
DataPlaneRestore,
@@ -16,6 +23,28 @@ from hy_memory import ( # type: ignore[import-untyped]
snapshot_local_data_plane,
)
from one_memory.database_access import main_database
from one_memory.hy_runtime_config import HY_MEMORY_NAMESPACE
# v2 reads the backfill's governed_memory_evidence links; v1 missed them.
_METADATA_REPAIR_VERSION = 2
_BATCH = 500
@dataclass(frozen=True)
class RepairMetadata:
source: str | None
memory_at: int | None
created_at: int | None
@dataclass(frozen=True)
class MetadataRepairResult:
migration_id: str | None
mapped: int
vector_rows: int
coding_rows: int
def snapshot_data_plane(
*,
@@ -45,3 +74,400 @@ def restore_data_plane(
writers_stopped=writers_stopped,
protective_root=protective_root,
)
def repair_sdk_metadata_from_bundle(
*,
data_dir: Path,
database_path: Path,
now: datetime | None = None,
) -> MetadataRepairResult:
"""Restore metadata lost by historical cutovers from their verified bundles."""
migration_id = _sdk_migration_id(database_path)
if migration_id is None:
return MetadataRepairResult(None, 0, 0, 0)
receipt = data_dir / f".sdk-metadata-repair-v{_METADATA_REPAIR_VERSION}-{migration_id}.json"
if receipt.is_file():
document = json.loads(receipt.read_text(encoding="utf-8"))
return MetadataRepairResult(
migration_id,
int(document.get("mapped", 0)),
int(document.get("vector_rows", 0)),
int(document.get("coding_rows", 0)),
)
# A home can carry several cutovers (v1 and then v2). A later bundle may
# have been taken after an earlier cutover had already retired the legacy
# tables, so every bundle is read and their links are pooled.
legacy = sorted(
(data_dir / "backups").glob("*-pre-cutover.sdk-bundle/management/one-memory.sqlite3")
)
if not legacy:
# No bundle yet is not a finished repair: try again on the next start.
return MetadataRepairResult(migration_id, 0, 0, 0)
current = now or datetime.now(UTC)
pools: dict[str, _Pool] = {}
for path in legacy:
_merge_pools(pools, _legacy_pools(path, now=current))
mapping = _decide(pools)
vector_rows = _repair_vector(data_dir, mapping, now=current)
coding_rows = _repair_coding(data_dir, mapping)
result = MetadataRepairResult(
migration_id,
len(mapping),
vector_rows,
coding_rows,
)
temporary = receipt.with_suffix(".tmp")
temporary.write_text(
json.dumps(
{
"schema_version": 1,
"migration_id": migration_id,
"mapped": result.mapped,
"vector_rows": vector_rows,
"coding_rows": coding_rows,
},
sort_keys=True,
)
+ "\n",
encoding="utf-8",
)
temporary.replace(receipt)
return result
def _sdk_migration_id(database_path: Path) -> str | None:
with main_database(database_path).read(row_factory=sqlite3.Row) as database:
row = database.execute(
"SELECT authority,migration_id FROM memory_authority_state WHERE singleton=1"
).fetchone()
if row is None or row["authority"] != "sdk":
return None
value = row["migration_id"]
return str(value) if isinstance(value, str) and value else None
@dataclass
class _Pool:
"""Everything the legacy library knew about one SDK memory."""
sources: set[str] = field(default_factory=set)
times: list[datetime] = field(default_factory=list)
created: list[datetime] = field(default_factory=list)
def _legacy_metadata(path: Path, *, now: datetime) -> dict[str, RepairMetadata]:
return _decide(_legacy_pools(path, now=now))
def _legacy_pools(path: Path, *, now: datetime) -> dict[str, _Pool]:
"""Link each SDK memory id to the legacy evidence it was made from.
Three legacy paths wrote memories the SDK now holds:
- the cutover backfill (`import_exact`) copied every governed memory into the
SDK under its legacy id, or under the id `memory_task_outputs` recorded,
without a source or a conversation time; `governed_memory_evidence` is the
link from that memory to its evidence;
- extraction tasks recorded their evidence as `support_refs`;
- the write queue recorded the SDK ids each job produced.
"""
with closing(sqlite3.connect(f"file:{path}?mode=ro", uri=True)) as database:
database.row_factory = sqlite3.Row
tables = {
str(row[0])
for row in database.execute("SELECT name FROM sqlite_master WHERE type='table'")
}
if "evidence" not in tables:
return {}
links: dict[str, set[str]] = {}
created: dict[str, list[datetime]] = {}
canonical: dict[str, str] = {}
if "memory_task_outputs" in tables:
for row in database.execute("SELECT body FROM memory_task_outputs"):
try:
body = json.loads(str(row["body"]))
except (TypeError, ValueError):
continue
legacy_id = body.get("memory_id")
canonical_id = body.get("hy_memory_id")
refs = body.get("support_refs")
if not isinstance(legacy_id, str) or not isinstance(canonical_id, str):
continue
canonical[legacy_id] = canonical_id
links.setdefault(canonical_id, set()).update(
str(ref["evidence_id"])
for ref in (refs if isinstance(refs, list) else ())
if isinstance(ref, dict) and isinstance(ref.get("evidence_id"), str)
)
if "governed_memories" in tables:
for row in database.execute(
"SELECT memory_id,created_at,updated_at FROM governed_memories"
):
legacy_id = str(row["memory_id"])
canonical_id = canonical.get(legacy_id, legacy_id)
links.setdefault(canonical_id, set())
legacy_created = _plausible_time(
row["created_at"], fallback=row["updated_at"], now=now
)
if legacy_created is not None:
created.setdefault(canonical_id, []).append(legacy_created)
if "governed_memory_evidence" in tables:
for row in database.execute(
"SELECT memory_id,evidence_id FROM governed_memory_evidence"
):
legacy_id = str(row["memory_id"])
links.setdefault(canonical.get(legacy_id, legacy_id), set()).add(
str(row["evidence_id"])
)
if "memory_write_jobs" in tables and "sdk_memory_ids_json" in _columns(
database, "memory_write_jobs"
):
job_evidence: dict[str, set[str]] = {}
if "memory_write_job_evidence" in tables:
for row in database.execute(
"SELECT job_id,evidence_id FROM memory_write_job_evidence"
):
job_evidence.setdefault(str(row["job_id"]), set()).add(str(row["evidence_id"]))
for row in database.execute(
"SELECT job_id,sdk_memory_ids_json FROM memory_write_jobs "
"WHERE sdk_memory_ids_json IS NOT NULL"
):
try:
memory_ids = json.loads(str(row["sdk_memory_ids_json"]))
except (TypeError, ValueError):
continue
for memory_id in memory_ids if isinstance(memory_ids, list) else ():
if isinstance(memory_id, str) and memory_id:
links.setdefault(memory_id, set()).update(
job_evidence.get(str(row["job_id"]), ())
)
evidence: dict[str, tuple[str, datetime | None]] = {}
wanted = sorted({identifier for refs in links.values() for identifier in refs})
for batch in _batches(wanted):
placeholders = ",".join("?" for _ in batch)
for row in database.execute(
"SELECT evidence_id,source_id,created_at,imported_at "
f"FROM evidence WHERE evidence_id IN ({placeholders})",
batch,
):
evidence[str(row["evidence_id"])] = (
str(row["source_id"]),
_plausible_time(row["created_at"], fallback=row["imported_at"], now=now),
)
pools: dict[str, _Pool] = {}
for canonical_id, refs in links.items():
pool = pools.setdefault(canonical_id, _Pool())
for identifier in refs:
if identifier not in evidence:
continue
source, timestamp = evidence[identifier]
if source and source != "default":
pool.sources.add(source)
if timestamp is not None:
pool.times.append(timestamp)
pool.created.extend(created.get(canonical_id, ()))
return pools
def _merge_pools(into: dict[str, _Pool], other: dict[str, _Pool]) -> None:
for canonical_id, pool in other.items():
merged = into.setdefault(canonical_id, _Pool())
merged.sources |= pool.sources
merged.times.extend(pool.times)
merged.created.extend(pool.created)
def _decide(pools: dict[str, _Pool]) -> dict[str, RepairMetadata]:
# Several legacy memories can fold into one SDK memory. Their evidence is
# pooled before deciding, so a disagreeing source stays unknown instead of
# whichever link happened to come last.
return {
canonical_id: RepairMetadata(
source=next(iter(pool.sources)) if len(pool.sources) == 1 else None,
memory_at=int(max(pool.times).timestamp()) if pool.times else None,
created_at=int(min(pool.created).timestamp()) if pool.created else None,
)
for canonical_id, pool in pools.items()
}
def _columns(database: sqlite3.Connection, table: str) -> set[str]:
return {str(row[1]) for row in database.execute(f"PRAGMA table_info({table})")}
def _plausible_time(
value: object,
*,
fallback: object,
now: datetime,
) -> datetime | None:
for candidate in (value, fallback):
if not isinstance(candidate, str) or not candidate:
continue
try:
parsed = datetime.fromisoformat(candidate)
except ValueError:
continue
if parsed.tzinfo is None:
parsed = parsed.replace(tzinfo=UTC)
parsed = parsed.astimezone(UTC)
if parsed <= now + timedelta(days=1):
return parsed
return None
def _repair_vector(
data_dir: Path,
mapping: dict[str, RepairMetadata],
*,
now: datetime,
) -> int:
directory = data_dir / "hy-memory" / HY_MEMORY_NAMESPACE / "vector"
if not (directory / "chroma.sqlite3").is_file():
return 0
import chromadb
from chromadb.config import Settings
client = chromadb.PersistentClient(
path=str(directory),
settings=Settings(anonymized_telemetry=False),
)
# The SDK client is not open yet (startup has not created its handle), so
# this is the only writer; release the store before the SDK takes it.
try:
repaired = 0
ids = tuple(mapping)
for collection in client.list_collections():
for batch in _batches(ids):
page = collection.get(ids=list(batch), include=["metadatas"])
updated_ids: list[str] = []
updated_metadata: list[dict[str, object]] = []
for memory_id, metadata in zip(
page.get("ids") or (),
page.get("metadatas") or (),
strict=True,
):
if not isinstance(metadata, dict):
continue
desired = mapping.get(str(memory_id))
if desired is None:
continue
next_metadata = dict(metadata)
changed = False
if desired.source and next_metadata.get("source") in {
None,
"",
"default",
}:
next_metadata["source"] = desired.source
changed = True
if (
desired.memory_at is not None
and next_metadata.get("memory_at") != desired.memory_at
):
next_metadata["memory_at"] = desired.memory_at
changed = True
if (
desired.created_at is not None
and next_metadata.get("gmt_created") != desired.created_at
):
next_metadata["gmt_created"] = desired.created_at
changed = True
if changed:
updated_ids.append(str(memory_id))
updated_metadata.append(next_metadata)
if updated_ids:
collection.update(ids=updated_ids, metadatas=updated_metadata)
repaired += len(updated_ids)
repaired += _settle_future_times(collection, now=now)
finally:
client.close()
return repaired
def _settle_future_times(collection: Any, *, now: datetime) -> int:
"""Move conversation times that are still in the future back to the write time.
A historical Cursor bug stored a file hash as the message time (years like
2254). The SDK lists newest first by `memory_at`, so those rows hold the
first page. Rows the bundles could date were fixed above; for the rest the
write time is the closest true moment that is known.
"""
cutoff = int((now + timedelta(days=1)).timestamp())
page = collection.get(where={"memory_at": {"$gt": cutoff}}, include=["metadatas"])
updated_ids: list[str] = []
updated_metadata: list[dict[str, object]] = []
for memory_id, metadata in zip(page.get("ids") or (), page.get("metadatas") or (), strict=True):
if not isinstance(metadata, dict):
continue
created = metadata.get("gmt_created")
settled = (
created
if isinstance(created, int) and not isinstance(created, bool) and created <= cutoff
else int(now.timestamp())
)
updated_ids.append(str(memory_id))
updated_metadata.append({**metadata, "memory_at": settled})
if updated_ids:
collection.update(ids=updated_ids, metadatas=updated_metadata)
return len(updated_ids)
def _repair_coding(
data_dir: Path,
mapping: dict[str, RepairMetadata],
) -> int:
root = data_dir / "hy-memory" / HY_MEMORY_NAMESPACE
repaired = 0
for name in ("coding.sqlite3", "coding_cv.sqlite3"):
path = root / name
if not path.is_file():
continue
with closing(sqlite3.connect(path)) as database:
tables = {
str(row[0])
for row in database.execute("SELECT name FROM sqlite_master WHERE type='table'")
}
for table, identifier in (
("coding_memory_meta", "experience_id"),
("working_fact", "fact_id"),
):
if table not in tables:
continue
rows = database.execute(
f"SELECT {identifier},source,created_at FROM {table}"
).fetchall()
for memory_id, source, created_at in rows:
desired = mapping.get(str(memory_id))
if desired is None:
continue
updates: list[str] = []
values: list[object] = []
if desired.source and source in {None, "", "default"}:
updates.append("source=?")
values.append(desired.source)
if desired.created_at is not None:
desired_created_at = (
datetime.fromtimestamp(desired.created_at, tz=UTC)
.replace(tzinfo=None)
.isoformat()
)
if created_at != desired_created_at:
updates.append("created_at=?")
values.append(desired_created_at)
if not updates:
continue
values.append(memory_id)
database.execute(
f"UPDATE {table} SET {','.join(updates)} WHERE {identifier}=?",
values,
)
repaired += 1
database.commit()
return repaired
def _batches[T](values: list[T] | tuple[T, ...]) -> Iterator[tuple[T, ...]]:
for start in range(0, len(values), _BATCH):
yield tuple(values[start : start + _BATCH])
@@ -92,9 +92,35 @@ class HySdkMemoryPage(BaseModel):
total: int = Field(ge=0)
limit: int = Field(ge=1)
offset: int = Field(ge=0)
# Set on a search whose relevance window was full: matches were looked for
# only among this many most relevant memories, so `total` may undercount.
search_window: int | None = None
@classmethod
def from_sdk(cls, raw: object, *, limit: int, offset: int) -> HySdkMemoryPage:
page, _skipped = cls._parse(raw, limit=limit, offset=offset, skip_invalid=False)
return page
@classmethod
def from_sdk_skipping_invalid(
cls, raw: object, *, limit: int, offset: int
) -> tuple[HySdkMemoryPage, int]:
"""Parse a page, leaving out rows outside the public record contract.
Returns the page and how many rows were left out, so the caller can
report them. The rows themselves are never touched.
"""
return cls._parse(raw, limit=limit, offset=offset, skip_invalid=True)
@classmethod
def _parse(
cls,
raw: object,
*,
limit: int,
offset: int,
skip_invalid: bool,
) -> tuple[HySdkMemoryPage, int]:
if not isinstance(raw, Mapping):
raise HySdkReadError("hy_memory_list_shape_unknown")
rows: list[object] = []
@@ -115,15 +141,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):
@@ -289,6 +327,38 @@ def parse_sdk_search(raw: object, *, error_code: str) -> tuple[SemanticMemoryHit
return tuple(found)
def library_keyword_hits(raw: object, query: str) -> tuple[tuple[str, ...], int]:
"""Ids in an SDK search window whose text contains every query term.
The local SDK store has no keyword channel (Chroma's keyword search is
empty), so search is pure vector similarity: a word in the library can
score below the floor, and a word in none of it still scores above. The
library search asks the SDK for its most relevant window and keeps only the
rows that actually contain the words, most relevant first. Returns those ids
and how many distinct rows the window held.
"""
rows = _reply_rows(raw, "hy_memory_search_shape_unknown")
terms = query.casefold().split()
seen: set[str] = set()
matched: list[str] = []
for item in sorted(rows, key=_score, reverse=True):
memory_id = item.get("memory_id") or item.get("id")
if not isinstance(memory_id, str) or not memory_id or memory_id in seen:
continue
seen.add(memory_id)
content = item.get("content")
tags = item.get("tags")
text = " ".join(
[
content if isinstance(content, str) else "",
*(tag for tag in (tags if isinstance(tags, list) else ()) if isinstance(tag, str)),
]
).casefold()
if all(term in text for term in terms):
matched.append(memory_id)
return tuple(matched), len(seen)
def parse_sdk_raw_search(raw: object) -> tuple[HySdkRawHit, ...]:
return tuple(
HySdkRawHit.from_sdk_search(row)
@@ -50,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]:
@@ -92,6 +92,15 @@ ALLOWED = {
"the memory ids a migrated home must still read back",
opened_with="?mode=ro",
),
("hy_sdk_migration.py", "_legacy_pools"): Allowed(
"each pre-cutover bundle's copy of the legacy database, read-only, to "
"recover the source and times historical cutovers dropped",
opened_with="?mode=ro",
),
("hy_sdk_migration.py", "_repair_coding"): Allowed(
"the SDK's own Coding/Working stores, not the main database: the one-time "
"metadata repair writes them at startup, before the SDK client opens"
),
("sdk_vector_reattach.py", "_stored_names"): Allowed(
"Chroma's own chroma.sqlite3, read-only, to list collection names before "
"the offline migration opens Chroma",
+213
View File
@@ -278,3 +278,216 @@ def test_sdk_user_profile_http_returns_native_assembled_profile(tmp_path):
}
asyncio.run(scenario())
@pytest.mark.fresh_sdk
def test_sdk_memory_library_uses_sdk_search_and_skips_empty_rows_without_mutation(
tmp_path,
caplog,
):
async def scenario():
app = application(tmp_path)
list_calls = []
search_calls = []
deleted = []
records = {
"release": {
"memory_id": "release",
"content": "Release plan",
"layer": "l2_fact",
"status": "active",
"user_id": "local",
"agent_id": "all-apps",
"session_id": "s1",
"source": "cursor",
"tags": ["shipping"],
},
"retired": {
"memory_id": "retired",
"content": "Release plan, first draft",
"layer": "l2_fact",
"status": "superseded",
"user_id": "local",
"agent_id": "all-apps",
"session_id": "s1",
"source": "cursor",
"tags": [],
},
"default": {
"memory_id": "default",
"content": "Unknown source",
"layer": "l2_fact",
"status": "active",
"user_id": "local",
"agent_id": "all-apps",
"session_id": "s3",
"source": "default",
"tags": [],
},
}
class Client:
def list_memories(self, **kwargs):
list_calls.append(kwargs)
rows = [
records["release"],
records["default"],
{
"memory_id": "empty",
"content": "",
"layer": "l2_fact",
"status": "active",
"user_id": "local",
"agent_id": "all-apps",
"session_id": "s2",
"source": "workbuddy",
"tags": [],
},
]
include_sources = kwargs.get("include_sources") or []
if include_sources:
rows = [row for row in rows if row["source"] in include_sources]
return {
"vdb": {
"memories": rows,
"total": len(rows),
"limit": kwargs["limit"],
"offset": kwargs["offset"],
}
}
def search(self, query, **kwargs):
search_calls.append((query, kwargs))
return {
"memories": {
"schema": [],
"proactive": [],
"normal": [
{
"memory_id": "release",
"content": "Release plan",
"score": 0.9,
},
{
"memory_id": "retired",
"content": "Release plan, first draft",
"score": 0.8,
},
],
}
}
def get(self, memory_id):
return records.get(memory_id)
def delete(self, memory_id):
deleted.append(memory_id)
return {"success": True, "deleted_count": 1}
sdk = Client()
class Handle:
async def client(self):
return sdk
async def close(self):
return None
async with app.router.lifespan_context(app):
app.state.sdk_client_handle = Handle()
async with client_for(app) as client:
response = await client.get(
"/api/v2/memory-library",
params={"source": "cursor", "query": "release"},
)
assert response.status_code == 200
body = response.json()
# `total` counts the window's keyword matches; the superseded
# hit matches but is not listed as an active memory.
assert body["total"] == 2
assert body["search_window"] is None
assert [item["memory_id"] for item in body["memories"]] == ["release"]
assert search_calls[0][0] == "release"
assert search_calls[0][1]["include_sources"] == ["cursor"]
assert search_calls[0][1]["limit"] == 20
assert search_calls[0][1]["intention_limit"] == 0
assert search_calls[0][1]["min_score"] == 0.0
assert list_calls == []
# The SDK still returns its nearest rows for a word that is in
# none of them; the library reports no match instead.
unmatched = await client.get(
"/api/v2/memory-library", params={"query": "zzz nowhere"}
)
assert unmatched.json()["total"] == 0
assert unmatched.json()["memories"] == []
listed = await client.get(
"/api/v2/memory-library",
params={"source": "workbuddy", "limit": 100},
)
assert listed.status_code == 200
assert listed.json()["memories"] == []
assert listed.json()["total"] == 1
assert deleted == []
assert list_calls[0]["limit"] == 100
assert "sdk_memory_invalid_rows_skipped count=1" in caplog.messages
graph = await client.get("/api/v2/memories/graph")
assert graph.status_code == 200
node_ids = {node["node_id"] for node in graph.json()["nodes"]}
assert "source:cursor" in node_ids
assert "source:default" not in node_ids
asyncio.run(scenario())
@pytest.mark.fresh_sdk
def test_sdk_context_search_sets_each_recall_lane_apart(tmp_path):
"""Normal, schema and intention lanes default to 10 / 3 / 1 and pass through."""
async def scenario():
app = application(tmp_path)
calls = []
class Client:
def search(self, query, **kwargs):
calls.append(kwargs)
return {"memories": {"schema": [], "proactive": [], "normal": []}}
def get(self, memory_id):
return None
sdk = Client()
class Handle:
async def client(self):
return sdk
async def close(self):
return None
async with app.router.lifespan_context(app):
app.state.sdk_client_handle = Handle()
async with client_for(app) as client:
defaulted = await client.post(
"/api/v2/context/search",
json={"query": "release", "kinds": ["memory"]},
)
chosen = await client.post(
"/api/v2/context/search",
json={
"query": "release",
"kinds": ["memory"],
"limit": 5,
"schema_limit": 0,
"intention_limit": 2,
},
)
assert defaulted.status_code == 200
assert chosen.status_code == 200
assert [
(call["limit"], call["schema_limit"], call["intention_limit"]) for call in calls
] == [(10, 3, 1), (5, 0, 2)]
asyncio.run(scenario())
@@ -136,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,260 @@ def _start(store, migration_id="m1"):
)
def test_post_cutover_metadata_repair_restores_source_and_original_times(store):
data_dir = store.database_path.parent
migration_id = "sdk-authority-v2-source-repair"
with main_database(store.database_path).write() as database, database:
database.execute(
"UPDATE memory_authority_state SET authority='sdk',migration_id=? WHERE singleton=1",
(migration_id,),
)
legacy = (
data_dir
/ "backups"
/ f"{migration_id}-pre-cutover.sdk-bundle"
/ "management"
/ "one-memory.sqlite3"
)
legacy.parent.mkdir(parents=True)
with sqlite3.connect(legacy) as database:
database.executescript(
"""
CREATE TABLE memory_task_outputs (body TEXT);
CREATE TABLE governed_memories (
memory_id TEXT, created_at TEXT, updated_at TEXT
);
CREATE TABLE evidence (
evidence_id TEXT, source_id TEXT, created_at TEXT, imported_at TEXT
);
"""
)
database.execute(
"INSERT INTO governed_memories VALUES (?,?,?)",
("legacy-1", "2026-10-18T00:00:00+00:00", "2026-10-18T01:00:00+00:00"),
)
database.execute(
"INSERT INTO evidence VALUES (?,?,?,?)",
("e1", "workbuddy", "2254-01-01T00:00:00+00:00", "2026-10-17T00:00:00+00:00"),
)
database.execute(
"INSERT INTO memory_task_outputs VALUES (?)",
(
json.dumps(
{
"memory_id": "legacy-1",
"hy_memory_id": "sdk-1",
"support_refs": [{"evidence_id": "e1"}],
}
),
),
)
import chromadb
from chromadb.config import Settings as ChromaSettings
vector = data_dir / "hy-memory" / "governed-v2" / "vector"
collection = chromadb.PersistentClient(
path=str(vector),
settings=ChromaSettings(anonymized_telemetry=False),
).get_or_create_collection("memory")
collection.add(
ids=["sdk-1"],
documents=["remember"],
embeddings=[[0.1, 0.2]],
metadatas=[
{
"source": "default",
"gmt_created": 1,
"memory_at": 8_962_051_200,
}
],
)
coding = data_dir / "hy-memory" / "governed-v2" / "coding_cv.sqlite3"
with sqlite3.connect(coding) as database:
database.execute(
"CREATE TABLE coding_memory_meta ("
"experience_id TEXT,user_id TEXT,agent_id TEXT,solution TEXT,"
"source TEXT,created_at TEXT,updated_at TEXT)"
)
database.execute(
"CREATE TABLE working_fact ("
"fact_id TEXT,user_id TEXT,agent_id TEXT,statement TEXT,"
"source TEXT,created_at TEXT)"
)
database.execute(
"INSERT INTO coding_memory_meta VALUES "
"('sdk-1','local','scope','remember','default','now','now')"
)
result = repair_sdk_metadata_from_bundle(
data_dir=data_dir,
database_path=store.database_path,
now=datetime(2026, 10, 25, tzinfo=UTC),
)
metadata = collection.get(ids=["sdk-1"], include=["metadatas"])["metadatas"][0]
assert result.vector_rows == 1
assert result.coding_rows == 1
assert metadata["source"] == "workbuddy"
assert datetime.fromtimestamp(metadata["memory_at"], tz=UTC) == datetime(
2026, 10, 17, tzinfo=UTC
)
assert datetime.fromtimestamp(metadata["gmt_created"], tz=UTC) == datetime(
2026, 10, 18, tzinfo=UTC
)
with sqlite3.connect(coding) as database:
source, created_at = database.execute(
"SELECT source,created_at FROM coding_memory_meta WHERE experience_id='sdk-1'"
).fetchone()
assert source == "workbuddy"
assert created_at.startswith("2026-10-18T00:00:00")
repeated = repair_sdk_metadata_from_bundle(
data_dir=data_dir,
database_path=store.database_path,
now=datetime(2026, 10, 25, tzinfo=UTC),
)
assert repeated == result
def test_metadata_repair_reaches_memories_the_cutover_backfilled_under_legacy_ids(store):
"""The backfill wrote governed memories with no source; their evidence links them."""
data_dir = store.database_path.parent
migration_id = "sdk-authority-v2-backfill"
with main_database(store.database_path).write() as database, database:
database.execute(
"UPDATE memory_authority_state SET authority='sdk',migration_id=? WHERE singleton=1",
(migration_id,),
)
# A v1 receipt from the earlier, narrower repair must not block this one.
(data_dir / f".sdk-metadata-repair-v1-{migration_id}.json").write_text("{}\n")
# The later bundle was taken after legacy tables were retired; the earlier
# cutover's bundle still carries them.
for bundle, populated in (("sdk-authority-v1-old", True), (migration_id, False)):
legacy = data_dir / "backups" / f"{bundle}-pre-cutover.sdk-bundle" / "management"
legacy.mkdir(parents=True)
with closing(sqlite3.connect(legacy / "one-memory.sqlite3")) as database, database:
database.executescript(
"""
CREATE TABLE evidence (
evidence_id TEXT, source_id TEXT, created_at TEXT, imported_at TEXT
);
"""
)
if not populated:
continue
database.executescript(
"""
CREATE TABLE governed_memories (
memory_id TEXT, created_at TEXT, updated_at TEXT
);
CREATE TABLE governed_memory_evidence (
memory_id TEXT, evidence_id TEXT, active INTEGER
);
"""
)
database.execute(
"INSERT INTO governed_memories VALUES ('legacy-7','2026-09-02T00:00:00+00:00',NULL)"
)
database.execute("INSERT INTO governed_memory_evidence VALUES ('legacy-7','e7',1)")
database.execute(
"INSERT INTO evidence VALUES ('e7','workbuddy','2026-09-01T00:00:00+00:00',NULL)"
)
import chromadb
from chromadb.config import Settings as ChromaSettings
vector = data_dir / "hy-memory" / "governed-v2" / "vector"
client = chromadb.PersistentClient(
path=str(vector), settings=ChromaSettings(anonymized_telemetry=False)
)
collection = client.get_or_create_collection("memory")
imported = int(datetime(2026, 10, 20, tzinfo=UTC).timestamp())
collection.add(
ids=["legacy-7", "unlinked"],
documents=["backfilled", "hash-dated"],
embeddings=[[0.1, 0.2], [0.2, 0.1]],
metadatas=[
{"source": "default", "gmt_created": imported, "memory_at": imported},
{"source": "cursor", "gmt_created": imported, "memory_at": 8_962_051_200},
],
)
client.close()
result = repair_sdk_metadata_from_bundle(
data_dir=data_dir,
database_path=store.database_path,
now=datetime(2026, 10, 25, tzinfo=UTC),
)
client = chromadb.PersistentClient(
path=str(vector), settings=ChromaSettings(anonymized_telemetry=False)
)
rows = client.get_collection("memory").get(ids=["legacy-7", "unlinked"], include=["metadatas"])
client.close()
metadata = dict(zip(rows["ids"], rows["metadatas"], strict=True))
assert result.vector_rows == 2
assert metadata["legacy-7"]["source"] == "workbuddy"
assert metadata["legacy-7"]["memory_at"] == int(datetime(2026, 9, 1, tzinfo=UTC).timestamp())
assert metadata["legacy-7"]["gmt_created"] == int(datetime(2026, 9, 2, tzinfo=UTC).timestamp())
assert metadata["unlinked"]["source"] == "cursor"
assert metadata["unlinked"]["memory_at"] == imported
def test_metadata_repair_keeps_source_unknown_when_outputs_disagree(tmp_path):
"""Two legacy memories folded into one SDK memory pool their evidence."""
from one_memory.hy_sdk_migration import _legacy_metadata
legacy = tmp_path / "one-memory.sqlite3"
with closing(sqlite3.connect(legacy)) as database, database:
database.executescript(
"""
CREATE TABLE memory_task_outputs (body TEXT);
CREATE TABLE governed_memories (
memory_id TEXT, created_at TEXT, updated_at TEXT
);
CREATE TABLE evidence (
evidence_id TEXT, source_id TEXT, created_at TEXT, imported_at TEXT
);
"""
)
database.executemany(
"INSERT INTO governed_memories VALUES (?,?,?)",
[
("legacy-1", "2026-10-18T00:00:00+00:00", None),
("legacy-2", "2026-10-12T00:00:00+00:00", None),
],
)
database.executemany(
"INSERT INTO evidence VALUES (?,?,?,NULL)",
[
("e1", "workbuddy", "2026-10-17T00:00:00+00:00"),
("e2", "cursor", "2026-10-11T00:00:00+00:00"),
],
)
database.executemany(
"INSERT INTO memory_task_outputs VALUES (?)",
[
(
json.dumps(
{
"memory_id": legacy_id,
"hy_memory_id": "sdk-1",
"support_refs": [{"evidence_id": evidence_id}],
}
),
)
for legacy_id, evidence_id in (("legacy-1", "e1"), ("legacy-2", "e2"))
],
)
repaired = _legacy_metadata(legacy, now=datetime(2026, 10, 25, tzinfo=UTC))["sdk-1"]
assert repaired.source is None
assert repaired.memory_at == int(datetime(2026, 10, 17, tzinfo=UTC).timestamp())
assert repaired.created_at == int(datetime(2026, 10, 12, tzinfo=UTC).timestamp())
def _insert_evidence(store, evidence_id, *, conversation="session-1", role="user", seq=1):
verbatim = f"text of {evidence_id}"
with main_database(store.database_path).write() as database, database:
@@ -887,10 +1144,24 @@ def test_the_sdk_memory_map_draws_sdk_memories_without_legacy_tables(tmp_path) -
"source": "cursor",
}
for memory_id in ("a", "b")
] + [
{
"memory_id": memory_id,
"content": f"backfilled {memory_id}",
"layer": "l2_fact",
"status": "active",
"user_id": "local",
"agent_id": "one-memory",
"session_id": "default_session",
"source": "default",
}
for memory_id in ("c", "d")
]
listed = []
class Client:
def list_memories(self, **_kwargs):
def list_memories(self, **kwargs):
listed.append(kwargs)
return {"vdb": {"memories": rows, "total": 7}}
class Handle:
@@ -926,19 +1197,32 @@ def test_the_sdk_memory_map_draws_sdk_memories_without_legacy_tables(tmp_path) -
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")
response = await client.get(
"/api/v2/memories/graph",
params={"source": "cursor", "offset": 100, "limit": 100},
)
app.state.sdk_client_handle = original
assert response.status_code == 200
return response.json()
graph = asyncio.run(scenario())
assert (graph["total_memories"], graph["projected_memories"]) == (7, 2)
assert listed[0]["include_sources"] == ["cursor"]
assert (listed[0]["offset"], listed[0]["limit"]) == (100, 100)
assert (graph["total_memories"], graph["projected_memories"]) == (7, 4)
assert {node["node_id"] for node in graph["nodes"]} == {
"memory:a",
"memory:b",
"memory:c",
"memory:d",
"source:cursor",
"hy:t1",
}
# "default_session" is the SDK's filler, not a conversation.
assert [
(edge["source_node_id"], edge["target_node_id"])
for edge in graph["edges"]
if edge["relation"] == "same_conversation"
] == [("memory:a", "memory:b")]
assert {edge["relation"] for edge in graph["edges"]} == {
"evidence_source",
"same_conversation",
@@ -313,6 +313,15 @@ 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", () => {
@@ -141,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 {
@@ -230,6 +231,8 @@ export const STABLE_RUNTIME_USER_MESSAGES: Record<string, string> = {
"升级过程返回了无法识别的内容,已停止。原有记忆数据不会丢失;请重新启动重试,如果仍然失败,请把这个提示反馈给我们。",
sdk_migration_incomplete:
"记忆数据格式升级没有完成,MemHub 暂时无法打开。请重新启动重试;如果仍然失败,请把这个提示反馈给我们,原有记忆数据不会丢失。",
runtime_service_not_ready:
"记忆服务仍在首次启动,请稍候后重试。你的记忆数据不会受到影响。",
desktop_start_failed:
"MemHub 启动失败。请重新启动;如果仍然打不开,请重新安装,你的记忆数据不会受到影响。",
};
+108 -10
View File
@@ -177,12 +177,13 @@ 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();
});
it("OBS-19: Enter in the memory filter re-reads, exactly like the refresh button", async () => {
it("OBS-19: Enter in the memory filter submits it; submitting it again re-reads", async () => {
const onRetry = vi.fn();
const onQuery = vi.fn();
const page = {
items: memories,
total: memories.length,
@@ -200,7 +201,7 @@ it("OBS-19: Enter in the memory filter re-reads, exactly like the refresh button
loading: false,
failed: false,
stale: false,
onQuery: vi.fn(),
onQuery,
onSource: vi.fn(),
resetPage: vi.fn(),
onPage: vi.fn(),
@@ -211,11 +212,39 @@ it("OBS-19: Enter in the memory filter re-reads, exactly like the refresh button
await act(async () => {});
await userEvent.click(screen.getByRole("button", { name: "刷新地图记忆" }));
expect(onRetry).toHaveBeenCalledTimes(1);
// Typing alone does not search; Enter submits the words.
const input = screen.getByRole("textbox", { name: "筛选地图记忆" });
await userEvent.type(input, "第一");
expect(onQuery).not.toHaveBeenCalled();
await userEvent.type(input, "{Enter}");
expect(onQuery).toHaveBeenCalledWith("第一");
// Submitting the words already applied re-reads, like the refresh button.
await userEvent.clear(input);
await userEvent.type(input, "{Enter}");
expect(onRetry).toHaveBeenCalledTimes(2);
});
it("searches only when 搜索 is pressed, not on every keystroke", async () => {
const onQuery = vi.fn();
const resetPage = vi.fn();
render(
<Host
simplified
libraryView={{
...pagedView({ items: memories, total: 2, offset: 0, limit: 100 }),
onQuery,
resetPage,
}}
/>,
);
await userEvent.type(
screen.getByRole("textbox", { name: "筛选地图记忆" }),
"第一{Enter}",
"版本号",
);
expect(onRetry).toHaveBeenCalledTimes(2);
expect(onQuery).not.toHaveBeenCalled();
await userEvent.click(screen.getByRole("button", { name: "搜索" }));
expect(onQuery).toHaveBeenCalledWith("版本号");
expect(resetPage).toHaveBeenCalled();
});
it("OBS-15: switching memory shows a read in progress instead of the previous memory", async () => {
@@ -224,11 +253,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 +276,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 () => {
@@ -501,6 +540,65 @@ it("D96: lists a memory by when its conversation happened, not when it was gener
).toBeTruthy();
});
it("names the SDK's no-source bucket 未识别来源, apart from a source under review", () => {
const items = ["有来源", "无来源"].map((content, i) => ({
...memories[0],
memory_id: `s${i}`,
content,
})) as GovernedMemory[];
render(
<Host
simplified
items={items}
sources={[
{
source_id: "codex",
display_name: "Codex",
authorized: true,
imported_messages: 1,
imported_conversations: 1,
} as SourceResponse,
]}
libraryView={{
...pagedView({
items,
total: 2,
offset: 0,
limit: 100,
source_ids_by_memory: { s0: ["codex"], s1: ["default"] },
search_window: 500,
}),
query: "版本",
unattributedSource: "default",
}}
/>,
);
const list = screen.getByRole("list", { name: "地图记忆列表" });
expect(
within(list).getByRole("button", { name: /有来源/ }).textContent,
).toContain("Codex");
const unattributed = within(list).getByRole("button", { name: /无来源/ });
expect(unattributed.textContent).toContain("未识别来源");
expect(unattributed.textContent).not.toContain("待核对");
expect(screen.getByText("只在最相关的 500 条记忆里查找关键词")).toBeTruthy();
});
it("says a filtered source is empty rather than that nothing matched", () => {
render(
<Host
simplified
items={[]}
libraryView={{
...pagedView({ items: [], total: 0, offset: 0, limit: 100 }),
source: "default",
unattributedSource: "default",
}}
/>,
);
expect(screen.getByText("这个来源下还没有记忆")).toBeTruthy();
expect(screen.queryByText("没有匹配的记忆")).toBeNull();
});
it("D96: with conversations imported and no memory yet, says how many were imported", () => {
render(
<Host
+117 -27
View File
@@ -63,6 +63,8 @@ type Props = Parameters<typeof Workbench>[0] & {
loading: boolean;
failed: boolean;
stale: boolean;
/** The source id the SDK uses for "no source given" (shown as 未识别来源). */
unattributedSource?: string;
onQuery(value: string): void;
onSource(value: string): void;
resetPage(): void;
@@ -168,18 +170,40 @@ export function MemoryMap(props: Props) {
} = props;
const library = props.libraryView;
const serverPaged = library !== undefined;
const unattributedLabel = text("未识别来源");
const graph = useMemo(
() =>
serverPaged
? projectPagedSourceMap(baseGraph, library?.page ?? null, sources)
? projectPagedSourceMap(
baseGraph,
library?.page ?? null,
sources,
library?.unattributedSource
? { [library.unattributedSource]: unattributedLabel }
: undefined,
)
: baseGraph,
[baseGraph, serverPaged, library?.page, sources],
[
baseGraph,
serverPaged,
library?.page,
library?.unattributedSource,
sources,
unattributedLabel,
],
);
const refreshing = !!library?.page && library.loading;
const overlaysSuspended = hidden || !!props.overlayBlocked;
const [axis, setAxis] = useState<MapAxis>("source");
const [groupKey, setGroupKey] = useState("");
const [search, setSearch] = useState("");
// On a server-paged list, typing only edits the words; the search runs when
// the reader submits it (the 搜索 button or Enter), not on every keystroke.
const [searchDraft, setSearchDraft] = useState(library?.query ?? "");
const submittedQuery = library?.query;
useEffect(() => {
if (submittedQuery !== undefined) setSearchDraft(submittedQuery);
}, [submittedQuery]);
const [selectedId, setSelectedId] = useState<string | null>(null);
const [groupNode, setGroupNode] = useState<MemoryGraphNode | null>(null);
const [question, setQuestion] = useState("");
@@ -338,7 +362,11 @@ export function MemoryMap(props: Props) {
const ids = library.page?.source_ids_by_memory?.[memoryId];
return ids?.length
? ids
.map((id) => sourceNameOr(sources, id, text("来源待核对")))
.map((id) =>
id === library.unattributedSource
? text("未识别来源")
: sourceNameOr(sources, id, text("来源待核对")),
)
.join("、")
: text("来源待核对");
}
@@ -556,6 +584,8 @@ export function MemoryMap(props: Props) {
setGroupNode(null);
if (library) {
clear();
// A source picked on the map sets this too; keep both on the one choice.
setGroupKey(key);
library.onSource(key.startsWith("source:") ? key.slice(7) : "");
library.resetPage();
return;
@@ -611,6 +641,10 @@ export function MemoryMap(props: Props) {
(source) =>
source.authorized && groupKey === `source:${source.source_id}`,
)?.display_name ??
(library?.unattributedSource &&
groupKey === `source:${library.unattributedSource}`
? text("未识别来源")
: null) ??
text("全部记忆");
if (
props.simplified &&
@@ -740,6 +774,7 @@ export function MemoryMap(props: Props) {
options={[
{ value: "", label: text("全部应用") },
...(library?.source &&
library.source !== library.unattributedSource &&
!sources.some(
(source) =>
source.source_id === library.source && source.authorized,
@@ -752,12 +787,22 @@ export function MemoryMap(props: Props) {
]
: []),
...(library
? sources
.filter((source) => source.authorized)
.map((source) => ({
value: `source:${source.source_id}`,
label: source.display_name,
}))
? [
...sources
.filter((source) => source.authorized)
.map((source) => ({
value: `source:${source.source_id}`,
label: source.display_name,
})),
...(library.unattributedSource
? [
{
value: `source:${library.unattributedSource}`,
label: text("未识别来源"),
},
]
: []),
]
: groups.map((group) => ({
value: group.key,
label: group.label,
@@ -789,7 +834,23 @@ export function MemoryMap(props: Props) {
))}
</fieldset>
)}
<div className="map-search">
<form
className="map-search"
role="search"
onSubmit={(e) => {
e.preventDefault();
if (!library || libraryPane === "dialogues") return;
const words = searchDraft.trim();
setFilterNotice("");
clear();
if (words === library.query.trim()) {
library.onRetry();
return;
}
library.onQuery(words);
library.resetPage();
}}
>
<Search size={16} aria-hidden="true" />
<input
aria-label={text("筛选地图记忆")}
@@ -805,31 +866,39 @@ export function MemoryMap(props: Props) {
value={
libraryPane === "dialogues"
? dialogueFilter
: (library?.query ?? search)
: library
? searchDraft
: search
}
maxLength={library ? 512 : undefined}
onKeyDown={(e) => {
// The refresh button next to this list re-reads the page;
// Enter has to do the same thing, not sit there doing nothing.
if (e.key !== "Enter" || e.nativeEvent.isComposing) return;
e.preventDefault();
library?.onRetry();
// Enter that confirms an IME candidate is not a search.
if (e.key === "Enter" && e.nativeEvent.isComposing)
e.preventDefault();
}}
onChange={(e) => {
if (libraryPane === "dialogues") {
setDialogueFilter(e.target.value);
return;
}
if (library) {
setSearchDraft(e.target.value);
return;
}
setFilterNotice("");
setSearch(e.target.value);
if (library) {
clear();
library.onQuery(e.target.value);
library.resetPage();
}
}}
/>
</div>
{library && libraryPane === "memories" ? (
<button
type="submit"
className="map-search-submit"
disabled={library.loading || hidden}
>
{text("搜索")}
</button>
) : null}
</form>
{library && libraryPane === "memories" && (
<div className="map-list-heading" aria-live="polite">
<span>
@@ -988,7 +1057,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 ? (
<>
@@ -1029,11 +1107,23 @@ export function MemoryMap(props: Props) {
!library.stale &&
!filtered.length && (
<p className="map-empty">
{library.query || library.source
{library.query
? text("没有匹配的记忆")
: groupKey
? text("本页没有匹配的主题记忆")
: text("还没有已生成的记忆")}
: library.source
? text("这个来源下还没有记忆")
: groupKey
? text("本页没有匹配的主题记忆")
: text("还没有已生成的记忆")}
</p>
)}
{libraryPane === "memories" &&
library?.page?.search_window &&
!library.loading &&
!library.failed && (
<p className="map-empty">
{text("只在最相关的 {count} 条记忆里查找关键词", {
count: count(library.page.search_window),
})}
</p>
)}
{libraryPane === "memories" &&
@@ -218,7 +218,7 @@ it("default map search finds an existing 501st memory rather than declaring no m
fireEvent.change(screen.getByLabelText("筛选地图记忆"), {
target: { value: "第501条独有目标" },
});
fireEvent.keyDown(screen.getByLabelText("筛选地图记忆"), { key: "Enter" });
fireEvent.submit(screen.getByRole("search"));
await waitFor(() =>
expect(
within(screen.getByRole("list", { name: "地图记忆列表" })).queryByText(
@@ -310,6 +310,7 @@ it("OBS-13: the settled-count headline names the filter it is counting", async (
fireEvent.change(screen.getByLabelText("筛选地图记忆"), {
target: { value: "第501条独有目标" },
});
fireEvent.submit(screen.getByRole("search"));
await waitFor(() =>
expect(
screen.getByText(
@@ -320,6 +321,7 @@ it("OBS-13: the settled-count headline names the filter it is counting", async (
fireEvent.change(screen.getByLabelText("筛选地图记忆"), {
target: { value: "" },
});
fireEvent.submit(screen.getByRole("search"));
await chooseSource("codex");
await waitFor(() =>
expect(
@@ -473,6 +475,7 @@ it("distinguishes failed search from empty, then retries the same query", async
fireEvent.change(screen.getByLabelText("筛选地图记忆"), {
target: { value: "目标" },
});
fireEvent.submit(screen.getByRole("search"));
await screen.findByText("记忆读取失败,请重试。");
expect(screen.queryByText("没有匹配的记忆")).toBeNull();
expect(screen.queryByText("还没有已生成的记忆")).toBeNull();
@@ -490,6 +493,7 @@ it("distinguishes failed search from empty, then retries the same query", async
fireEvent.change(screen.getByLabelText("筛选地图记忆"), {
target: { value: "确实没有这项" },
});
fireEvent.submit(screen.getByRole("search"));
await screen.findByText("没有匹配的记忆");
expect(screen.queryByText("还没有已生成的记忆")).toBeNull();
expect(
@@ -510,6 +514,7 @@ it("aborts an older query after a source change and never replaces the newer res
fireEvent.change(screen.getByLabelText("筛选地图记忆"), {
target: { value: "旧搜索" },
});
fireEvent.submit(screen.getByRole("search"));
await waitFor(() => expect(resolve).toHaveLength(1));
const signals = vi
.mocked(api.getMemoryLibrary)
@@ -521,6 +526,7 @@ it("aborts an older query after a source change and never replaces the newer res
fireEvent.change(screen.getByLabelText("筛选地图记忆"), {
target: { value: "目标" },
});
fireEvent.submit(screen.getByRole("search"));
await within(screen.getByRole("list", { name: "地图记忆列表" })).findByText(
"第501条独有目标",
);
@@ -553,6 +559,7 @@ it("hidden map aborts page reads and does not accept late body or poll until rea
fireEvent.change(screen.getByLabelText("筛选地图记忆"), {
target: { value: "迟到" },
});
fireEvent.submit(screen.getByRole("search"));
await act(async () => {});
const signals = vi
.mocked(api.getMemoryLibrary)
@@ -611,6 +618,7 @@ it("keeps the populated map while a search is in flight and does not treat it as
fireEvent.change(screen.getByLabelText("筛选地图记忆"), {
target: { value: "onememory" },
});
fireEvent.submit(screen.getByRole("search"));
await act(async () => {});
expect(screen.queryByText("正在读取…")).toBeNull();
expect(screen.getByText("已沉淀 501 条 · 当前展示 100 条")).toBeTruthy();
@@ -700,6 +708,7 @@ it("explicit refresh keeps the search scope and replaces the page with the lates
fireEvent.change(screen.getByLabelText("筛选地图记忆"), {
target: { value: "目标" },
});
fireEvent.submit(screen.getByRole("search"));
const list = screen.getByRole("list", { name: "地图记忆列表" });
await within(list).findByText("第501条独有目标");
vi.mocked(api.getMemoryLibrary).mockResolvedValueOnce({
@@ -21,6 +21,7 @@ const api = vi.hoisted(() => ({
legacyGraph: vi.fn(),
sdkLibrary: vi.fn(),
sdkGraph: vi.fn(),
sdkMemory: vi.fn(),
}));
vi.mock("./api", () => ({
getMemoryAuthority: api.authority,
@@ -29,6 +30,9 @@ vi.mock("./api", () => ({
getMemoryGraph: api.legacyGraph,
getSdkMemoryLibrary: api.sdkLibrary,
getSdkMemoryGraph: api.sdkGraph,
getSdkMemory: api.sdkMemory,
updateSdkMemory: vi.fn(),
deleteSdkMemory: vi.fn(),
getMemoryProcessing: async () => null,
getModelProfiles: async () => [],
apiErrorCode: () => null,
@@ -42,12 +46,19 @@ vi.mock("./MemoryMap", () => ({
graph,
loaded,
onOpenMemory,
libraryView,
}: {
hidden: boolean;
memories: GovernedMemory[];
graph: MemoryGraphResponse | null;
loaded: boolean;
onOpenMemory(id: string): void;
libraryView?: {
page: { source_ids_by_memory?: Record<string, string[]> } | null;
unattributedSource?: string;
onSource(value: string): void;
resetPage(): void;
};
}) => (
<div hidden={hidden} data-testid="map">
<span data-testid="map-memories">
@@ -55,9 +66,30 @@ vi.mock("./MemoryMap", () => ({
</span>
<span data-testid="map-nodes">{graph?.nodes.length ?? "none"}</span>
<span data-testid="map-loaded">{String(loaded)}</span>
<span data-testid="map-sources">
{memories
.map((m) =>
(libraryView?.page?.source_ids_by_memory?.[m.memory_id] ?? []).join(
"+",
),
)
.join(",")}
</span>
<span data-testid="map-unattributed">
{libraryView?.unattributedSource ?? ""}
</span>
<button type="button" onClick={() => onOpenMemory("a")}>
open a
</button>
<button
type="button"
onClick={() => {
libraryView?.onSource("workbuddy");
libraryView?.resetPage();
}}
>
pick workbuddy
</button>
</div>
),
}));
@@ -133,15 +165,64 @@ it("draws the map from SDK memories on SDK authority, without legacy reads", asy
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 },
{
statuses: ["active"],
source: undefined,
query: undefined,
offset: 0,
limit: 100,
},
expect.any(AbortSignal),
);
expect(api.sdkGraph).toHaveBeenCalledWith(
{ source: undefined, offset: 0, limit: 100 },
expect.any(AbortSignal),
);
expect(api.legacyLibrary).not.toHaveBeenCalled();
expect(api.legacyGraph).not.toHaveBeenCalled();
});
it("opens a memory from the SDK map in the SDK library", async () => {
it("keeps rows, sources, count and graph on the one filtered page", async () => {
sdkAuthority();
render(<MemorySpace simplified active />);
await waitFor(() =>
expect(screen.getByTestId("map-loaded").textContent).toBe("true"),
);
api.sdkLibrary.mockResolvedValue({
memories: [
{ ...record("w1"), source: "workbuddy" },
{ ...record("w2"), source: "default" },
],
total: 566,
limit: 100,
offset: 0,
});
fireEvent.click(screen.getByText("pick workbuddy"));
await waitFor(() =>
expect(screen.getByTestId("map-memories").textContent).toBe(
"w1:confirmed,w2:confirmed",
),
);
expect(screen.getByTestId("map-sources").textContent).toBe(
"workbuddy,default",
);
expect(screen.getByTestId("map-unattributed").textContent).toBe("default");
expect(api.sdkLibrary).toHaveBeenLastCalledWith(
expect.objectContaining({ source: "workbuddy", offset: 0 }),
expect.any(AbortSignal),
);
expect(api.sdkGraph).toHaveBeenLastCalledWith(
{ source: "workbuddy", offset: 0, limit: 100 },
expect.any(AbortSignal),
);
expect(await screen.findByText(/566/)).toBeTruthy();
});
it("opens an SDK memory in place instead of switching away from the map", async () => {
sdkAuthority();
api.sdkMemory.mockResolvedValue(record("a"));
render(<MemorySpace simplified active />);
await waitFor(() =>
@@ -149,10 +230,10 @@ it("opens a memory from the SDK map in the SDK library", async () => {
);
fireEvent.click(screen.getByText("open a"));
const library = await screen.findByTestId("sdk-library");
expect(library.textContent).toBe("a");
expect(library.closest("[hidden]")).toBeNull();
expect(screen.getByTestId("map").hidden).toBe(true);
await waitFor(() => expect(api.sdkMemory).toHaveBeenCalledWith("a"));
// The inspector stays on the map; the reader is never moved to a second list.
expect(screen.getByTestId("map").hidden).toBe(false);
expect(screen.getByTestId("sdk-library").textContent).toBe("");
});
it("keeps asking for the authority until Core answers", async () => {
+210 -67
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,6 +41,7 @@ import {
getMemoryGraph,
getMemoryProcessing,
getModelProfiles,
getSdkMemory,
getSdkMemoryGraph,
getSdkMemoryLibrary,
manualExtract,
@@ -47,6 +50,7 @@ import {
saveMemoryNote,
setExtractionWindow,
syncMemoryIndex,
updateSdkMemory,
} from "./api";
import { ConversationSearch } from "./ConversationSearch";
import { clockStamp } from "./clock-stamp";
@@ -58,8 +62,8 @@ import { subscribePoll } from "./poll-scheduler";
import { R6Button } from "./R6Controls";
import { SdkMemoryLibrary } from "./SdkMemoryLibrary";
import { SdkRawHistory } from "./SdkRawHistory";
import { sdkMapMemory } from "./sdk-memory-map";
import { sourceNameOr } from "./source-label";
import { sdkDetail, sdkLibraryPage } from "./sdk-memory-map";
import { sourceNameOr, UNATTRIBUTED_SOURCE_ID } from "./source-label";
import { traceUiAction } from "./ui-trace";
import { useMemoryLibrary } from "./use-memory-library";
import { isDefiniteRefusal, writeTraceStatus } from "./write-outcome";
@@ -296,16 +300,36 @@ export function MemorySpace({
text: string;
memories: AskMemory[];
} | null>(null);
const [sdkFocusedMemoryId, setSdkFocusedMemoryId] = 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);
// The SDK library is no longer where a memory opens from the map; the id is
// kept so the panel still has a focus target for its own tests.
const [sdkFocusedMemoryId] = useState<string | null>(null);
// The map on SDK authority. Legacy reads (library page, governed graph) are
// refused there. Like the governed map, the list, its count, its source and
// time labels and the graph are all one page: the library page and the graph
// for that same source and window, read together. Reading the graph from a
// different, unfiltered window made rows, sources and totals disagree.
const [sdkGraph, setSdkGraph] = useState<MemoryGraphResponse | null>(null);
// A write on SDK authority has no governed refresh to ride on; bumping this
// re-reads the SDK page and graph after an edit or a delete.
const [sdkMapRevision, setSdkMapRevision] = useState(0);
const bumpSdkMap = useCallback(() => setSdkMapRevision((v) => v + 1), []);
const [sdkLibrary, setSdkLibrary] = useState<{
page: MemoryLibraryPage | null;
loading: boolean;
failed: boolean;
/** The filter the shown page was read with, for the count above it. */
settledSource: string;
settledQuery: string;
}>({
page: null,
loading: false,
failed: false,
settledSource: "",
settledQuery: "",
});
const [sdkLibraryQuery, setSdkLibraryQuery] = useState("");
const [sdkLibrarySource, setSdkLibrarySource] = useState("");
const [sdkLibraryOffset, setSdkLibraryOffset] = useState(0);
const sdkMapVisible =
memoryAuthority === "sdk" &&
simplified &&
@@ -313,35 +337,88 @@ export function MemorySpace({
displayedMode === "map";
useEffect(() => {
if (!sdkMapVisible) return;
// A write bumps the revision; reading it here re-runs the read at once
// instead of waiting for the 30s poll.
void sdkMapRevision;
let cancelled = false;
let controller: AbortController | null = null;
const read = async () => {
const source = sdkLibrarySource || undefined;
const query = sdkLibraryQuery || undefined;
const read = async (first: boolean) => {
controller?.abort();
const request = new AbortController();
controller = request;
if (first) setSdkLibrary((current) => ({ ...current, loading: true }));
try {
const [page, graph] = await Promise.all([
getSdkMemoryLibrary(
{ statuses: ["active"], offset: 0, limit: 300 },
{
statuses: ["active"],
source,
query,
offset: sdkLibraryOffset,
limit: 100,
},
request.signal,
),
getSdkMemoryGraph(request.signal),
// The overlay is decoration: the list stands without it.
getSdkMemoryGraph(
{ source, offset: query ? 0 : sdkLibraryOffset, limit: 100 },
request.signal,
).catch(() => null),
]);
if (cancelled) return;
setSdkMap({ memories: page.memories.map(sdkMapMemory), graph });
setSdkMapFailed(false);
setSdkLibrary({
page: sdkLibraryPage(page),
loading: false,
failed: false,
settledSource: sdkLibrarySource,
settledQuery: sdkLibraryQuery,
});
setSdkGraph(graph);
} catch {
if (!cancelled && !request.signal.aborted) setSdkMapFailed(true);
if (cancelled || request.signal.aborted) return;
setSdkLibrary((current) => ({
...current,
loading: false,
failed: true,
}));
}
};
void read();
const timer = setInterval(() => void read(), 30_000);
void read(true);
const timer = setInterval(() => void read(false), 30_000);
return () => {
cancelled = true;
clearInterval(timer);
controller?.abort();
};
}, [sdkMapVisible]);
}, [
sdkMapVisible,
sdkMapRevision,
sdkLibraryOffset,
sdkLibraryQuery,
sdkLibrarySource,
]);
const sdkFilterLabel = [
sdkLibrary.settledSource
? `${t("memory:来源")} ${
sdkLibrary.settledSource === UNATTRIBUTED_SOURCE_ID
? t("memory:未识别来源")
: sourceNameOr(
sources,
sdkLibrary.settledSource,
sdkLibrary.settledSource,
)
}`
: "",
sdkLibrary.settledQuery.trim()
? t("memory:关键词“{query}”", {
query: sdkLibrary.settledQuery.trim(),
})
: "",
]
.filter(Boolean)
.join(" · ");
const [draft, setDraft] = useState("");
const [selection, setSelection] = useState<Set<string>>(new Set());
const bulkInFlight = useRef(false);
@@ -1025,8 +1102,9 @@ export function MemorySpace({
open(memory.memory_id);
return;
}
setSdkFocusedMemoryId(memory.memory_id);
setMode("library");
// An SDK answer cites SDK memories; they open in the inspector like any
// other rather than switching the reader to a second list.
void open(memory.memory_id);
}
async function saveAnswer(draft: { content: string }) {
@@ -1075,7 +1153,13 @@ export function MemorySpace({
setDetailFreshness(null);
setDetailLoad({ memoryId, status: "loading" });
try {
const result = await getMemory(memoryId);
// Governed detail is refused on SDK authority, so the inspector reads the
// same memory through v2 and renders it in place. Staying here keeps the
// map's list, graph and detail where the design put them.
const result =
memoryAuthority === "sdk"
? sdkDetail(await getSdkMemory(memoryId))
: await getMemory(memoryId);
if (epoch !== detailEpoch.current || result.memory.memory_id !== memoryId)
return;
setDetail(result);
@@ -1123,6 +1207,28 @@ export function MemorySpace({
return;
setBusy(true);
setError(null);
if (memoryAuthority === "sdk") {
// An SDK memory is removed outright: there is no revision to undo.
if (actionName !== "delete") {
setBusy(false);
return;
}
const trace = traceUiAction("memory", "confirm");
try {
await deleteSdkMemory(detail.memory.memory_id);
trace.done("succeeded");
closeDetail();
bumpSdkMap();
} catch (reason) {
trace.done(writeTraceStatus(reason), {
reason: "memory_action_failed",
});
setError(message(reason, t));
} finally {
setBusy(false);
}
return;
}
const intent = `${detail.memory.memory_id}:${actionName}:${detail.memory.revision}`;
const requestId = requestIdFor(requestIds.current, intent);
const trace = traceUiAction("memory", "confirm");
@@ -1329,6 +1435,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");
@@ -1501,19 +1629,22 @@ export function MemorySpace({
</h1>
<span className="memory-loaded-count">
{memoryAuthority === "sdk" && simplified && !management
? sdkMapFailed && !sdkMap
? t("memory:记忆读取失败")
: sdkMap
? tn(
"memory:已沉淀 {count} 条{filter} · 当前展示 {shown} 条",
sdkMap.graph.total_memories,
{
filter: "",
shown: new Intl.NumberFormat(locale).format(
sdkMap.memories.length,
),
},
)
? sdkLibrary.page
? tn(
"memory:已沉淀 {count} 条{filter} · 当前展示 {shown} 条",
sdkLibrary.page.total,
{
filter: sdkFilterLabel
? t("memory:({filter})", { filter: sdkFilterLabel })
: "",
shown: new Intl.NumberFormat(locale).format(
sdkLibrary.page.items.length,
),
},
)
: // The list below says a failed read once, with its retry.
sdkLibrary.failed
? null
: t("memory:正在读取…")
: memoryAuthority === "sdk"
? null
@@ -1550,6 +1681,9 @@ export function MemorySpace({
suspended={!active}
onPendingChange={setPendingNote}
onSaved={() => {
// A finished note is a new SDK memory: re-read the page now
// rather than leave it for the next poll.
if (memoryAuthority === "sdk") bumpSdkMap();
void refresh(query).catch((reason) => setError(message(reason, t)));
}}
onOpenMemory={(id) => {
@@ -1727,9 +1861,11 @@ export function MemorySpace({
<ModelPrompt
processing={processing}
hasMemories={
simplified
? knownMapTotal.current > 0 || (mapLibrary.page?.total ?? 0) > 0
: memories.length > 0
memoryAuthority === "sdk"
? (sdkLibrary.page?.total ?? 0) > 0
: simplified
? knownMapTotal.current > 0 || (mapLibrary.page?.total ?? 0) > 0
: memories.length > 0
}
onConfigureModel={onConfigureModel}
/>
@@ -1887,23 +2023,38 @@ export function MemorySpace({
focusDialogue={focusDialogue}
onFocusDialogueConsumed={onFocusDialogueConsumed}
libraryView={
simplified && memoryAuthority !== "sdk"
simplified && memoryAuthority === "sdk"
? {
query: mapQuery,
source: mapSource,
page: mapLibrary.page,
loading: mapLibrary.loading,
failed: mapLibrary.failed,
stale: sourceSnapshotStale,
onQuery: setMapQuery,
onSource: setMapSource,
resetPage: () => mapLibrary.go(0),
onPage: mapLibrary.go,
onRetry: () => {
void mapLibrary.refresh().catch(() => {});
},
query: sdkLibraryQuery,
source: sdkLibrarySource,
page: sdkLibrary.page,
loading: sdkLibrary.loading,
failed: sdkLibrary.failed,
stale: false,
unattributedSource: UNATTRIBUTED_SOURCE_ID,
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)
@@ -1913,14 +2064,14 @@ export function MemorySpace({
hidden={!active || displayedMode !== "map"}
memories={
memoryAuthority === "sdk"
? (sdkMap?.memories ?? [])
? (sdkLibrary.page?.items ?? [])
: simplified
? (mapLibrary.page?.items ?? [])
: memories
}
graph={
memoryAuthority === "sdk"
? (sdkMap?.graph ?? null)
? sdkGraph
: simplified && sourceSnapshotStale
? null
: graph
@@ -1931,7 +2082,7 @@ export function MemorySpace({
onViewHistory={simplified ? onConnect : () => setMode("history")}
loaded={
memoryAuthority === "sdk"
? sdkMap !== null
? sdkLibrary.page !== null
: simplified
? mapLibrary.page !== null
: loaded
@@ -1942,7 +2093,7 @@ export function MemorySpace({
!active ||
deleteCount > 0 ||
(memoryAuthority === "sdk"
? sdkMap === null
? sdkLibrary.page === null
: simplified && (!mapLibrary.page || sourceSnapshotStale))
}
draft={draft}
@@ -1951,14 +2102,6 @@ export function MemorySpace({
streaming={streaming}
onDraft={setDraft}
onOpenMemory={(memoryId) => {
if (memoryAuthority === "sdk") {
// Governed detail is refused on SDK authority; the SDK library
// opens, edits and deletes the same memory through v2.
setSdkFocusedMemoryId(memoryId);
if (simplified) setManagementOpen(true);
else setMode("library");
return;
}
const recalled = answerMemories.current.find(
(memory) => memory.memory_id === memoryId,
);
@@ -1,6 +1,6 @@
// @vitest-environment jsdom
import type { MemoryDetail } from "@one-memory/contracts";
import type { MemoryDetail, SourceResponse } from "@one-memory/contracts";
import {
act,
cleanup,
@@ -14,7 +14,7 @@ import { afterEach, beforeEach, expect, it, vi } from "vitest";
import { LANGUAGE_STORAGE_KEY, LanguageProvider } from "./gui-language";
import { SelectedMemoryDetail } from "./SelectedMemoryDetail";
it("localizes informational details and dates while preserving evidence and memory", async () => {
it("localizes memory details without exposing the retired evidence surface", async () => {
delete window.oneMemory;
localStorage.setItem(LANGUAGE_STORAGE_KEY, "es");
render(
@@ -29,9 +29,9 @@ it("localizes informational details and dates while preserving evidence and memo
);
expect(await screen.findByText("Guardada · Versión 2")).toBeTruthy();
expect(
screen.getByRole("heading", { name: "Evidencia original" }),
).toBeTruthy();
expect(screen.getByText("这次先确认范围。")).toBeTruthy();
screen.queryByRole("heading", { name: "Evidencia original" }),
).toBeNull();
expect(screen.queryByText("这次先确认范围。")).toBeNull();
expect(correct).not.toHaveBeenCalled();
expect(remove).not.toHaveBeenCalled();
localStorage.clear();
@@ -145,6 +145,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();
});
+65 -96
View File
@@ -1,16 +1,22 @@
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";
import { clockStamp } from "./clock-stamp";
import { useGuiLanguage } from "./gui-language";
import { R6Button } from "./R6Controls";
import { sourceNameOr } from "./source-label";
import { sourceNameOr, UNATTRIBUTED_SOURCE_ID } from "./source-label";
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,19 +56,28 @@ 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")
: memory.status === "superseded"
? t("selectedMemory.status.superseded")
: t("selectedMemory.status.unknown");
const source = sourceNameOr(
sources,
evidence?.source_id,
t("selectedMemory.sourceUnknown"),
);
const sourceId = detail.source_id ?? evidence?.source_id;
const source =
detail.sdk_status && sourceId === UNATTRIBUTED_SOURCE_ID
? t("selectedMemory.sourceUnattributed")
: sourceNameOr(sources, sourceId, t("selectedMemory.sourceUnknown"));
const dirty = page === "edit" && draft !== memory.content;
useEffect(() => {
if (page === "edit" && memory.revision > base) setConflictFound(true);
@@ -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");
});
});
+16 -1
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,
@@ -1163,12 +1169,21 @@ export async function getMemory(memoryId: string): Promise<MemoryDetail> {
let pendingMemoryGraph: Promise<MemoryGraphResponse> | null = null;
/** The memory map on SDK authority: SDK memories plus the graph overlay. */
/** The SDK map for one list page: the same source and window the list reads. */
export async function getSdkMemoryGraph(
{
source,
offset = 0,
limit = 100,
}: { source?: string; offset?: number; limit?: number } = {},
signal?: AbortSignal,
): Promise<MemoryGraphResponse> {
const response = await client.get<MemoryGraphResponse>(
"/api/v2/memories/graph",
{ params: { limit: 300 }, signal },
{
params: { limit, offset, ...(source ? { source } : {}) },
signal,
},
);
return response.data;
}
+6
View File
@@ -54,6 +54,12 @@ describe("clockStamp", () => {
expect(clockStamp(value, "es")).toContain("19");
});
it("names the year of a date in another year", () => {
expect(clockStamp("2254-01-13T13:54:00Z", "zh-CN")).toContain("2254");
expect(clockStamp("2025-12-31T12:00:00Z", "es")).toContain("2025");
expect(clockStamp("2026-08-03T15:07:00Z", "zh-CN")).not.toContain("2026");
});
it("formats another date with month and day, without a today word", () => {
const value = "2026-08-03T15:07:00Z";
const es = clockStamp(value, "es");
+3
View File
@@ -34,7 +34,10 @@ export function clockStamp(
date.getDate() === now.getDate()
)
return translate(locale, "clock.today", { clock });
// Another year carries its year, as the memory list's day stamp does: a
// bare "01/13" for 2254-01-13 reads as this January.
return new Intl.DateTimeFormat(locale, {
...(date.getFullYear() !== now.getFullYear() ? { year: "numeric" } : {}),
month: "2-digit",
day: "2-digit",
hour: "2-digit",
+17
View File
@@ -689,6 +689,23 @@ button.rail-button.active::before {
background: transparent;
font-size: 12px;
}
.map-search input {
flex: 1;
}
.map-search-submit {
flex: none;
padding: 4px 10px;
border: 0;
border-radius: 6px;
color: #fff;
background: #3d5a70;
font-size: 12px;
cursor: pointer;
}
.map-search-submit:disabled {
opacity: 0.5;
cursor: default;
}
.map-search:focus-within,
.map-question:focus-within {
outline: 2px solid #7892a6;
+3 -1
View File
@@ -23,6 +23,8 @@ export function projectPagedSourceMap(
graph: MemoryGraphResponse | null,
page: MemoryLibraryPage | null,
sources: SourceResponse[],
/** Names for source ids that are not in the catalog (e.g. 未识别来源). */
labels: Record<string, string> = {},
): MemoryGraphResponse {
const nodes = new Map<string, MemoryGraphNode>();
const edges = new Map<string, MemoryGraphEdge>();
@@ -93,7 +95,7 @@ export function projectPagedSourceMap(
node_type: "source",
memory_id: null,
source_id: id,
label: catalog.get(id)?.display_name || id,
label: catalog.get(id)?.display_name || labels[id] || id,
kind: null,
status: null,
confidence: null,
@@ -0,0 +1,87 @@
import type { SdkMemoryRecord } from "@one-memory/contracts";
import { afterEach, beforeEach, expect, it, vi } from "vitest";
import { projectPagedSourceMap } from "./memory-map-model";
import { sdkDetail, sdkLibraryPage } from "./sdk-memory-map";
const NOW = Date.UTC(2026, 9, 25) / 1_000;
beforeEach(() => {
vi.useFakeTimers();
vi.setSystemTime(NOW * 1_000);
});
afterEach(() => vi.useRealTimers());
const record = (extra: Partial<SdkMemoryRecord>): SdkMemoryRecord => ({
memory_id: "m",
content: "memory",
layer: "l2_fact",
status: "active",
is_latest: true,
supersedes: [],
superseded_by: [],
tags: [],
owner: null,
source_raw_ids: [],
user_id: "local",
agent_id: "all-apps",
session_id: "s1",
source: "cursor",
memory_at: null,
gmt_created: NOW - 86_400,
...extra,
});
it("keeps the SDK's no-source bucket as a source the list and map can name", () => {
const page = sdkLibraryPage({
memories: [record({ memory_id: "d", source: "default" })],
total: 1,
limit: 100,
offset: 0,
});
expect(page.source_ids_by_memory).toEqual({ d: ["default"] });
expect(sdkDetail(record({ source: "default" })).source_id).toBe("default");
const drawn = projectPagedSourceMap(null, page, [], {
default: "未识别来源",
});
expect(
drawn.nodes.find((node) => node.node_id === "source:default")?.label,
).toBe("未识别来源");
});
it("does not present an impossible conversation time as when it happened", () => {
const future = Date.UTC(2254, 0, 13) / 1_000;
const page = sdkLibraryPage({
memories: [record({ memory_id: "f", memory_at: future })],
total: 1,
limit: 100,
offset: 0,
});
expect(page.source_time_by_memory?.f).toEqual({
at: new Date((NOW - 86_400) * 1_000).toISOString(),
basis: "import",
});
expect(sdkDetail(record({ memory_at: future })).sdk_memory_at).toBeNull();
const real = NOW - 3 * 86_400;
expect(
sdkLibraryPage({
memories: [record({ memory_id: "r", memory_at: real })],
total: 1,
limit: 100,
offset: 0,
}).source_time_by_memory?.r.basis,
).toBe("conversation");
});
it("carries a full search window through to the list", () => {
expect(
sdkLibraryPage({
memories: [],
total: 0,
limit: 100,
offset: 0,
search_window: 500,
}).search_window,
).toBe(500);
});
+89 -4
View File
@@ -1,6 +1,9 @@
import type {
GovernedMemory,
MemoryDetail,
MemoryKind,
MemoryLibraryPage,
SdkMemoryPage,
SdkMemoryRecord,
} from "@one-memory/contracts";
@@ -18,11 +21,35 @@ function stamp(seconds: number | null | undefined): string {
: "";
}
const EARLIEST_PLAUSIBLE = Date.UTC(2000, 0, 1) / 1_000;
/** A conversation time that can be true: not before 2000, not tomorrow or later.
*
* A historical Cursor bug stored a file hash as the message time (years like
* 2254). Such a value is not shown as when the conversation happened; the
* import time, or "time unknown", says what is actually known.
*/
function plausible(seconds: number | null | undefined): number | null {
return typeof seconds === "number" &&
Number.isFinite(seconds) &&
seconds >= EARLIEST_PLAUSIBLE &&
seconds <= Date.now() / 1_000 + 86_400
? seconds
: null;
}
/** The SDK writes "default" when no source was given; keep it as that bucket. */
function sourceOf(record: SdkMemoryRecord): string {
return record.source || "default";
}
/** An SDK memory in the shape the memory map draws.
*
* The map groups and labels by id, content, kind, status and tags only. It
* never writes through this shape: opening a memory goes to the SDK library,
* so the governed-only fields are inert placeholders here.
* The map groups and labels by id, content, kind, status and tags only, so the
* governed-only fields are inert placeholders here. Opening, editing and
* deleting still go through the SDK (v2) endpoints — `sdkDetail()` below wraps
* the same projection for the map's inspector, which is why the inspector can
* stay where the design put it instead of sending the reader to another view.
*/
export function sdkMapMemory(record: SdkMemoryRecord): GovernedMemory {
const created = stamp(record.gmt_created ?? record.memory_at);
@@ -43,9 +70,67 @@ export function sdkMapMemory(record: SdkMemoryRecord): GovernedMemory {
tags: [...record.tags],
supersedes: [...record.supersedes],
superseded_by: [...record.superseded_by],
revision: 1,
// GovernedMemory still requires a revision for the shared map model. SDK
// has no revision contract; the inspector recognises sdk_status and never
// renders this placeholder.
revision: 0,
created_at: created,
updated_at: created,
deleted_at: null,
};
}
/** One SDK memory as the map's inspector reads it.
*
* The inspector was built for a governed detail. SDK-native memory has no
* product revision or "original evidence" section; source_raw_ids remain an
* internal SDK provenance relation and are deliberately not projected as UI
* evidence.
*/
export function sdkDetail(record: SdkMemoryRecord): MemoryDetail {
return {
memory: sdkMapMemory(record),
evidence: [],
history: [],
index_sync: null,
sdk_status: record.status,
source_id: sourceOf(record),
sdk_memory_at: stamp(plausible(record.memory_at)) || null,
sdk_created_at: stamp(record.gmt_created) || null,
};
}
/** One SDK page as the map's left list reads it.
*
* The list asks a library page for its rows, its total and — per row — when
* the cited conversation happened. The SDK page carries the row itself, so the
* time is taken from the memory: `memory_at` is the moment the conversation
* happened, `gmt_created` only the moment it was imported. Naming which one it
* is keeps "对话于" from silently becoming "沉淀于".
*/
export function sdkLibraryPage(page: SdkMemoryPage): MemoryLibraryPage {
const sourceTime: Record<
string,
{ at: string; basis: "conversation" | "import" }
> = {};
const sourceIds: Record<string, string[]> = {};
for (const record of page.memories) {
sourceIds[record.memory_id] = [sourceOf(record)];
const conversation = plausible(record.memory_at);
const at = conversation ?? plausible(record.gmt_created);
if (at === null) continue;
sourceTime[record.memory_id] = {
at: new Date(at * 1_000).toISOString(),
basis: conversation !== null ? "conversation" : "import",
};
}
return {
items: page.memories.map(sdkMapMemory),
total: page.total,
offset: page.offset,
limit: page.limit,
source_ids_by_memory: sourceIds,
source_time_by_memory: sourceTime,
search_window: page.search_window ?? null,
};
}
+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;
+7
View File
@@ -9,6 +9,13 @@ const INTERNAL_SOURCE_NAMES: Record<string, string> = {
"memory-content-export": "导入的已有记忆",
};
/**
* The source the SDK records when a writer named none. The legacy-to-SDK
* backfill wrote every legacy memory this way, so it is a real, filterable
* bucket ("未识别来源"), not a source still being checked.
*/
export const UNATTRIBUTED_SOURCE_ID = "default";
type NamedSource = Pick<SourceResponse, "source_id" | "display_name">;
/** The name to show for a source id, falling back to the id itself. */
+1 -1
View File
@@ -2,7 +2,7 @@
"name": "one-memory",
"version": "1.0.5",
"release": {
"build_number": 17,
"build_number": 22,
"macos_min_version": "13.0",
"app_update": "manual",
"runtime_manifest_schema": 1,
+9
View File
@@ -470,6 +470,9 @@ export interface MemoryLibraryPage {
* import): a partial snapshot, not an authoritative view. Show "importing"
* and retry; never render this state as a failure. */
stale?: boolean;
/** Set on a search whose relevance window was full: matches were looked for
* only among this many most relevant memories, so `total` may undercount. */
search_window?: number | null;
}
export interface MemoryLibraryQuery {
@@ -518,6 +521,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;
@@ -555,6 +563,7 @@ export interface SdkMemoryPage {
total: number;
limit: number;
offset: number;
search_window?: number | null;
}
export interface SdkUserProfileResponse {
@@ -317,6 +317,39 @@ it("selects SDK Context v2 after cutover and passes native fields unchanged", as
);
});
it.each([
[10, 200],
[11, 502],
])(
"accepts up to Core's v2 default of 10 items when no limit is sent (%i items)",
async (count, status) => {
const f = await fixture();
const items = Array.from({ length: count }, (_, index) => ({
kind: "memory",
memory_id: `memory-${index}`,
content: "m",
layer: "l2_fact",
status: "active",
source: "codex",
updated_at: "2026-10-21T00:00:00Z",
score: 0.5,
truncated: false,
}));
f.setAuthority("sdk");
f.respond({
schema_version: 2,
request_id: "sdk-request",
state: "grounded",
items,
used_characters: count,
truncated: false,
total_ms: 1,
});
expect((await f.post({ query: "original" })).status).toBe(status);
},
);
it("does not fall back to legacy context when the selected SDK response is invalid", async () => {
const f = await fixture();
f.setAuthority("sdk");
@@ -74,7 +74,7 @@ export const CONTEXT_INPUT_SCHEMA = {
description:
"Filters raw only, within already granted sources; does not change memory scope.",
},
limit: { type: "integer", minimum: 1, maximum: 20, default: 8 },
limit: { type: "integer", minimum: 1, maximum: 20, default: 10 },
character_budget: {
type: "integer",
minimum: 200,
@@ -285,8 +285,10 @@ export function isContextResponseForRequest(
): value is HostContextSearchResponse {
if (!isHostContextSearchResponse(value)) return false;
const kinds = request.kinds ?? ["raw", "memory"];
// Core's default item cap: 8 on v1, 10 on SDK-native v2.
const limit = request.limit ?? (isSdkContextSearchResponse(value) ? 10 : 8);
return (
value.items.length <= (request.limit ?? 8) &&
value.items.length <= limit &&
value.used_characters <= (request.character_budget ?? 4000) &&
value.items.every(
(item) =>
@@ -74,10 +74,16 @@ export const currentChineseMessages = {
"memorySpace.tabs.workbench": "工作台",
"selectedMemory.status.confirmed": "已沉淀",
"selectedMemory.status.active": "已生效",
"selectedMemory.status.deleted": "已删除",
"selectedMemory.status.superseded": "已被取代",
"selectedMemory.status.shadow": "已隐藏",
"selectedMemory.status.negated": "已否定",
"selectedMemory.status.conflicted": "有冲突",
"selectedMemory.status.archived": "已归档",
"selectedMemory.status.unknown": "状态待核对",
"selectedMemory.sourceUnknown": "来源待核对",
"selectedMemory.sourceUnattributed": "未识别来源",
"selectedMemory.discardDraft": "放弃尚未保存的修改?",
"selectedMemory.newVersionReadFailed": "暂时无法读取新版本,草稿已保留。",
"selectedMemory.updateRejected":
@@ -208,10 +214,16 @@ export const currentEnglishMessages: Record<
"memorySpace.tabs.workbench": "Workbench",
"selectedMemory.status.confirmed": "Created",
"selectedMemory.status.active": "Active",
"selectedMemory.status.deleted": "Deleted",
"selectedMemory.status.superseded": "Superseded",
"selectedMemory.status.shadow": "Hidden",
"selectedMemory.status.negated": "Negated",
"selectedMemory.status.conflicted": "Conflicted",
"selectedMemory.status.archived": "Archived",
"selectedMemory.status.unknown": "Status needs review",
"selectedMemory.sourceUnknown": "Source needs review",
"selectedMemory.sourceUnattributed": "Unidentified source",
"selectedMemory.discardDraft": "Discard unsaved changes?",
"selectedMemory.newVersionReadFailed":
"Could not load the new version. Your draft is retained.",
@@ -356,10 +368,16 @@ export const currentSpanishMessages: Record<
"memorySpace.tabs.workbench": "Espacio de trabajo",
"selectedMemory.status.confirmed": "Guardada",
"selectedMemory.status.active": "Activa",
"selectedMemory.status.deleted": "Eliminada",
"selectedMemory.status.superseded": "Sustituida",
"selectedMemory.status.shadow": "Oculta",
"selectedMemory.status.negated": "Negada",
"selectedMemory.status.conflicted": "En conflicto",
"selectedMemory.status.archived": "Archivada",
"selectedMemory.status.unknown": "Estado pendiente de comprobar",
"selectedMemory.sourceUnknown": "Origen sin verificar",
"selectedMemory.sourceUnattributed": "Origen no identificado",
"selectedMemory.discardDraft": "¿Descartar los cambios sin guardar?",
"selectedMemory.newVersionReadFailed":
"No se pudo leer la nueva versión. Tu borrador se conserva.",
@@ -1571,6 +1571,10 @@ const baseEnglishMessages = {
"memory:全部记忆": "All memories",
"memory:来源待核对": "Source needs review",
"memory:所选来源待核对": "Selected source needs review",
"memory:未识别来源": "Unidentified source",
"memory:这个来源下还没有记忆": "No memories from this source yet",
"memory:只在最相关的 {count} 条记忆里查找关键词":
"Keywords are matched only among the {count} most relevant memories",
"memory:未知来源": "Unknown source",
"memory:记忆读取失败": "Could not load memories",
"memory:正在读取…": "Loading…",
@@ -146,6 +146,10 @@ export const memoryChineseMessages = {
"memory:全部记忆": "全部记忆",
"memory:来源待核对": "来源待核对",
"memory:所选来源待核对": "所选来源待核对",
"memory:未识别来源": "未识别来源",
"memory:这个来源下还没有记忆": "这个来源下还没有记忆",
"memory:只在最相关的 {count} 条记忆里查找关键词":
"只在最相关的 {count} 条记忆里查找关键词",
"memory:未知来源": "未知来源",
"memory:记忆读取失败": "记忆读取失败",
"memory:正在读取…": "正在读取…",
@@ -538,6 +542,10 @@ export const memorySpanishMessages: Record<
"memory:全部记忆": "Todas las memorias",
"memory:来源待核对": "Origen sin verificar",
"memory:所选来源待核对": "Origen seleccionado sin verificar",
"memory:未识别来源": "Origen no identificado",
"memory:这个来源下还没有记忆": "Aún no hay recuerdos de este origen",
"memory:只在最相关的 {count} 条记忆里查找关键词":
"Las palabras clave solo se buscan entre los {count} recuerdos más relevantes",
"memory:未知来源": "Origen desconocido",
"memory:记忆读取失败": "Error al leer las memorias",
"memory:正在读取…": "Leyendo…",
@@ -711,7 +711,7 @@ describe("local Runtime installation", () => {
expect(replacement.version_directory).not.toBe(firstPlan.version_directory);
});
it("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