Compare commits

..
Author SHA1 Message Date
kristinhe 686c14ed62 chore(release): bump MemHub build number to 24 2026-10-25 20:19:43 +08:00
30 changed files with 229 additions and 1052 deletions

No files matched your search

-9
View File
@@ -4,15 +4,6 @@ Hy Memory and MemHub are versioned and released independently.
## Unreleased
- The SDK memory page now reads its list, count, source and time labels and
graph from one page response for one source filter, so rows, totals and the
map no longer disagree. Memories with no recorded source are shown and
filterable as "unidentified source"; library search keeps only memories
that contain the words within the SDK's relevance window.
- Metadata repair v2 follows `governed_memory_evidence` across every
pre-cutover bundle, which restores the source of memories the cutover
backfilled without one, and moves impossible future conversation times back
to the write time.
- Reused the existing three-column memory map for SDK-authority list/detail
views and removed the retired "original evidence" surface. SDK-native status,
source, conversation time and creation time now render without fabricated
+17 -32
View File
@@ -105,7 +105,6 @@ from one_memory.hy_sdk_types import (
HySdkRawRecord,
HySdkReadError,
HySdkUserProfile,
library_keyword_hits,
parse_sdk_search,
)
from one_memory.imports.access import (
@@ -3154,14 +3153,10 @@ def create_app(
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
# SDK search has no count-only contract. Fetch one bounded
# relevance window so pagination can expose more than the
# first page without listing the whole library in Core.
fetch = 500
search_raw = await asyncio.to_thread(
client.search,
search_query,
@@ -3169,8 +3164,8 @@ def create_app(
agent_ids=[],
include_sources=[source] if source else None,
limit=fetch,
min_score=0.0,
schema_min_score=0.0,
min_score=sdk_relevance_floor(),
schema_min_score=sdk_relevance_floor(),
schema_limit=fetch,
# The library lists stored memories; proactive intentions
# are a recall-time lane the list never shows.
@@ -3178,8 +3173,13 @@ def create_app(
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]))
hits = parse_sdk_search(
search_raw,
error_code="hy_memory_search_shape_unknown",
)
rows = await _get_sdk_rows(
client, [hit.memory_id for hit in hits[offset : offset + limit]]
)
memories: list[HySdkMemoryRecord] = []
skipped = 0
for row in rows:
@@ -3195,10 +3195,9 @@ def create_app(
_log_skipped_sdk_memories(skipped)
return HySdkMemoryPage(
memories=tuple(memories),
total=len(hit_ids),
total=len(hits),
limit=limit,
offset=offset,
search_window=fetch if window >= fetch else None,
)
raw = await asyncio.to_thread(
client.list_memories,
@@ -3225,8 +3224,6 @@ 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.
@@ -3236,19 +3233,10 @@ 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",
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
client.list_memories, user_id="local", limit=limit, offset=0, order="desc"
)
page, skipped = HySdkMemoryPage.from_sdk_skipping_invalid(raw, limit=limit, offset=0)
_log_skipped_sdk_memories(skipped)
except Exception as error:
raise _sdk_http_error(error, "hy_memory_list_failed") from None
@@ -3269,10 +3257,7 @@ def create_app(
kind=memory.layer,
status=memory.status,
)
# The SDK fills in "default_session" when a writer gave none (the
# cutover backfill did so for every legacy memory); it names no
# conversation, so it must not chain those memories together.
if memory.session_id and memory.session_id != "default_session":
if memory.session_id:
sessions.setdefault(memory.session_id, []).append(memory_node_id)
if memory.source and memory.source != "default":
source_node_id = f"source:{memory.source}"
@@ -47,19 +47,6 @@ class ContextSearchRequest(BaseModel):
return self
class SdkContextSearchRequest(ContextSearchRequest):
"""`POST /api/v2/context/search`: the three SDK recall lanes are set apart.
`limit` is the normal lane's top-k and the cap on returned items;
`schema_limit` and `intention_limit` are the SDK's schema (L6) and
proactive intention (L7) lanes. A caller's values are passed through.
"""
limit: int = Field(default=10, ge=1, le=20, strict=True)
schema_limit: int = Field(default=3, ge=0, le=20, strict=True)
intention_limit: int = Field(default=1, ge=0, le=20, strict=True)
class ContextMessage(BaseModel):
model_config = ConfigDict(frozen=True, extra="forbid")
@@ -34,7 +34,6 @@ from one_memory.context_models import (
MemoryContextItem,
RawContextItem,
SdkContextItem,
SdkContextSearchRequest,
SdkContextSearchResponse,
SdkMemoryContextItem,
SdkRawContextItem,
@@ -440,7 +439,7 @@ def sdk_context_router(dependencies: Sequence[Depends]) -> APIRouter:
@router.post("/search", response_model=SdkContextSearchResponse)
async def search(
value: SdkContextSearchRequest,
value: ContextSearchRequest,
request: Request,
response: Response,
) -> SdkContextSearchResponse:
@@ -515,8 +514,8 @@ def sdk_context_router(dependencies: Sequence[Depends]) -> APIRouter:
limit=value.limit,
min_score=sdk_relevance_floor(),
schema_min_score=sdk_relevance_floor(),
schema_limit=value.schema_limit,
intention_limit=value.intention_limit,
schema_limit=value.limit,
intention_limit=value.limit,
scene="normal",
reader=HY_MEMORY_READER,
)
@@ -11,10 +11,9 @@ import json
import sqlite3
from collections.abc import Iterator
from contextlib import closing
from dataclasses import dataclass, field
from dataclasses import dataclass
from datetime import UTC, datetime, timedelta
from pathlib import Path
from typing import Any
from hy_memory import ( # type: ignore[import-untyped]
DataPlaneRestore,
@@ -26,8 +25,7 @@ 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
# v2 reads the backfill's governed_memory_evidence links; v1 missed them.
_METADATA_REPAIR_VERSION = 2
_METADATA_REPAIR_VERSION = 1
_BATCH = 500
@@ -82,7 +80,7 @@ def repair_sdk_metadata_from_bundle(
database_path: Path,
now: datetime | None = None,
) -> MetadataRepairResult:
"""Restore metadata lost by historical cutovers from their verified bundles."""
"""Restore metadata lost by historical cutovers from their verified bundle."""
migration_id = _sdk_migration_id(database_path)
if migration_id is None:
return MetadataRepairResult(None, 0, 0, 0)
@@ -95,21 +93,17 @@ def repair_sdk_metadata_from_bundle(
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")
legacy = (
data_dir
/ "backups"
/ f"{migration_id}-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.
if not legacy.is_file():
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)
mapping = _legacy_metadata(legacy, now=now or datetime.now(UTC))
vector_rows = _repair_vector(data_dir, mapping)
coding_rows = _repair_coding(data_dir, mapping)
result = MetadataRepairResult(
migration_id,
@@ -147,104 +141,40 @@ 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 "evidence" not in tables:
if not {"memory_task_outputs", "governed_memories", "evidence"} <= 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(
outputs: list[tuple[str, str, tuple[str, ...]]] = []
evidence_ids: set[str] = set()
for row in database.execute("SELECT body FROM memory_task_outputs"):
try:
body = json.loads(str(row["body"]))
except (TypeError, ValueError):
continue
legacy_id = body.get("memory_id")
canonical_id = body.get("hy_memory_id")
refs = body.get("support_refs")
if not isinstance(legacy_id, str) or not isinstance(canonical_id, str):
continue
identifiers = (
tuple(
str(ref["evidence_id"])
for ref in (refs if isinstance(refs, list) else ())
for ref in refs
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"]), ())
)
if isinstance(refs, list)
else ()
)
outputs.append((legacy_id, canonical_id, identifiers))
evidence_ids.update(identifiers)
evidence: dict[str, tuple[str, datetime | None]] = {}
wanted = sorted({identifier for refs in links.values() for identifier in refs})
for batch in _batches(wanted):
for batch in _batches(sorted(evidence_ids)):
placeholders = ",".join("?" for _ in batch)
for row in database.execute(
"SELECT evidence_id,source_id,created_at,imported_at "
@@ -253,49 +183,49 @@ def _legacy_pools(path: Path, *, now: datetime) -> dict[str, _Pool]:
):
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,
),
)
pools: dict[str, _Pool] = {}
for canonical_id, refs in links.items():
pool = pools.setdefault(canonical_id, _Pool())
governed = {
str(row["memory_id"]): _plausible_time(
row["created_at"],
fallback=row["updated_at"],
now=now,
)
for row in database.execute(
"SELECT memory_id,created_at,updated_at FROM governed_memories"
)
}
# Several legacy memories can fold into one SDK memory. Pool their evidence
# before deciding, so a disagreeing source cannot be overwritten by whichever
# output happened to come last.
pooled: dict[str, tuple[set[str], list[datetime], list[datetime]]] = {}
for legacy_id, canonical_id, refs in outputs:
sources, times, created = pooled.setdefault(canonical_id, (set(), [], []))
for identifier in refs:
if identifier not in evidence:
continue
source, timestamp = evidence[identifier]
if source and source != "default":
pool.sources.add(source)
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.
times.append(timestamp)
legacy_created = governed.get(legacy_id)
if legacy_created is not None:
created.append(legacy_created)
return {
canonical_id: RepairMetadata(
source=next(iter(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,
source=next(iter(sources)) if len(sources) == 1 else None,
memory_at=int(max(times).timestamp()) if times else None,
created_at=int(min(created).timestamp()) if created else None,
)
for canonical_id, pool in pools.items()
for canonical_id, (sources, times, created) in pooled.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,
*,
@@ -320,11 +250,9 @@ 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 (directory / "chroma.sqlite3").is_file():
if not mapping or not (directory / "chroma.sqlite3").is_file():
return 0
import chromadb
from chromadb.config import Settings
@@ -380,40 +308,11 @@ 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,9 +92,6 @@ 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:
@@ -327,38 +324,6 @@ def parse_sdk_search(raw: object, *, error_code: str) -> tuple[SemanticMemoryHit
return tuple(found)
def library_keyword_hits(raw: object, query: str) -> tuple[tuple[str, ...], int]:
"""Ids in an SDK search window whose text contains every query term.
The local SDK store has no keyword channel (Chroma's keyword search is
empty), so search is pure vector similarity: a word in the library can
score below the floor, and a word in none of it still scores above. The
library search asks the SDK for its most relevant window and keeps only the
rows that actually contain the words, most relevant first. Returns those ids
and how many distinct rows the window held.
"""
rows = _reply_rows(raw, "hy_memory_search_shape_unknown")
terms = query.casefold().split()
seen: set[str] = set()
matched: list[str] = []
for item in sorted(rows, key=_score, reverse=True):
memory_id = item.get("memory_id") or item.get("id")
if not isinstance(memory_id, str) or not memory_id or memory_id in seen:
continue
seen.add(memory_id)
content = item.get("content")
tags = item.get("tags")
text = " ".join(
[
content if isinstance(content, str) else "",
*(tag for tag in (tags if isinstance(tags, list) else ()) if isinstance(tag, str)),
]
).casefold()
if all(term in text for term in terms):
matched.append(memory_id)
return tuple(matched), len(seen)
def parse_sdk_raw_search(raw: object) -> tuple[HySdkRawHit, ...]:
return tuple(
HySdkRawHit.from_sdk_search(row)
@@ -92,8 +92,8 @@ 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 "
("hy_sdk_migration.py", "_legacy_metadata"): Allowed(
"the pre-cutover bundle's copy of the legacy database, read-only, to "
"recover the source and times historical cutovers dropped",
opened_with="?mode=ro",
),
+3 -64
View File
@@ -402,26 +402,16 @@ 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 window's keyword matches; the superseded
# hit matches but is not listed as an active memory.
# `total` counts the bounded relevance window; the superseded
# hit is in it but is not listed as an active memory.
assert body["total"] == 2
assert 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]["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},
@@ -440,54 +430,3 @@ def test_sdk_memory_library_uses_sdk_search_and_skips_empty_rows_without_mutatio
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())
@@ -186,90 +186,6 @@ 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
@@ -1144,24 +1060,10 @@ 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):
listed.append(kwargs)
def list_memories(self, **_kwargs):
return {"vdb": {"memories": rows, "total": 7}}
class Handle:
@@ -1197,32 +1099,19 @@ 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",
params={"source": "cursor", "offset": 100, "limit": 100},
)
response = await client.get("/api/v2/memories/graph")
app.state.sdk_client_handle = original
assert response.status_code == 200
return response.json()
graph = asyncio.run(scenario())
assert listed[0]["include_sources"] == ["cursor"]
assert (listed[0]["offset"], listed[0]["limit"]) == (100, 100)
assert (graph["total_memories"], graph["projected_memories"]) == (7, 4)
assert (graph["total_memories"], graph["projected_memories"]) == (7, 2)
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",
+4 -92
View File
@@ -181,9 +181,8 @@ it("OBS-22: the graph inspector has no editing entry and never says changes cann
expect(screen.queryByText(/暂不能保存更改/)).toBeNull();
});
it("OBS-19: Enter in the memory filter submits it; submitting it again re-reads", async () => {
it("OBS-19: Enter in the memory filter re-reads, exactly like the refresh button", async () => {
const onRetry = vi.fn();
const onQuery = vi.fn();
const page = {
items: memories,
total: memories.length,
@@ -201,7 +200,7 @@ it("OBS-19: Enter in the memory filter submits it; submitting it again re-reads"
loading: false,
failed: false,
stale: false,
onQuery,
onQuery: vi.fn(),
onSource: vi.fn(),
resetPage: vi.fn(),
onPage: vi.fn(),
@@ -212,39 +211,11 @@ it("OBS-19: Enter in the memory filter submits it; submitting it again re-reads"
await act(async () => {});
await userEvent.click(screen.getByRole("button", { name: "刷新地图记忆" }));
expect(onRetry).toHaveBeenCalledTimes(1);
// Typing alone does not search; Enter submits the words.
const input = screen.getByRole("textbox", { name: "筛选地图记忆" });
await userEvent.type(input, "第一");
expect(onQuery).not.toHaveBeenCalled();
await userEvent.type(input, "{Enter}");
expect(onQuery).toHaveBeenCalledWith("第一");
// Submitting the words already applied re-reads, like the refresh button.
await userEvent.clear(input);
await userEvent.type(input, "{Enter}");
expect(onRetry).toHaveBeenCalledTimes(2);
});
it("searches only when 搜索 is pressed, not on every keystroke", async () => {
const onQuery = vi.fn();
const resetPage = vi.fn();
render(
<Host
simplified
libraryView={{
...pagedView({ items: memories, total: 2, offset: 0, limit: 100 }),
onQuery,
resetPage,
}}
/>,
);
await userEvent.type(
screen.getByRole("textbox", { name: "筛选地图记忆" }),
"版本号",
"第一{Enter}",
);
expect(onQuery).not.toHaveBeenCalled();
await userEvent.click(screen.getByRole("button", { name: "搜索" }));
expect(onQuery).toHaveBeenCalledWith("版本号");
expect(resetPage).toHaveBeenCalled();
expect(onRetry).toHaveBeenCalledTimes(2);
});
it("OBS-15: switching memory shows a read in progress instead of the previous memory", async () => {
@@ -540,65 +511,6 @@ it("D96: lists a memory by when its conversation happened, not when it was gener
).toBeTruthy();
});
it("names the SDK's no-source bucket 未识别来源, apart from a source under review", () => {
const items = ["有来源", "无来源"].map((content, i) => ({
...memories[0],
memory_id: `s${i}`,
content,
})) as GovernedMemory[];
render(
<Host
simplified
items={items}
sources={[
{
source_id: "codex",
display_name: "Codex",
authorized: true,
imported_messages: 1,
imported_conversations: 1,
} as SourceResponse,
]}
libraryView={{
...pagedView({
items,
total: 2,
offset: 0,
limit: 100,
source_ids_by_memory: { s0: ["codex"], s1: ["default"] },
search_window: 500,
}),
query: "版本",
unattributedSource: "default",
}}
/>,
);
const list = screen.getByRole("list", { name: "地图记忆列表" });
expect(
within(list).getByRole("button", { name: /有来源/ }).textContent,
).toContain("Codex");
const unattributed = within(list).getByRole("button", { name: /无来源/ });
expect(unattributed.textContent).toContain("未识别来源");
expect(unattributed.textContent).not.toContain("待核对");
expect(screen.getByText("只在最相关的 500 条记忆里查找关键词")).toBeTruthy();
});
it("says a filtered source is empty rather than that nothing matched", () => {
render(
<Host
simplified
items={[]}
libraryView={{
...pagedView({ items: [], total: 0, offset: 0, limit: 100 }),
source: "default",
unattributedSource: "default",
}}
/>,
);
expect(screen.getByText("这个来源下还没有记忆")).toBeTruthy();
expect(screen.queryByText("没有匹配的记忆")).toBeNull();
});
it("D96: with conversations imported and no memory yet, says how many were imported", () => {
render(
<Host
+26 -107
View File
@@ -63,8 +63,6 @@ type Props = Parameters<typeof Workbench>[0] & {
loading: boolean;
failed: boolean;
stale: boolean;
/** The source id the SDK uses for "no source given" (shown as 未识别来源). */
unattributedSource?: string;
onQuery(value: string): void;
onSource(value: string): void;
resetPage(): void;
@@ -170,40 +168,18 @@ export function MemoryMap(props: Props) {
} = props;
const library = props.libraryView;
const serverPaged = library !== undefined;
const unattributedLabel = text("未识别来源");
const graph = useMemo(
() =>
serverPaged
? projectPagedSourceMap(
baseGraph,
library?.page ?? null,
sources,
library?.unattributedSource
? { [library.unattributedSource]: unattributedLabel }
: undefined,
)
? projectPagedSourceMap(baseGraph, library?.page ?? null, sources)
: baseGraph,
[
baseGraph,
serverPaged,
library?.page,
library?.unattributedSource,
sources,
unattributedLabel,
],
[baseGraph, serverPaged, library?.page, sources],
);
const refreshing = !!library?.page && library.loading;
const overlaysSuspended = hidden || !!props.overlayBlocked;
const [axis, setAxis] = useState<MapAxis>("source");
const [groupKey, setGroupKey] = useState("");
const [search, setSearch] = useState("");
// On a server-paged list, typing only edits the words; the search runs when
// the reader submits it (the 搜索 button or Enter), not on every keystroke.
const [searchDraft, setSearchDraft] = useState(library?.query ?? "");
const submittedQuery = library?.query;
useEffect(() => {
if (submittedQuery !== undefined) setSearchDraft(submittedQuery);
}, [submittedQuery]);
const [selectedId, setSelectedId] = useState<string | null>(null);
const [groupNode, setGroupNode] = useState<MemoryGraphNode | null>(null);
const [question, setQuestion] = useState("");
@@ -362,11 +338,7 @@ export function MemoryMap(props: Props) {
const ids = library.page?.source_ids_by_memory?.[memoryId];
return ids?.length
? ids
.map((id) =>
id === library.unattributedSource
? text("未识别来源")
: sourceNameOr(sources, id, text("来源待核对")),
)
.map((id) => sourceNameOr(sources, id, text("来源待核对")))
.join("、")
: text("来源待核对");
}
@@ -584,8 +556,6 @@ export function MemoryMap(props: Props) {
setGroupNode(null);
if (library) {
clear();
// A source picked on the map sets this too; keep both on the one choice.
setGroupKey(key);
library.onSource(key.startsWith("source:") ? key.slice(7) : "");
library.resetPage();
return;
@@ -641,10 +611,6 @@ export function MemoryMap(props: Props) {
(source) =>
source.authorized && groupKey === `source:${source.source_id}`,
)?.display_name ??
(library?.unattributedSource &&
groupKey === `source:${library.unattributedSource}`
? text("未识别来源")
: null) ??
text("全部记忆");
if (
props.simplified &&
@@ -774,7 +740,6 @@ export function MemoryMap(props: Props) {
options={[
{ value: "", label: text("全部应用") },
...(library?.source &&
library.source !== library.unattributedSource &&
!sources.some(
(source) =>
source.source_id === library.source && source.authorized,
@@ -787,22 +752,12 @@ export function MemoryMap(props: Props) {
]
: []),
...(library
? [
...sources
.filter((source) => source.authorized)
.map((source) => ({
value: `source:${source.source_id}`,
label: source.display_name,
})),
...(library.unattributedSource
? [
{
value: `source:${library.unattributedSource}`,
label: text("未识别来源"),
},
]
: []),
]
? sources
.filter((source) => source.authorized)
.map((source) => ({
value: `source:${source.source_id}`,
label: source.display_name,
}))
: groups.map((group) => ({
value: group.key,
label: group.label,
@@ -834,23 +789,7 @@ export function MemoryMap(props: Props) {
))}
</fieldset>
)}
<form
className="map-search"
role="search"
onSubmit={(e) => {
e.preventDefault();
if (!library || libraryPane === "dialogues") return;
const words = searchDraft.trim();
setFilterNotice("");
clear();
if (words === library.query.trim()) {
library.onRetry();
return;
}
library.onQuery(words);
library.resetPage();
}}
>
<div className="map-search">
<Search size={16} aria-hidden="true" />
<input
aria-label={text("筛选地图记忆")}
@@ -866,39 +805,31 @@ export function MemoryMap(props: Props) {
value={
libraryPane === "dialogues"
? dialogueFilter
: library
? searchDraft
: search
: (library?.query ?? search)
}
maxLength={library ? 512 : undefined}
onKeyDown={(e) => {
// Enter that confirms an IME candidate is not a search.
if (e.key === "Enter" && e.nativeEvent.isComposing)
e.preventDefault();
// The refresh button next to this list re-reads the page;
// Enter has to do the same thing, not sit there doing nothing.
if (e.key !== "Enter" || e.nativeEvent.isComposing) return;
e.preventDefault();
library?.onRetry();
}}
onChange={(e) => {
if (libraryPane === "dialogues") {
setDialogueFilter(e.target.value);
return;
}
if (library) {
setSearchDraft(e.target.value);
return;
}
setFilterNotice("");
setSearch(e.target.value);
if (library) {
clear();
library.onQuery(e.target.value);
library.resetPage();
}
}}
/>
{library && libraryPane === "memories" ? (
<button
type="submit"
className="map-search-submit"
disabled={library.loading || hidden}
>
{text("搜索")}
</button>
) : null}
</form>
</div>
{library && libraryPane === "memories" && (
<div className="map-list-heading" aria-live="polite">
<span>
@@ -1107,23 +1038,11 @@ export function MemoryMap(props: Props) {
!library.stale &&
!filtered.length && (
<p className="map-empty">
{library.query
{library.query || library.source
? text("没有匹配的记忆")
: library.source
? text("这个来源下还没有记忆")
: groupKey
? text("本页没有匹配的主题记忆")
: text("还没有已生成的记忆")}
</p>
)}
{libraryPane === "memories" &&
library?.page?.search_window &&
!library.loading &&
!library.failed && (
<p className="map-empty">
{text("只在最相关的 {count} 条记忆里查找关键词", {
count: count(library.page.search_window),
})}
: groupKey
? text("本页没有匹配的主题记忆")
: text("还没有已生成的记忆")}
</p>
)}
{libraryPane === "memories" &&
@@ -218,7 +218,7 @@ it("default map search finds an existing 501st memory rather than declaring no m
fireEvent.change(screen.getByLabelText("筛选地图记忆"), {
target: { value: "第501条独有目标" },
});
fireEvent.submit(screen.getByRole("search"));
fireEvent.keyDown(screen.getByLabelText("筛选地图记忆"), { key: "Enter" });
await waitFor(() =>
expect(
within(screen.getByRole("list", { name: "地图记忆列表" })).queryByText(
@@ -310,7 +310,6 @@ it("OBS-13: the settled-count headline names the filter it is counting", async (
fireEvent.change(screen.getByLabelText("筛选地图记忆"), {
target: { value: "第501条独有目标" },
});
fireEvent.submit(screen.getByRole("search"));
await waitFor(() =>
expect(
screen.getByText(
@@ -321,7 +320,6 @@ it("OBS-13: the settled-count headline names the filter it is counting", async (
fireEvent.change(screen.getByLabelText("筛选地图记忆"), {
target: { value: "" },
});
fireEvent.submit(screen.getByRole("search"));
await chooseSource("codex");
await waitFor(() =>
expect(
@@ -475,7 +473,6 @@ it("distinguishes failed search from empty, then retries the same query", async
fireEvent.change(screen.getByLabelText("筛选地图记忆"), {
target: { value: "目标" },
});
fireEvent.submit(screen.getByRole("search"));
await screen.findByText("记忆读取失败,请重试。");
expect(screen.queryByText("没有匹配的记忆")).toBeNull();
expect(screen.queryByText("还没有已生成的记忆")).toBeNull();
@@ -493,7 +490,6 @@ it("distinguishes failed search from empty, then retries the same query", async
fireEvent.change(screen.getByLabelText("筛选地图记忆"), {
target: { value: "确实没有这项" },
});
fireEvent.submit(screen.getByRole("search"));
await screen.findByText("没有匹配的记忆");
expect(screen.queryByText("还没有已生成的记忆")).toBeNull();
expect(
@@ -514,7 +510,6 @@ it("aborts an older query after a source change and never replaces the newer res
fireEvent.change(screen.getByLabelText("筛选地图记忆"), {
target: { value: "旧搜索" },
});
fireEvent.submit(screen.getByRole("search"));
await waitFor(() => expect(resolve).toHaveLength(1));
const signals = vi
.mocked(api.getMemoryLibrary)
@@ -526,7 +521,6 @@ it("aborts an older query after a source change and never replaces the newer res
fireEvent.change(screen.getByLabelText("筛选地图记忆"), {
target: { value: "目标" },
});
fireEvent.submit(screen.getByRole("search"));
await within(screen.getByRole("list", { name: "地图记忆列表" })).findByText(
"第501条独有目标",
);
@@ -559,7 +553,6 @@ it("hidden map aborts page reads and does not accept late body or poll until rea
fireEvent.change(screen.getByLabelText("筛选地图记忆"), {
target: { value: "迟到" },
});
fireEvent.submit(screen.getByRole("search"));
await act(async () => {});
const signals = vi
.mocked(api.getMemoryLibrary)
@@ -618,7 +611,6 @@ it("keeps the populated map while a search is in flight and does not treat it as
fireEvent.change(screen.getByLabelText("筛选地图记忆"), {
target: { value: "onememory" },
});
fireEvent.submit(screen.getByRole("search"));
await act(async () => {});
expect(screen.queryByText("正在读取…")).toBeNull();
expect(screen.getByText("已沉淀 501 条 · 当前展示 100 条")).toBeTruthy();
@@ -708,7 +700,6 @@ it("explicit refresh keeps the search scope and replaces the page with the lates
fireEvent.change(screen.getByLabelText("筛选地图记忆"), {
target: { value: "目标" },
});
fireEvent.submit(screen.getByRole("search"));
const list = screen.getByRole("list", { name: "地图记忆列表" });
await within(list).findByText("第501条独有目标");
vi.mocked(api.getMemoryLibrary).mockResolvedValueOnce({
@@ -46,19 +46,12 @@ 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">
@@ -66,30 +59,9 @@ 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>
),
}));
@@ -165,61 +137,13 @@ 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"],
source: undefined,
query: undefined,
offset: 0,
limit: 100,
},
expect.any(AbortSignal),
);
expect(api.sdkGraph).toHaveBeenCalledWith(
{ source: undefined, offset: 0, limit: 100 },
{ statuses: ["active"], offset: 0, limit: 300 },
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"));
+85 -101
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 } from "./sdk-memory-map";
import { sourceNameOr, UNATTRIBUTED_SOURCE_ID } from "./source-label";
import { sdkDetail, sdkLibraryPage, sdkMapMemory } from "./sdk-memory-map";
import { sourceNameOr } from "./source-label";
import { traceUiAction } from "./ui-trace";
import { useMemoryLibrary } from "./use-memory-library";
import { isDefiniteRefusal, writeTraceStatus } from "./write-outcome";
@@ -303,30 +303,25 @@ 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. 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);
// The map on SDK authority. Legacy reads (library page, governed graph)
// are refused there, so it draws SDK memories and the v2 graph instead.
const [sdkMap, setSdkMap] = useState<{
memories: GovernedMemory[];
graph: MemoryGraphResponse;
} | null>(null);
const [sdkMapFailed, setSdkMapFailed] = useState(false);
// A write on SDK authority has no governed refresh to ride on; bumping this
// re-reads the SDK page and graph after an edit or a delete.
const [sdkMapRevision, setSdkMapRevision] = useState(0);
const bumpSdkMap = useCallback(() => setSdkMapRevision((v) => v + 1), []);
// The map's left list pages through the SDK library the same way it pages
// through the governed one: the list is always one page, and the count above
// it is the library's total, not the page's length.
const [sdkLibrary, setSdkLibrary] = useState<{
page: MemoryLibraryPage | null;
loading: boolean;
failed: boolean;
/** 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: "",
});
}>({ page: null, loading: false, failed: false });
const [sdkLibraryQuery, setSdkLibraryQuery] = useState("");
const [sdkLibrarySource, setSdkLibrarySource] = useState("");
const [sdkLibraryOffset, setSdkLibraryOffset] = useState(0);
@@ -342,83 +337,80 @@ export function MemorySpace({
void sdkMapRevision;
let cancelled = false;
let controller: AbortController | null = null;
const source = sdkLibrarySource || undefined;
const query = sdkLibraryQuery || undefined;
const read = async (first: boolean) => {
const read = async () => {
controller?.abort();
const request = new AbortController();
controller = request;
if (first) setSdkLibrary((current) => ({ ...current, loading: true }));
try {
const [page, graph] = await Promise.all([
getSdkMemoryLibrary(
{
statuses: ["active"],
source,
query,
offset: sdkLibraryOffset,
limit: 100,
},
{ statuses: ["active"], offset: 0, limit: 300 },
request.signal,
),
// The overlay is decoration: the list stands without it.
getSdkMemoryGraph(
{ source, offset: query ? 0 : sdkLibraryOffset, limit: 100 },
request.signal,
).catch(() => null),
getSdkMemoryGraph(request.signal),
]);
if (cancelled) return;
setSdkLibrary({
page: sdkLibraryPage(page),
loading: false,
failed: false,
settledSource: sdkLibrarySource,
settledQuery: sdkLibraryQuery,
});
setSdkGraph(graph);
setSdkMap({ memories: page.memories.map(sdkMapMemory), graph });
setSdkMapFailed(false);
} catch {
if (cancelled || request.signal.aborted) return;
setSdkLibrary((current) => ({
...current,
loading: false,
failed: true,
}));
if (!cancelled && !request.signal.aborted) setSdkMapFailed(true);
}
};
void read(true);
const timer = setInterval(() => void read(false), 30_000);
void read();
const timer = setInterval(() => void read(), 30_000);
return () => {
cancelled = true;
clearInterval(timer);
controller?.abort();
};
}, [sdkMapVisible, sdkMapRevision]);
useEffect(() => {
if (memoryAuthority !== "sdk" || !simplified || !active) return;
if (displayedMode !== "map") return;
void sdkMapRevision;
let cancelled = false;
const controller = new AbortController();
setSdkLibrary((current) => ({ ...current, loading: true }));
void getSdkMemoryLibrary(
{
statuses: ["active"],
source: sdkLibrarySource || undefined,
query: sdkLibraryQuery || undefined,
offset: sdkLibraryOffset,
limit: 100,
},
controller.signal,
)
.then((page) => {
if (cancelled) return;
setSdkLibrary({
page: sdkLibraryPage(page),
loading: false,
failed: false,
});
})
.catch(() => {
if (cancelled || controller.signal.aborted) return;
setSdkLibrary((current) => ({
...current,
loading: false,
failed: true,
}));
});
return () => {
cancelled = true;
controller.abort();
};
}, [
sdkMapVisible,
sdkMapRevision,
memoryAuthority,
simplified,
active,
displayedMode,
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);
@@ -1629,22 +1621,20 @@ export function MemorySpace({
</h1>
<span className="memory-loaded-count">
{memoryAuthority === "sdk" && simplified && !management
? sdkLibrary.page
? tn(
"memory:已沉淀 {count} 条{filter} · 当前展示 {shown} 条",
sdkLibrary.page.total,
{
filter: sdkFilterLabel
? t("memory:({filter})", { filter: sdkFilterLabel })
: "",
shown: new Intl.NumberFormat(locale).format(
sdkLibrary.page.items.length,
),
},
)
: // The list below says a failed read once, with its retry.
sdkLibrary.failed
? null
? 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,
),
},
)
: t("memory:正在读取…")
: memoryAuthority === "sdk"
? null
@@ -1681,9 +1671,6 @@ 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) => {
@@ -1861,11 +1848,9 @@ export function MemorySpace({
<ModelPrompt
processing={processing}
hasMemories={
memoryAuthority === "sdk"
? (sdkLibrary.page?.total ?? 0) > 0
: simplified
? knownMapTotal.current > 0 || (mapLibrary.page?.total ?? 0) > 0
: memories.length > 0
simplified
? knownMapTotal.current > 0 || (mapLibrary.page?.total ?? 0) > 0
: memories.length > 0
}
onConfigureModel={onConfigureModel}
/>
@@ -2031,7 +2016,6 @@ export function MemorySpace({
loading: sdkLibrary.loading,
failed: sdkLibrary.failed,
stale: false,
unattributedSource: UNATTRIBUTED_SOURCE_ID,
onQuery: setSdkLibraryQuery,
onSource: setSdkLibrarySource,
resetPage: () => setSdkLibraryOffset(0),
@@ -2064,14 +2048,14 @@ export function MemorySpace({
hidden={!active || displayedMode !== "map"}
memories={
memoryAuthority === "sdk"
? (sdkLibrary.page?.items ?? [])
? (sdkMap?.memories ?? [])
: simplified
? (mapLibrary.page?.items ?? [])
: memories
}
graph={
memoryAuthority === "sdk"
? sdkGraph
? (sdkMap?.graph ?? null)
: simplified && sourceSnapshotStale
? null
: graph
@@ -2082,7 +2066,7 @@ export function MemorySpace({
onViewHistory={simplified ? onConnect : () => setMode("history")}
loaded={
memoryAuthority === "sdk"
? sdkLibrary.page !== null
? sdkMap !== null
: simplified
? mapLibrary.page !== null
: loaded
@@ -2093,7 +2077,7 @@ export function MemorySpace({
!active ||
deleteCount > 0 ||
(memoryAuthority === "sdk"
? sdkLibrary.page === null
? sdkMap === 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, UNATTRIBUTED_SOURCE_ID } from "./source-label";
import { sourceNameOr } from "./source-label";
import { traceUiAction } from "./ui-trace";
import { isDefiniteRefusal, writeTraceStatus } from "./write-outcome";
import "./selected-memory.css";
@@ -73,11 +73,11 @@ export function SelectedMemoryDetail({
: memory.status === "superseded"
? t("selectedMemory.status.superseded")
: t("selectedMemory.status.unknown");
const sourceId = detail.source_id ?? evidence?.source_id;
const source =
detail.sdk_status && sourceId === UNATTRIBUTED_SOURCE_ID
? t("selectedMemory.sourceUnattributed")
: sourceNameOr(sources, sourceId, t("selectedMemory.sourceUnknown"));
const source = sourceNameOr(
sources,
detail.source_id ?? evidence?.source_id,
t("selectedMemory.sourceUnknown"),
);
const dirty = page === "edit" && draft !== memory.content;
useEffect(() => {
if (page === "edit" && memory.revision > base) setConflictFound(true);
+1 -10
View File
@@ -1169,21 +1169,12 @@ export async function getMemory(memoryId: string): Promise<MemoryDetail> {
let pendingMemoryGraph: Promise<MemoryGraphResponse> | null = null;
/** The memory map on SDK authority: SDK memories plus the graph overlay. */
/** The SDK map for one list page: the same source and window the list reads. */
export async function getSdkMemoryGraph(
{
source,
offset = 0,
limit = 100,
}: { source?: string; offset?: number; limit?: number } = {},
signal?: AbortSignal,
): Promise<MemoryGraphResponse> {
const response = await client.get<MemoryGraphResponse>(
"/api/v2/memories/graph",
{
params: { limit, offset, ...(source ? { source } : {}) },
signal,
},
{ params: { limit: 300 }, signal },
);
return response.data;
}
-6
View File
@@ -54,12 +54,6 @@ describe("clockStamp", () => {
expect(clockStamp(value, "es")).toContain("19");
});
it("names the year of a date in another year", () => {
expect(clockStamp("2254-01-13T13:54:00Z", "zh-CN")).toContain("2254");
expect(clockStamp("2025-12-31T12:00:00Z", "es")).toContain("2025");
expect(clockStamp("2026-08-03T15:07:00Z", "zh-CN")).not.toContain("2026");
});
it("formats another date with month and day, without a today word", () => {
const value = "2026-08-03T15:07:00Z";
const es = clockStamp(value, "es");
-3
View File
@@ -34,10 +34,7 @@ export function clockStamp(
date.getDate() === now.getDate()
)
return translate(locale, "clock.today", { clock });
// Another year carries its year, as the memory list's day stamp does: a
// bare "01/13" for 2254-01-13 reads as this January.
return new Intl.DateTimeFormat(locale, {
...(date.getFullYear() !== now.getFullYear() ? { year: "numeric" } : {}),
month: "2-digit",
day: "2-digit",
hour: "2-digit",
-17
View File
@@ -689,23 +689,6 @@ button.rail-button.active::before {
background: transparent;
font-size: 12px;
}
.map-search input {
flex: 1;
}
.map-search-submit {
flex: none;
padding: 4px 10px;
border: 0;
border-radius: 6px;
color: #fff;
background: #3d5a70;
font-size: 12px;
cursor: pointer;
}
.map-search-submit:disabled {
opacity: 0.5;
cursor: default;
}
.map-search:focus-within,
.map-question:focus-within {
outline: 2px solid #7892a6;
+1 -3
View File
@@ -23,8 +23,6 @@ export function projectPagedSourceMap(
graph: MemoryGraphResponse | null,
page: MemoryLibraryPage | null,
sources: SourceResponse[],
/** Names for source ids that are not in the catalog (e.g. 未识别来源). */
labels: Record<string, string> = {},
): MemoryGraphResponse {
const nodes = new Map<string, MemoryGraphNode>();
const edges = new Map<string, MemoryGraphEdge>();
@@ -95,7 +93,7 @@ export function projectPagedSourceMap(
node_type: "source",
memory_id: null,
source_id: id,
label: catalog.get(id)?.display_name || labels[id] || id,
label: catalog.get(id)?.display_name || id,
kind: null,
status: null,
confidence: null,
@@ -1,87 +0,0 @@
import type { SdkMemoryRecord } from "@one-memory/contracts";
import { afterEach, beforeEach, expect, it, vi } from "vitest";
import { projectPagedSourceMap } from "./memory-map-model";
import { sdkDetail, sdkLibraryPage } from "./sdk-memory-map";
const NOW = Date.UTC(2026, 9, 25) / 1_000;
beforeEach(() => {
vi.useFakeTimers();
vi.setSystemTime(NOW * 1_000);
});
afterEach(() => vi.useRealTimers());
const record = (extra: Partial<SdkMemoryRecord>): SdkMemoryRecord => ({
memory_id: "m",
content: "memory",
layer: "l2_fact",
status: "active",
is_latest: true,
supersedes: [],
superseded_by: [],
tags: [],
owner: null,
source_raw_ids: [],
user_id: "local",
agent_id: "all-apps",
session_id: "s1",
source: "cursor",
memory_at: null,
gmt_created: NOW - 86_400,
...extra,
});
it("keeps the SDK's no-source bucket as a source the list and map can name", () => {
const page = sdkLibraryPage({
memories: [record({ memory_id: "d", source: "default" })],
total: 1,
limit: 100,
offset: 0,
});
expect(page.source_ids_by_memory).toEqual({ d: ["default"] });
expect(sdkDetail(record({ source: "default" })).source_id).toBe("default");
const drawn = projectPagedSourceMap(null, page, [], {
default: "未识别来源",
});
expect(
drawn.nodes.find((node) => node.node_id === "source:default")?.label,
).toBe("未识别来源");
});
it("does not present an impossible conversation time as when it happened", () => {
const future = Date.UTC(2254, 0, 13) / 1_000;
const page = sdkLibraryPage({
memories: [record({ memory_id: "f", memory_at: future })],
total: 1,
limit: 100,
offset: 0,
});
expect(page.source_time_by_memory?.f).toEqual({
at: new Date((NOW - 86_400) * 1_000).toISOString(),
basis: "import",
});
expect(sdkDetail(record({ memory_at: future })).sdk_memory_at).toBeNull();
const real = NOW - 3 * 86_400;
expect(
sdkLibraryPage({
memories: [record({ memory_id: "r", memory_at: real })],
total: 1,
limit: 100,
offset: 0,
}).source_time_by_memory?.r.basis,
).toBe("conversation");
});
it("carries a full search window through to the list", () => {
expect(
sdkLibraryPage({
memories: [],
total: 0,
limit: 100,
offset: 0,
search_window: 500,
}).search_window,
).toBe(500);
});
+8 -30
View File
@@ -21,28 +21,6 @@ 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
@@ -94,8 +72,9 @@ export function sdkDetail(record: SdkMemoryRecord): MemoryDetail {
history: [],
index_sync: null,
sdk_status: record.status,
source_id: sourceOf(record),
sdk_memory_at: stamp(plausible(record.memory_at)) || null,
source_id:
record.source && record.source !== "default" ? record.source : null,
sdk_memory_at: stamp(record.memory_at) || null,
sdk_created_at: stamp(record.gmt_created) || null,
};
}
@@ -115,13 +94,13 @@ export function sdkLibraryPage(page: SdkMemoryPage): MemoryLibraryPage {
> = {};
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;
if (record.source && record.source !== "default")
sourceIds[record.memory_id] = [record.source];
const at = record.memory_at ?? record.gmt_created;
if (typeof at !== "number" || !Number.isFinite(at)) continue;
sourceTime[record.memory_id] = {
at: new Date(at * 1_000).toISOString(),
basis: conversation !== null ? "conversation" : "import",
basis: record.memory_at ? "conversation" : "import",
};
}
return {
@@ -131,6 +110,5 @@ 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,13 +9,6 @@ const INTERNAL_SOURCE_NAMES: Record<string, string> = {
"memory-content-export": "导入的已有记忆",
};
/**
* The source the SDK records when a writer named none. The legacy-to-SDK
* backfill wrote every legacy memory this way, so it is a real, filterable
* bucket ("未识别来源"), not a source still being checked.
*/
export const UNATTRIBUTED_SOURCE_ID = "default";
type NamedSource = Pick<SourceResponse, "source_id" | "display_name">;
/** The name to show for a source id, falling back to the id itself. */
+1 -1
View File
@@ -2,7 +2,7 @@
"name": "one-memory",
"version": "1.0.5",
"release": {
"build_number": 22,
"build_number": 24,
"macos_min_version": "13.0",
"app_update": "manual",
"runtime_manifest_schema": 1,
-4
View File
@@ -470,9 +470,6 @@ export interface MemoryLibraryPage {
* import): a partial snapshot, not an authoritative view. Show "importing"
* and retry; never render this state as a failure. */
stale?: boolean;
/** Set on a search whose relevance window was full: matches were looked for
* only among this many most relevant memories, so `total` may undercount. */
search_window?: number | null;
}
export interface MemoryLibraryQuery {
@@ -563,7 +560,6 @@ export interface SdkMemoryPage {
total: number;
limit: number;
offset: number;
search_window?: number | null;
}
export interface SdkUserProfileResponse {
@@ -317,39 +317,6 @@ it("selects SDK Context v2 after cutover and passes native fields unchanged", as
);
});
it.each([
[10, 200],
[11, 502],
])(
"accepts up to Core's v2 default of 10 items when no limit is sent (%i items)",
async (count, status) => {
const f = await fixture();
const items = Array.from({ length: count }, (_, index) => ({
kind: "memory",
memory_id: `memory-${index}`,
content: "m",
layer: "l2_fact",
status: "active",
source: "codex",
updated_at: "2026-10-21T00:00:00Z",
score: 0.5,
truncated: false,
}));
f.setAuthority("sdk");
f.respond({
schema_version: 2,
request_id: "sdk-request",
state: "grounded",
items,
used_characters: count,
truncated: false,
total_ms: 1,
});
expect((await f.post({ query: "original" })).status).toBe(status);
},
);
it("does not fall back to legacy context when the selected SDK response is invalid", async () => {
const f = await fixture();
f.setAuthority("sdk");
@@ -74,7 +74,7 @@ export const CONTEXT_INPUT_SCHEMA = {
description:
"Filters raw only, within already granted sources; does not change memory scope.",
},
limit: { type: "integer", minimum: 1, maximum: 20, default: 10 },
limit: { type: "integer", minimum: 1, maximum: 20, default: 8 },
character_budget: {
type: "integer",
minimum: 200,
@@ -285,10 +285,8 @@ export function isContextResponseForRequest(
): value is HostContextSearchResponse {
if (!isHostContextSearchResponse(value)) return false;
const kinds = request.kinds ?? ["raw", "memory"];
// Core's default item cap: 8 on v1, 10 on SDK-native v2.
const limit = request.limit ?? (isSdkContextSearchResponse(value) ? 10 : 8);
return (
value.items.length <= limit &&
value.items.length <= (request.limit ?? 8) &&
value.used_characters <= (request.character_budget ?? 4000) &&
value.items.every(
(item) =>
@@ -83,7 +83,6 @@ export const currentChineseMessages = {
"selectedMemory.status.archived": "已归档",
"selectedMemory.status.unknown": "状态待核对",
"selectedMemory.sourceUnknown": "来源待核对",
"selectedMemory.sourceUnattributed": "未识别来源",
"selectedMemory.discardDraft": "放弃尚未保存的修改?",
"selectedMemory.newVersionReadFailed": "暂时无法读取新版本,草稿已保留。",
"selectedMemory.updateRejected":
@@ -223,7 +222,6 @@ 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.",
@@ -377,7 +375,6 @@ 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,10 +1571,6 @@ const baseEnglishMessages = {
"memory:全部记忆": "All memories",
"memory:来源待核对": "Source needs review",
"memory:所选来源待核对": "Selected source needs review",
"memory:未识别来源": "Unidentified source",
"memory:这个来源下还没有记忆": "No memories from this source yet",
"memory:只在最相关的 {count} 条记忆里查找关键词":
"Keywords are matched only among the {count} most relevant memories",
"memory:未知来源": "Unknown source",
"memory:记忆读取失败": "Could not load memories",
"memory:正在读取…": "Loading…",
@@ -146,10 +146,6 @@ export const memoryChineseMessages = {
"memory:全部记忆": "全部记忆",
"memory:来源待核对": "来源待核对",
"memory:所选来源待核对": "所选来源待核对",
"memory:未识别来源": "未识别来源",
"memory:这个来源下还没有记忆": "这个来源下还没有记忆",
"memory:只在最相关的 {count} 条记忆里查找关键词":
"只在最相关的 {count} 条记忆里查找关键词",
"memory:未知来源": "未知来源",
"memory:记忆读取失败": "记忆读取失败",
"memory:正在读取…": "正在读取…",
@@ -542,10 +538,6 @@ export const memorySpanishMessages: Record<
"memory:全部记忆": "Todas las memorias",
"memory:来源待核对": "Origen sin verificar",
"memory:所选来源待核对": "Origen seleccionado sin verificar",
"memory:未识别来源": "Origen no identificado",
"memory:这个来源下还没有记忆": "Aún no hay recuerdos de este origen",
"memory:只在最相关的 {count} 条记忆里查找关键词":
"Las palabras clave solo se buscan entre los {count} recuerdos más relevantes",
"memory:未知来源": "Origen desconocido",
"memory:记忆读取失败": "Error al leer las memorias",
"memory:正在读取…": "Leyendo…",