Compare commits

...
Author SHA1 Message Date
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
23 changed files with 833 additions and 198 deletions

No files matched your search

+9
View File
@@ -4,6 +4,15 @@ 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
+29 -16
View File
@@ -105,6 +105,7 @@ from one_memory.hy_sdk_types import (
HySdkRawRecord,
HySdkReadError,
HySdkUserProfile,
library_keyword_hits,
parse_sdk_search,
)
from one_memory.imports.access import (
@@ -3153,9 +3154,11 @@ def create_app(
status_code=422,
detail="sdk_search_supports_active_status_only",
)
# SDK search has no count-only contract. Fetch one bounded
# relevance window so pagination can expose more than the
# first page without listing the whole library in Core.
# 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.
fetch = 500
search_raw = await asyncio.to_thread(
client.search,
@@ -3164,8 +3167,8 @@ def create_app(
agent_ids=[],
include_sources=[source] if source else None,
limit=fetch,
min_score=sdk_relevance_floor(),
schema_min_score=sdk_relevance_floor(),
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.
@@ -3173,13 +3176,8 @@ def create_app(
scene="normal",
reader=HY_MEMORY_READER,
)
hits = parse_sdk_search(
search_raw,
error_code="hy_memory_search_shape_unknown",
)
rows = await _get_sdk_rows(
client, [hit.memory_id for hit in hits[offset : offset + limit]]
)
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:
@@ -3195,9 +3193,10 @@ def create_app(
_log_skipped_sdk_memories(skipped)
return HySdkMemoryPage(
memories=tuple(memories),
total=len(hits),
total=len(hit_ids),
limit=limit,
offset=offset,
search_window=fetch if window >= fetch else None,
)
raw = await asyncio.to_thread(
client.list_memories,
@@ -3224,6 +3223,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.
@@ -3233,10 +3234,19 @@ 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, skipped = HySdkMemoryPage.from_sdk_skipping_invalid(
raw, limit=limit, offset=offset
)
page, skipped = HySdkMemoryPage.from_sdk_skipping_invalid(raw, limit=limit, offset=0)
_log_skipped_sdk_memories(skipped)
except Exception as error:
raise _sdk_http_error(error, "hy_memory_list_failed") from None
@@ -3257,7 +3267,10 @@ 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 and memory.source != "default":
source_node_id = f"source:{memory.source}"
@@ -11,9 +11,10 @@ import json
import sqlite3
from collections.abc import Iterator
from contextlib import closing
from dataclasses import dataclass
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,
@@ -25,7 +26,8 @@ from hy_memory import ( # type: ignore[import-untyped]
from one_memory.database_access import main_database
from one_memory.hy_runtime_config import HY_MEMORY_NAMESPACE
_METADATA_REPAIR_VERSION = 1
# v2 reads the backfill's governed_memory_evidence links; v1 missed them.
_METADATA_REPAIR_VERSION = 2
_BATCH = 500
@@ -80,7 +82,7 @@ def repair_sdk_metadata_from_bundle(
database_path: Path,
now: datetime | None = None,
) -> MetadataRepairResult:
"""Restore metadata lost by historical cutovers from their verified bundle."""
"""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)
@@ -93,17 +95,21 @@ def repair_sdk_metadata_from_bundle(
int(document.get("vector_rows", 0)),
int(document.get("coding_rows", 0)),
)
legacy = (
data_dir
/ "backups"
/ f"{migration_id}-pre-cutover.sdk-bundle"
/ "management"
/ "one-memory.sqlite3"
# 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.is_file():
if not legacy:
# No bundle yet is not a finished repair: try again on the next start.
return MetadataRepairResult(migration_id, 0, 0, 0)
mapping = _legacy_metadata(legacy, now=now or datetime.now(UTC))
vector_rows = _repair_vector(data_dir, mapping)
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,
@@ -141,40 +147,104 @@ def _sdk_migration_id(database_path: Path) -> str | None:
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 not {"memory_task_outputs", "governed_memories", "evidence"} <= tables:
if "evidence" not in tables:
return {}
outputs: list[tuple[str, str, tuple[str, ...]]] = []
evidence_ids: set[str] = set()
for row in database.execute("SELECT body FROM memory_task_outputs"):
try:
body = json.loads(str(row["body"]))
except (TypeError, ValueError):
continue
legacy_id = body.get("memory_id")
canonical_id = body.get("hy_memory_id")
refs = body.get("support_refs")
if not isinstance(legacy_id, str) or not isinstance(canonical_id, str):
continue
identifiers = (
tuple(
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
for ref in (refs if isinstance(refs, list) else ())
if isinstance(ref, dict) and isinstance(ref.get("evidence_id"), str)
)
if isinstance(refs, list)
else ()
)
outputs.append((legacy_id, canonical_id, identifiers))
evidence_ids.update(identifiers)
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]] = {}
for batch in _batches(sorted(evidence_ids)):
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 "
@@ -183,49 +253,49 @@ def _legacy_metadata(path: Path, *, now: datetime) -> dict[str, RepairMetadata]:
):
evidence[str(row["evidence_id"])] = (
str(row["source_id"]),
_plausible_time(
row["created_at"],
fallback=row["imported_at"],
now=now,
),
_plausible_time(row["created_at"], fallback=row["imported_at"], now=now),
)
governed = {
str(row["memory_id"]): _plausible_time(
row["created_at"],
fallback=row["updated_at"],
now=now,
)
for row in database.execute(
"SELECT memory_id,created_at,updated_at FROM governed_memories"
)
}
# Several legacy memories can fold into one SDK memory. Pool their evidence
# before deciding, so a disagreeing source cannot be overwritten by whichever
# output happened to come last.
pooled: dict[str, tuple[set[str], list[datetime], list[datetime]]] = {}
for legacy_id, canonical_id, refs in outputs:
sources, times, created = pooled.setdefault(canonical_id, (set(), [], []))
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":
sources.add(source)
pool.sources.add(source)
if timestamp is not None:
times.append(timestamp)
legacy_created = governed.get(legacy_id)
if legacy_created is not None:
created.append(legacy_created)
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(sources)) if len(sources) == 1 else None,
memory_at=int(max(times).timestamp()) if times else None,
created_at=int(min(created).timestamp()) if created else None,
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, (sources, times, created) in pooled.items()
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,
*,
@@ -250,9 +320,11 @@ def _plausible_time(
def _repair_vector(
data_dir: Path,
mapping: dict[str, RepairMetadata],
*,
now: datetime,
) -> int:
directory = data_dir / "hy-memory" / HY_MEMORY_NAMESPACE / "vector"
if not mapping or not (directory / "chroma.sqlite3").is_file():
if not (directory / "chroma.sqlite3").is_file():
return 0
import chromadb
from chromadb.config import Settings
@@ -308,11 +380,40 @@ def _repair_vector(
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],
@@ -92,6 +92,9 @@ class HySdkMemoryPage(BaseModel):
total: int = Field(ge=0)
limit: int = Field(ge=1)
offset: int = Field(ge=0)
# Set on a search whose relevance window was full: matches were looked for
# only among this many most relevant memories, so `total` may undercount.
search_window: int | None = None
@classmethod
def from_sdk(cls, raw: object, *, limit: int, offset: int) -> HySdkMemoryPage:
@@ -324,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)
@@ -92,8 +92,8 @@ ALLOWED = {
"the memory ids a migrated home must still read back",
opened_with="?mode=ro",
),
("hy_sdk_migration.py", "_legacy_metadata"): Allowed(
"the pre-cutover bundle's copy of the legacy database, read-only, to "
("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",
),
+12 -2
View File
@@ -402,16 +402,26 @@ def test_sdk_memory_library_uses_sdk_search_and_skips_empty_rows_without_mutatio
)
assert response.status_code == 200
body = response.json()
# `total` counts the bounded relevance window; the superseded
# hit is in it but is not listed as an active memory.
# `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"] == 500
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},
@@ -186,6 +186,90 @@ def test_post_cutover_metadata_repair_restores_source_and_original_times(store):
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
@@ -1060,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:
@@ -1099,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",
+59
View File
@@ -511,6 +511,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
+63 -13
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,12 +170,27 @@ 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;
@@ -338,7 +355,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 +577,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 +634,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 +767,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 +780,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,
@@ -1038,11 +1076,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" &&
@@ -46,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">
@@ -59,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>
),
}));
@@ -137,13 +165,61 @@ 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("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"));
+96 -80
View File
@@ -62,8 +62,8 @@ import { subscribePoll } from "./poll-scheduler";
import { R6Button } from "./R6Controls";
import { SdkMemoryLibrary } from "./SdkMemoryLibrary";
import { SdkRawHistory } from "./SdkRawHistory";
import { sdkDetail, sdkLibraryPage, sdkMapMemory } from "./sdk-memory-map";
import { sourceNameOr } from "./source-label";
import { 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";
@@ -303,25 +303,30 @@ export function MemorySpace({
// The SDK library is no longer where a memory opens from the map; the id is
// kept so the panel still has a focus target for its own tests.
const [sdkFocusedMemoryId] = useState<string | null>(null);
// The map on SDK authority. Legacy reads (library page, governed graph)
// are refused there, so it draws SDK memories and the v2 graph instead.
const [sdkMap, setSdkMap] = useState<{
memories: GovernedMemory[];
graph: MemoryGraphResponse;
} | null>(null);
const [sdkMapFailed, setSdkMapFailed] = useState(false);
// 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), []);
// The map's left list pages through the SDK library the same way it pages
// through the governed one: the list is always one page, and the count above
// it is the library's total, not the page's length.
const [sdkLibrary, setSdkLibrary] = useState<{
page: MemoryLibraryPage | null;
loading: boolean;
failed: boolean;
}>({ page: null, loading: false, failed: false });
/** 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);
@@ -337,80 +342,83 @@ export function MemorySpace({
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);
} catch {
if (!cancelled && !request.signal.aborted) setSdkMapFailed(true);
}
};
void read();
const timer = setInterval(() => void read(), 30_000);
return () => {
cancelled = true;
clearInterval(timer);
controller?.abort();
};
}, [sdkMapVisible, sdkMapRevision]);
useEffect(() => {
if (memoryAuthority !== "sdk" || !simplified || !active) return;
if (displayedMode !== "map") return;
void sdkMapRevision;
let cancelled = false;
const controller = new AbortController();
setSdkLibrary((current) => ({ ...current, loading: true }));
void getSdkMemoryLibrary(
{
statuses: ["active"],
source: sdkLibrarySource || undefined,
query: sdkLibraryQuery || undefined,
offset: sdkLibraryOffset,
limit: 100,
},
controller.signal,
)
.then((page) => {
if (cancelled) return;
setSdkLibrary({
page: sdkLibraryPage(page),
loading: false,
failed: false,
settledSource: sdkLibrarySource,
settledQuery: sdkLibraryQuery,
});
})
.catch(() => {
if (cancelled || controller.signal.aborted) return;
setSdkGraph(graph);
} catch {
if (cancelled || request.signal.aborted) return;
setSdkLibrary((current) => ({
...current,
loading: false,
failed: true,
}));
});
}
};
void read(true);
const timer = setInterval(() => void read(false), 30_000);
return () => {
cancelled = true;
controller.abort();
clearInterval(timer);
controller?.abort();
};
}, [
memoryAuthority,
simplified,
active,
displayedMode,
sdkMapVisible,
sdkMapRevision,
sdkLibraryOffset,
sdkLibraryQuery,
sdkLibrarySource,
sdkMapRevision,
]);
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);
@@ -1621,20 +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(
sdkLibrary.page?.items.length ??
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
@@ -1671,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) => {
@@ -1848,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}
/>
@@ -2016,6 +2031,7 @@ export function MemorySpace({
loading: sdkLibrary.loading,
failed: sdkLibrary.failed,
stale: false,
unattributedSource: UNATTRIBUTED_SOURCE_ID,
onQuery: setSdkLibraryQuery,
onSource: setSdkLibrarySource,
resetPage: () => setSdkLibraryOffset(0),
@@ -2048,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
@@ -2066,7 +2082,7 @@ export function MemorySpace({
onViewHistory={simplified ? onConnect : () => setMode("history")}
loaded={
memoryAuthority === "sdk"
? sdkMap !== null
? sdkLibrary.page !== null
: simplified
? mapLibrary.page !== null
: loaded
@@ -2077,7 +2093,7 @@ export function MemorySpace({
!active ||
deleteCount > 0 ||
(memoryAuthority === "sdk"
? sdkMap === null
? sdkLibrary.page === null
: simplified && (!mapLibrary.page || sourceSnapshotStale))
}
draft={draft}
+6 -6
View File
@@ -9,7 +9,7 @@ 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";
@@ -73,11 +73,11 @@ export function SelectedMemoryDetail({
: memory.status === "superseded"
? t("selectedMemory.status.superseded")
: t("selectedMemory.status.unknown");
const source = sourceNameOr(
sources,
detail.source_id ?? 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);
+10 -1
View File
@@ -1169,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",
+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);
});
+30 -8
View File
@@ -21,6 +21,28 @@ 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, so the
@@ -72,9 +94,8 @@ export function sdkDetail(record: SdkMemoryRecord): MemoryDetail {
history: [],
index_sync: null,
sdk_status: record.status,
source_id:
record.source && record.source !== "default" ? record.source : null,
sdk_memory_at: stamp(record.memory_at) || null,
source_id: sourceOf(record),
sdk_memory_at: stamp(plausible(record.memory_at)) || null,
sdk_created_at: stamp(record.gmt_created) || null,
};
}
@@ -94,13 +115,13 @@ export function sdkLibraryPage(page: SdkMemoryPage): MemoryLibraryPage {
> = {};
const sourceIds: Record<string, string[]> = {};
for (const record of page.memories) {
if (record.source && record.source !== "default")
sourceIds[record.memory_id] = [record.source];
const at = record.memory_at ?? record.gmt_created;
if (typeof at !== "number" || !Number.isFinite(at)) continue;
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: record.memory_at ? "conversation" : "import",
basis: conversation !== null ? "conversation" : "import",
};
}
return {
@@ -110,5 +131,6 @@ export function sdkLibraryPage(page: SdkMemoryPage): MemoryLibraryPage {
limit: page.limit,
source_ids_by_memory: sourceIds,
source_time_by_memory: sourceTime,
search_window: page.search_window ?? null,
};
}
+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. */
+4
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 {
@@ -560,6 +563,7 @@ export interface SdkMemoryPage {
total: number;
limit: number;
offset: number;
search_window?: number | null;
}
export interface SdkUserProfileResponse {
@@ -83,6 +83,7 @@ export const currentChineseMessages = {
"selectedMemory.status.archived": "已归档",
"selectedMemory.status.unknown": "状态待核对",
"selectedMemory.sourceUnknown": "来源待核对",
"selectedMemory.sourceUnattributed": "未识别来源",
"selectedMemory.discardDraft": "放弃尚未保存的修改?",
"selectedMemory.newVersionReadFailed": "暂时无法读取新版本,草稿已保留。",
"selectedMemory.updateRejected":
@@ -222,6 +223,7 @@ export const currentEnglishMessages: Record<
"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.",
@@ -375,6 +377,7 @@ export const currentSpanishMessages: Record<
"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…",