Compare commits
2
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
71854a3510 | ||
|
|
fcb1bcd866 |
No files matched your search
@@ -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
|
||||
|
||||
@@ -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",
|
||||
),
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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,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"));
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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");
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
@@ -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,
|
||||
};
|
||||
}
|
||||
@@ -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. */
|
||||
|
||||
@@ -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…",
|
||||
|
||||
Reference in new issue
Block a user