diff --git a/memhub/apps/core/src/one_memory/api.py b/memhub/apps/core/src/one_memory/api.py index daea38c..eeaf98a 100644 --- a/memhub/apps/core/src/one_memory/api.py +++ b/memhub/apps/core/src/one_memory/api.py @@ -12,7 +12,6 @@ from collections.abc import ( Awaitable, Callable, Iterable, - Mapping, MutableMapping, ) from contextlib import asynccontextmanager, nullcontext, suppress @@ -104,6 +103,7 @@ from one_memory.hy_sdk_types import ( HySdkMemoryRecord, HySdkRawPage, HySdkRawRecord, + HySdkReadError, HySdkUserProfile, parse_sdk_search, ) @@ -3081,13 +3081,8 @@ def create_app( offset=0, order="desc", ) - page = HySdkMemoryPage.from_sdk( - raw, - limit=1, - offset=0, - skip_invalid=True, - ) - _log_invalid_sdk_memories(raw) + page, skipped = HySdkMemoryPage.from_sdk_skipping_invalid(raw, limit=1, offset=0) + _log_skipped_sdk_memories(skipped) except Exception as error: raise _sdk_http_error(error, "hy_memory_list_failed") from None return SdkHomeSummary( @@ -3172,7 +3167,9 @@ def create_app( min_score=sdk_relevance_floor(), schema_min_score=sdk_relevance_floor(), schema_limit=fetch, - intention_limit=fetch, + # The library lists stored memories; proactive intentions + # are a recall-time lane the list never shows. + intention_limit=0, scene="normal", reader=HY_MEMORY_READER, ) @@ -3180,27 +3177,22 @@ def create_app( search_raw, error_code="hy_memory_search_shape_unknown", ) - selected = hits[offset : offset + limit] - rows = await asyncio.gather( - *( - asyncio.to_thread(client.get, hit.memory_id) - for hit in selected - ) + rows = await _get_sdk_rows( + client, [hit.memory_id for hit in hits[offset : offset + limit]] ) memories: list[HySdkMemoryRecord] = [] - invalid_count = 0 + skipped = 0 for row in rows: if row is None: continue try: - memories.append(HySdkMemoryRecord.from_sdk(row)) - except HySdkError: - invalid_count += 1 - if invalid_count: - logger.warning( - "sdk_memory_invalid_rows_skipped count=%d", - invalid_count, - ) + record = HySdkMemoryRecord.from_sdk(row) + except HySdkReadError: + skipped += 1 + continue + if record.status == "active": + memories.append(record) + _log_skipped_sdk_memories(skipped) return HySdkMemoryPage( memories=tuple(memories), total=len(hits), @@ -3215,13 +3207,10 @@ def create_app( offset=offset, limit=limit, ) - page = HySdkMemoryPage.from_sdk( - raw, - limit=limit, - offset=offset, - skip_invalid=True, + page, skipped = HySdkMemoryPage.from_sdk_skipping_invalid( + raw, limit=limit, offset=offset ) - _log_invalid_sdk_memories(raw) + _log_skipped_sdk_memories(skipped) return page except HTTPException: raise @@ -3247,13 +3236,8 @@ def create_app( raw = await asyncio.to_thread( client.list_memories, user_id="local", limit=limit, offset=0, order="desc" ) - page = HySdkMemoryPage.from_sdk( - raw, - limit=limit, - offset=0, - skip_invalid=True, - ) - _log_invalid_sdk_memories(raw) + page, skipped = HySdkMemoryPage.from_sdk_skipping_invalid(raw, limit=limit, offset=0) + _log_skipped_sdk_memories(skipped) except Exception as error: raise _sdk_http_error(error, "hy_memory_list_failed") from None vdb = raw.get("vdb") if isinstance(raw, dict) else None @@ -3281,9 +3265,7 @@ def create_app( nodes[source_node_id] = MemoryGraphNode( node_id=source_node_id, node_type="source", - label=( - registration.descriptor.display_name if registration else memory.source - ), + label=(registration.descriptor.display_name if registration else memory.source), source_id=memory.source, ) edges.append( @@ -5661,29 +5643,24 @@ def _sdk_client_handle(app: FastAPI) -> SdkClientHandle: return handle -def _log_invalid_sdk_memories(raw: object) -> None: - count = _invalid_sdk_memory_count(raw) +def _log_skipped_sdk_memories(count: int) -> None: if count: logger.warning("sdk_memory_invalid_rows_skipped count=%d", count) -def _invalid_sdk_memory_count(raw: object) -> int: - if not isinstance(raw, Mapping): - return 0 - found = 0 - for bucket_name, rows_name in (("vdb", "memories"), ("graph", "nodes")): - bucket = raw.get(bucket_name) - if not isinstance(bucket, Mapping): - continue - rows = bucket.get(rows_name) - if not isinstance(rows, list): - continue - for row in rows: - try: - HySdkMemoryRecord.from_sdk(row) - except HySdkError: - found += 1 - return found +# `client.get` funnels into the SDK's single loop thread, so more workers only +# queue there while holding the default executor every other request needs. +_SDK_GET_CONCURRENCY = 8 + + +async def _get_sdk_rows(client: Any, memory_ids: list[str]) -> list[object]: + gate = asyncio.Semaphore(_SDK_GET_CONCURRENCY) + + async def one(memory_id: str) -> object: + async with gate: + return await asyncio.to_thread(client.get, memory_id) + + return list(await asyncio.gather(*(one(memory_id) for memory_id in memory_ids))) async def _get_sdk_memory( diff --git a/memhub/apps/core/src/one_memory/hy_sdk_migration.py b/memhub/apps/core/src/one_memory/hy_sdk_migration.py index 8e674d9..2e4f3a0 100644 --- a/memhub/apps/core/src/one_memory/hy_sdk_migration.py +++ b/memhub/apps/core/src/one_memory/hy_sdk_migration.py @@ -10,6 +10,7 @@ from __future__ import annotations import json import sqlite3 from collections.abc import Iterator +from contextlib import closing from dataclasses import dataclass from datetime import UTC, datetime, timedelta from pathlib import Path @@ -83,10 +84,7 @@ def repair_sdk_metadata_from_bundle( migration_id = _sdk_migration_id(database_path) if migration_id is None: return MetadataRepairResult(None, 0, 0, 0) - receipt = ( - data_dir - / f".sdk-metadata-repair-v{_METADATA_REPAIR_VERSION}-{migration_id}.json" - ) + receipt = data_dir / f".sdk-metadata-repair-v{_METADATA_REPAIR_VERSION}-{migration_id}.json" if receipt.is_file(): document = json.loads(receipt.read_text(encoding="utf-8")) return MetadataRepairResult( @@ -144,13 +142,11 @@ def _sdk_migration_id(database_path: Path) -> str | None: def _legacy_metadata(path: Path, *, now: datetime) -> dict[str, RepairMetadata]: - with sqlite3.connect(f"file:{path}?mode=ro", uri=True) as database: + 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'" - ) + for row in database.execute("SELECT name FROM sqlite_master WHERE type='table'") } if not {"memory_task_outputs", "governed_memories", "evidence"} <= tables: return {} @@ -170,8 +166,7 @@ def _legacy_metadata(path: Path, *, now: datetime) -> dict[str, RepairMetadata]: tuple( str(ref["evidence_id"]) for ref in refs - if isinstance(ref, dict) - and isinstance(ref.get("evidence_id"), str) + if isinstance(ref, dict) and isinstance(ref.get("evidence_id"), str) ) if isinstance(refs, list) else () @@ -204,21 +199,31 @@ def _legacy_metadata(path: Path, *, now: datetime) -> dict[str, RepairMetadata]: "SELECT memory_id,created_at,updated_at FROM governed_memories" ) } - result: dict[str, RepairMetadata] = {} + # 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: - resolved = [evidence[identifier] for identifier in refs if identifier in evidence] - sources = {source for source, _timestamp in resolved if source and source != "default"} - times = [timestamp for _source, timestamp in resolved if timestamp is not None] - result[canonical_id] = RepairMetadata( + sources, times, created = pooled.setdefault(canonical_id, (set(), [], [])) + for identifier in refs: + if identifier not in evidence: + continue + source, timestamp = evidence[identifier] + if source and source != "default": + sources.add(source) + if timestamp is not None: + times.append(timestamp) + legacy_created = governed.get(legacy_id) + if legacy_created is not None: + created.append(legacy_created) + return { + canonical_id: RepairMetadata( source=next(iter(sources)) if len(sources) == 1 else None, memory_at=int(max(times).timestamp()) if times else None, - created_at=( - int(governed[legacy_id].timestamp()) - if governed.get(legacy_id) is not None - else None - ), + created_at=int(min(created).timestamp()) if created else None, ) - return result + for canonical_id, (sources, times, created) in pooled.items() + } def _plausible_time( @@ -256,50 +261,55 @@ def _repair_vector( path=str(directory), settings=Settings(anonymized_telemetry=False), ) - repaired = 0 - ids = tuple(mapping) - for collection in client.list_collections(): - for batch in _batches(ids): - page = collection.get(ids=list(batch), include=["metadatas"]) - updated_ids: list[str] = [] - updated_metadata: list[dict[str, object]] = [] - for memory_id, metadata in zip( - page.get("ids") or (), - page.get("metadatas") or (), - strict=True, - ): - if not isinstance(metadata, dict): - continue - desired = mapping.get(str(memory_id)) - if desired is None: - continue - next_metadata = dict(metadata) - changed = False - if desired.source and next_metadata.get("source") in { - None, - "", - "default", - }: - next_metadata["source"] = desired.source - changed = True - if ( - desired.memory_at is not None - and next_metadata.get("memory_at") != desired.memory_at + # The SDK client is not open yet (startup has not created its handle), so + # this is the only writer; release the store before the SDK takes it. + try: + repaired = 0 + ids = tuple(mapping) + for collection in client.list_collections(): + for batch in _batches(ids): + page = collection.get(ids=list(batch), include=["metadatas"]) + updated_ids: list[str] = [] + updated_metadata: list[dict[str, object]] = [] + for memory_id, metadata in zip( + page.get("ids") or (), + page.get("metadatas") or (), + strict=True, ): - next_metadata["memory_at"] = desired.memory_at - changed = True - if ( - desired.created_at is not None - and next_metadata.get("gmt_created") != desired.created_at - ): - next_metadata["gmt_created"] = desired.created_at - changed = True - if changed: - updated_ids.append(str(memory_id)) - updated_metadata.append(next_metadata) - if updated_ids: - collection.update(ids=updated_ids, metadatas=updated_metadata) - repaired += len(updated_ids) + if not isinstance(metadata, dict): + continue + desired = mapping.get(str(memory_id)) + if desired is None: + continue + next_metadata = dict(metadata) + changed = False + if desired.source and next_metadata.get("source") in { + None, + "", + "default", + }: + next_metadata["source"] = desired.source + changed = True + if ( + desired.memory_at is not None + and next_metadata.get("memory_at") != desired.memory_at + ): + next_metadata["memory_at"] = desired.memory_at + changed = True + if ( + desired.created_at is not None + and next_metadata.get("gmt_created") != desired.created_at + ): + next_metadata["gmt_created"] = desired.created_at + changed = True + if changed: + updated_ids.append(str(memory_id)) + updated_metadata.append(next_metadata) + if updated_ids: + collection.update(ids=updated_ids, metadatas=updated_metadata) + repaired += len(updated_ids) + finally: + client.close() return repaired @@ -313,12 +323,10 @@ def _repair_coding( path = root / name if not path.is_file(): continue - with sqlite3.connect(path) as database: + with closing(sqlite3.connect(path)) as database: tables = { str(row[0]) - for row in database.execute( - "SELECT name FROM sqlite_master WHERE type='table'" - ) + for row in database.execute("SELECT name FROM sqlite_master WHERE type='table'") } for table, identifier in ( ("coding_memory_meta", "experience_id"), @@ -351,8 +359,7 @@ def _repair_coding( continue values.append(memory_id) database.execute( - f"UPDATE {table} SET {','.join(updates)} " - f"WHERE {identifier}=?", + f"UPDATE {table} SET {','.join(updates)} WHERE {identifier}=?", values, ) repaired += 1 diff --git a/memhub/apps/core/src/one_memory/hy_sdk_types.py b/memhub/apps/core/src/one_memory/hy_sdk_types.py index 604234c..4e46f9d 100644 --- a/memhub/apps/core/src/one_memory/hy_sdk_types.py +++ b/memhub/apps/core/src/one_memory/hy_sdk_types.py @@ -94,14 +94,30 @@ class HySdkMemoryPage(BaseModel): offset: int = Field(ge=0) @classmethod - def from_sdk( + def from_sdk(cls, raw: object, *, limit: int, offset: int) -> HySdkMemoryPage: + page, _skipped = cls._parse(raw, limit=limit, offset=offset, skip_invalid=False) + return page + + @classmethod + def from_sdk_skipping_invalid( + cls, raw: object, *, limit: int, offset: int + ) -> tuple[HySdkMemoryPage, int]: + """Parse a page, leaving out rows outside the public record contract. + + Returns the page and how many rows were left out, so the caller can + report them. The rows themselves are never touched. + """ + return cls._parse(raw, limit=limit, offset=offset, skip_invalid=True) + + @classmethod + def _parse( cls, raw: object, *, limit: int, offset: int, - skip_invalid: bool = False, - ) -> HySdkMemoryPage: + skip_invalid: bool, + ) -> tuple[HySdkMemoryPage, int]: if not isinstance(raw, Mapping): raise HySdkReadError("hy_memory_list_shape_unknown") rows: list[object] = [] @@ -122,15 +138,17 @@ class HySdkMemoryPage(BaseModel): total += bucket_total rows.extend(bucket[rows_name]) deduplicated: dict[str, HySdkMemoryRecord] = {} + skipped = 0 for row in rows: try: record = HySdkMemoryRecord.from_sdk(row) except HySdkReadError: if not skip_invalid: raise + skipped += 1 continue deduplicated.setdefault(record.memory_id, record) - return cls( + page = cls( memories=tuple(deduplicated.values()), # `total` is the SDK's stable result-set count. Subtracting only # invalid rows observed on this page would make it change as the @@ -140,6 +158,7 @@ class HySdkMemoryPage(BaseModel): limit=limit, offset=offset, ) + return page, skipped class HySdkRawRecord(BaseModel): diff --git a/memhub/apps/core/src/one_memory/imports/sources/common.py b/memhub/apps/core/src/one_memory/imports/sources/common.py index cbb9e17..22a88c9 100644 --- a/memhub/apps/core/src/one_memory/imports/sources/common.py +++ b/memhub/apps/core/src/one_memory/imports/sources/common.py @@ -691,7 +691,7 @@ def touched_cursor( previous = options.cursors.get(source_file.safe_id) keep = ( previous is not None - and previous.file_identity == source_file.file_identity + and _same_file(previous, source_file) and previous.observed_size <= source_file.size ) # A kept cursor means the next scan resumes exactly where this one ended: @@ -871,7 +871,7 @@ def resume_checkpoint( previous = options.cursors.get(source_file.safe_id) if previous is None: return None - if previous.file_identity != source_file.file_identity: + if not _same_file(previous, source_file): return None if source_file.size < previous.observed_size: return None @@ -1056,12 +1056,25 @@ def source_cursor( def _cursor_file_identity(source_file: AuthorizedSourceFile) -> str: return ( - f"{source_file.file_identity}:v:{source_file.cursor_version}" + f"{source_file.file_identity}{_VERSION_SEPARATOR}{source_file.cursor_version}" if source_file.cursor_version else source_file.file_identity ) +_VERSION_SEPARATOR = ":v:" + + +def _same_file(previous: SourceCursor, source_file: AuthorizedSourceFile) -> bool: + """Whether a stored cursor still points into this file. + + The stored identity may carry a change token (Cursor's WAL version). The + token says the file changed, which `is_unchanged` handles; it does not mean + the file was replaced, so resuming compares the bare `dev:ino` only. + """ + return previous.file_identity.partition(_VERSION_SEPARATOR)[0] == source_file.file_identity + + _POSITION = re.compile(r"(?<=:)\d+(?=:|$)") diff --git a/memhub/apps/core/tests/test_main_database_entry.py b/memhub/apps/core/tests/test_main_database_entry.py index 6d8b9cf..8b5e0c9 100644 --- a/memhub/apps/core/tests/test_main_database_entry.py +++ b/memhub/apps/core/tests/test_main_database_entry.py @@ -92,6 +92,15 @@ ALLOWED = { "the memory ids a migrated home must still read back", opened_with="?mode=ro", ), + ("hy_sdk_migration.py", "_legacy_metadata"): Allowed( + "the pre-cutover bundle's copy of the legacy database, read-only, to " + "recover the source and times historical cutovers dropped", + opened_with="?mode=ro", + ), + ("hy_sdk_migration.py", "_repair_coding"): Allowed( + "the SDK's own Coding/Working stores, not the main database: the one-time " + "metadata repair writes them at startup, before the SDK client opens" + ), ("sdk_vector_reattach.py", "_stored_names"): Allowed( "Chroma's own chroma.sqlite3, read-only, to list collection names before " "the offline migration opens Chroma", diff --git a/memhub/apps/core/tests/test_memory_notes.py b/memhub/apps/core/tests/test_memory_notes.py index 886188c..9d201b5 100644 --- a/memhub/apps/core/tests/test_memory_notes.py +++ b/memhub/apps/core/tests/test_memory_notes.py @@ -302,6 +302,17 @@ def test_sdk_memory_library_uses_sdk_search_and_skips_empty_rows_without_mutatio "source": "cursor", "tags": ["shipping"], }, + "retired": { + "memory_id": "retired", + "content": "Release plan, first draft", + "layer": "l2_fact", + "status": "superseded", + "user_id": "local", + "agent_id": "all-apps", + "session_id": "s1", + "source": "cursor", + "tags": [], + }, "default": { "memory_id": "default", "content": "Unknown source", @@ -335,9 +346,7 @@ def test_sdk_memory_library_uses_sdk_search_and_skips_empty_rows_without_mutatio ] include_sources = kwargs.get("include_sources") or [] if include_sources: - rows = [ - row for row in rows if row["source"] in include_sources - ] + rows = [row for row in rows if row["source"] in include_sources] return { "vdb": { "memories": rows, @@ -358,7 +367,12 @@ def test_sdk_memory_library_uses_sdk_search_and_skips_empty_rows_without_mutatio "memory_id": "release", "content": "Release plan", "score": 0.9, - } + }, + { + "memory_id": "retired", + "content": "Release plan, first draft", + "score": 0.8, + }, ], } } @@ -388,13 +402,14 @@ def test_sdk_memory_library_uses_sdk_search_and_skips_empty_rows_without_mutatio ) assert response.status_code == 200 body = response.json() - assert body["total"] == 1 - assert [item["memory_id"] for item in body["memories"]] == [ - "release" - ] + # `total` counts the bounded relevance window; the superseded + # hit is in it but is not listed as an active memory. + assert body["total"] == 2 + assert [item["memory_id"] for item in body["memories"]] == ["release"] assert search_calls[0][0] == "release" assert search_calls[0][1]["include_sources"] == ["cursor"] assert search_calls[0][1]["limit"] == 500 + assert search_calls[0][1]["intention_limit"] == 0 assert list_calls == [] listed = await client.get( diff --git a/memhub/apps/core/tests/test_primary_source_adapters.py b/memhub/apps/core/tests/test_primary_source_adapters.py index 4a73983..1bba727 100644 --- a/memhub/apps/core/tests/test_primary_source_adapters.py +++ b/memhub/apps/core/tests/test_primary_source_adapters.py @@ -164,6 +164,36 @@ def test_cursor_wal_change_token_does_not_become_a_future_conversation_time( assert int(records[0].evidence.created_at.timestamp()) == stamp +def test_cursor_wal_store_resumes_from_its_rowid_after_new_frames(tmp_path) -> None: + """The WAL change token marks a store changed; it must not reset its checkpoint.""" + root = tmp_path / ".cursor" + database = root / "chats" / "workspace" / "wal-resume" / "store.db" + database.parent.mkdir(parents=True) + connection = sqlite3.connect(database) + connection.execute("PRAGMA journal_mode=WAL") + connection.execute("PRAGMA wal_autocheckpoint=0") + connection.execute("CREATE TABLE blobs (id TEXT, data TEXT)") + connection.execute( + "INSERT INTO blobs VALUES (?, ?)", + ("old", json.dumps({"id": "old", "role": "user", "content": "old row"})), + ) + connection.commit() + adapter = CursorUnifiedSourceAdapter(grant=grant("cursor", root)) + try: + baseline = asyncio.run(adapter.live_baseline()) + assert evidence(asyncio.run(scan(adapter, cursors=baseline))) == [] + connection.execute( + "INSERT INTO blobs VALUES (?, ?)", + ("new", json.dumps({"id": "new", "role": "user", "content": "new row"})), + ) + connection.commit() + appended = asyncio.run(scan(adapter, cursors=baseline)) + finally: + connection.close() + + assert [item.evidence.verbatim for item in evidence(appended)] == ["new row"] + + def test_cursor_transcript_timestamp_envelope_keeps_only_the_user_query_text(tmp_path) -> None: root = tmp_path / ".cursor" transcript = ( diff --git a/memhub/apps/core/tests/test_sdk_data_plane_migration.py b/memhub/apps/core/tests/test_sdk_data_plane_migration.py index 8e05c4d..b02c679 100644 --- a/memhub/apps/core/tests/test_sdk_data_plane_migration.py +++ b/memhub/apps/core/tests/test_sdk_data_plane_migration.py @@ -5,6 +5,7 @@ import hashlib import io import json import sqlite3 +from contextlib import closing from datetime import UTC, datetime, timedelta from pathlib import Path @@ -74,8 +75,7 @@ def test_post_cutover_metadata_repair_restores_source_and_original_times(store): migration_id = "sdk-authority-v2-source-repair" with main_database(store.database_path).write() as database, database: database.execute( - "UPDATE memory_authority_state SET authority='sdk',migration_id=? " - "WHERE singleton=1", + "UPDATE memory_authority_state SET authority='sdk',migration_id=? WHERE singleton=1", (migration_id,), ) legacy = ( @@ -186,6 +186,60 @@ def test_post_cutover_metadata_repair_restores_source_and_original_times(store): assert repeated == result +def test_metadata_repair_keeps_source_unknown_when_outputs_disagree(tmp_path): + """Two legacy memories folded into one SDK memory pool their evidence.""" + from one_memory.hy_sdk_migration import _legacy_metadata + + legacy = tmp_path / "one-memory.sqlite3" + with closing(sqlite3.connect(legacy)) as database, database: + database.executescript( + """ + CREATE TABLE memory_task_outputs (body TEXT); + CREATE TABLE governed_memories ( + memory_id TEXT, created_at TEXT, updated_at TEXT + ); + CREATE TABLE evidence ( + evidence_id TEXT, source_id TEXT, created_at TEXT, imported_at TEXT + ); + """ + ) + database.executemany( + "INSERT INTO governed_memories VALUES (?,?,?)", + [ + ("legacy-1", "2026-10-18T00:00:00+00:00", None), + ("legacy-2", "2026-10-12T00:00:00+00:00", None), + ], + ) + database.executemany( + "INSERT INTO evidence VALUES (?,?,?,NULL)", + [ + ("e1", "workbuddy", "2026-10-17T00:00:00+00:00"), + ("e2", "cursor", "2026-10-11T00:00:00+00:00"), + ], + ) + database.executemany( + "INSERT INTO memory_task_outputs VALUES (?)", + [ + ( + json.dumps( + { + "memory_id": legacy_id, + "hy_memory_id": "sdk-1", + "support_refs": [{"evidence_id": evidence_id}], + } + ), + ) + for legacy_id, evidence_id in (("legacy-1", "e1"), ("legacy-2", "e2")) + ], + ) + + repaired = _legacy_metadata(legacy, now=datetime(2026, 10, 25, tzinfo=UTC))["sdk-1"] + + assert repaired.source is None + assert repaired.memory_at == int(datetime(2026, 10, 17, tzinfo=UTC).timestamp()) + assert repaired.created_at == int(datetime(2026, 10, 12, tzinfo=UTC).timestamp()) + + def _insert_evidence(store, evidence_id, *, conversation="session-1", role="user", seq=1): verbatim = f"text of {evidence_id}" with main_database(store.database_path).write() as database, database: diff --git a/memhub/apps/web/src/SelectedMemoryDetail.test.tsx b/memhub/apps/web/src/SelectedMemoryDetail.test.tsx index 0233d3f..4a14ac8 100644 --- a/memhub/apps/web/src/SelectedMemoryDetail.test.tsx +++ b/memhub/apps/web/src/SelectedMemoryDetail.test.tsx @@ -188,6 +188,29 @@ it("splits long memory text into a bounded heading and readable body", () => { "selected-memory-body", ); }); +it.each([ + [ + "a late sentence end", + `${"长".repeat(80)}。${"尾".repeat(10)}`, + "长".repeat(72), + ], + ["words", `${"word ".repeat(20)}end`, "word ".repeat(14).trim()], +])("keeps the heading within the limit for %s", (_case, content, heading) => { + render( + , + ); + expect(screen.getByRole("heading", { name: heading })).toBeTruthy(); +}); it("opens full editor and saves content without rewriting scope", async () => { const changed = vi.fn(); render( diff --git a/memhub/apps/web/src/SelectedMemoryDetail.tsx b/memhub/apps/web/src/SelectedMemoryDetail.tsx index dd2dbd5..0f74278 100644 --- a/memhub/apps/web/src/SelectedMemoryDetail.tsx +++ b/memhub/apps/web/src/SelectedMemoryDetail.tsx @@ -16,6 +16,8 @@ import "./selected-memory.css"; type Page = "read" | "edit" | "delete"; +const HEADING_LIMIT = 72; + export function SelectedMemoryDetail({ detail, sources, @@ -234,12 +236,18 @@ export function SelectedMemoryDetail({ if (alive.current) setBusy(false); } } + // The heading is the first sentence when it fits in HEADING_LIMIT + // characters; otherwise the text breaks at the last space within the limit + // (CJK text has none, so it breaks at the limit itself). const punctuation = memory.content.search(/[。!?!?\n]/); + const space = memory.content.lastIndexOf(" ", HEADING_LIMIT); const split = - punctuation > 0 && punctuation < 85 + punctuation > 0 && punctuation < HEADING_LIMIT ? punctuation + 1 - : memory.content.length > 72 - ? 72 + : memory.content.length > HEADING_LIMIT + ? space > HEADING_LIMIT / 2 + ? space + : HEADING_LIMIT : -1; const heading = split > 0 ? memory.content.slice(0, split).trim() : memory.content; diff --git a/memhub/packages/runtime-control/src/local-install.test.ts b/memhub/packages/runtime-control/src/local-install.test.ts index ed63e4a..2982ca7 100644 --- a/memhub/packages/runtime-control/src/local-install.test.ts +++ b/memhub/packages/runtime-control/src/local-install.test.ts @@ -804,6 +804,32 @@ describe("local Runtime installation", () => { ).rejects.toThrow("distribution_downgrade_refused"); }); + it("does not let the same-build opt-in replace a tree whose build it cannot read", async () => { + if (process.platform !== "darwin") return; + const first = await distribution("1.0.0", { buildNumber: 5 }); + const firstPlan = await planLocalInstall({ + source: first.source, + installRoot: first.installRoot, + platform: process.platform, + arch: process.arch, + }); + await applyLocalInstall(firstPlan); + await writeFile( + path.join(firstPlan.version_directory, "manifest.json"), + "{", + ); + const older = await distribution("0.9.0", { buildNumber: 4 }); + await expect( + planLocalInstall({ + source: older.source, + installRoot: first.installRoot, + platform: process.platform, + arch: process.arch, + allowSameBuildReplace: true, + }), + ).rejects.toThrow("distribution_manifest_invalid"); + }); + it("accepts an equal build_number when the bytes are identical (idempotent reinstall)", async () => { if (process.platform !== "darwin") return; const first = await distribution("1.0.0", { buildNumber: 5 }); diff --git a/memhub/packages/runtime-control/src/local-install.ts b/memhub/packages/runtime-control/src/local-install.ts index 1ec444b..60607af 100644 --- a/memhub/packages/runtime-control/src/local-install.ts +++ b/memhub/packages/runtime-control/src/local-install.ts @@ -369,7 +369,11 @@ async function inspectBuildNumberTransition(options: { } let selectedVerified = true; let selected = await readManifest(target).catch(() => null); - if (options.allowSameBuildReplace && selected !== null) { + if (options.allowSameBuildReplace) { + // The opt-in may replace a dirty tree, but only one whose build it can + // still read: without a manifest the downgrade check below has nothing to + // compare, and a newer runtime would be overwritten by an older one. + if (selected === null) throw new Error("distribution_manifest_invalid"); try { selected = await verifyDistribution( target, diff --git a/memhub/packages/runtime-control/src/packaged-runtime-install.test.ts b/memhub/packages/runtime-control/src/packaged-runtime-install.test.ts index aab8358..bd3b0aa 100644 --- a/memhub/packages/runtime-control/src/packaged-runtime-install.test.ts +++ b/memhub/packages/runtime-control/src/packaged-runtime-install.test.ts @@ -508,6 +508,31 @@ describe("installPackagedRuntime", () => { }, ); + it("refuses to repair a selected tree whose manifest is unreadable", async () => { + const { root, resources, executable, installRoot } = await fixture(); + const { selected } = await installFormalCurrent({ + root, + resources, + executable, + installRoot, + buildNumber: 3758, + }); + await writeFile(path.join(selected, "manifest.json"), "{"); + + await expect( + installPackagedRuntime({ + resourcesPath: resources, + executablePath: executable, + installRoot, + platform: "darwin", + arch: "arm64", + buildNumber: 3757, + gitCommit: "a".repeat(40), + replaceSameBuild: true, + }), + ).rejects.toThrow("distribution_manifest_invalid"); + }); + it("preserves downgrade refusal when selected current is newer", async () => { const { root, resources, executable, installRoot } = await fixture(); await installFormalCurrent({ diff --git a/memhub/packages/runtime-control/src/packaged-runtime-install.ts b/memhub/packages/runtime-control/src/packaged-runtime-install.ts index 3ea1503..6974bdc 100644 --- a/memhub/packages/runtime-control/src/packaged-runtime-install.ts +++ b/memhub/packages/runtime-control/src/packaged-runtime-install.ts @@ -325,8 +325,11 @@ async function reuseSelectedSameBuild(options: { ) .then((value) => JSON.parse(value) as DistributionManifest) .catch(() => null); + // Without a readable build the downgrade check cannot run; fail closed + // rather than let an older bundle replace a newer runtime. + if (declared === null) throw new Error("distribution_manifest_invalid"); if ( - declared?.build_number !== undefined && + declared.build_number !== undefined && options.buildNumber !== undefined && declared.build_number > options.buildNumber ) {