Compare commits

...
Author SHA1 Message Date
kristinheandCursor 099f5ae7c0 test(memhub): update memory search affordance
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-10-27 13:13:55 +08:00
kristinheandCursor 2735d8693a fix(memhub): prioritize memory list space
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-10-27 12:19:49 +08:00
kristinheandCursor 47bbfb4b27 fix(memhub): simplify memory note copy
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-10-27 12:11:54 +08:00
kristinheandCursor 3f169c9951 style(memhub): format language selector
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-10-27 12:01:58 +08:00
kristinheandCursor cb97aa5822 fix(memhub): show attributed app calls on home
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-10-27 11:43:25 +08:00
kristinheandCursor 48a5d70026 fix(memhub): stabilize language setting layout
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-10-27 11:20:53 +08:00
kristinheandCursor b21c929728 fix(memhub): stabilize memory detail layout
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-10-27 11:12:47 +08:00
kristinheandCursor b9b3c587ff fix(memhub): hide recent update internals
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-10-27 11:01:14 +08:00
feitianxiang 5a5650fc94 Merge pull request #182 from Tencent-Hunyuan/chore/bump-build-30
chore(release): bump MemHub build number to 30
2026-10-27 09:21:00 +08:00
alvinfeiandClaude Opus 5.5 0e149b6c9c chore(release): bump MemHub build number to 30
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-10-27 09:17:07 +08:00
feitianxiang 0dd2bcd6b1 Merge pull request #181 from Tencent-Hunyuan/fix/core-ready-before-recovery
fix(memhub): make Core ready before it resumes unfinished work
2026-10-27 09:13:25 +08:00
alvinfeiandClaude Opus 5.5 567a8b349d test(desktop): let the attach-race winner exist before the loop gives up
The test wrote the winning Core's receipt and token from inside the
spawn, without awaiting it, while the readiness loop ran with zero
delay. On a slow runner the loop finished first, the re-read found a
receipt without its token, and start() reported a false conflict. The
probe now waits for the winner's files, which is the order the test is
about.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-10-27 09:00:50 +08:00
alvinfeiandClaude Opus 5.5 d89afb3887 fix(memhub): make Core ready before it resumes unfinished work
A home with pending imports and write jobs kept /health closed while
the lifespan resumed them, so the Runtime hit its 60s readiness budget,
killed a healthy Core with core_start_timeout, and the next Core began
the same recovery again. The write drain also started before readiness
and held the single writer the rest of startup needed.

Readiness is now only "the stores are open and Core answers". After
core.ready a recovery task runs, step by step: the post-cutover
metadata repair, the durable write drain, raw retention (or the legacy
pending-dispatch replay), interrupted imports, live sync, source
reconcile and the legacy catch-up. A failed step is logged and named in
/health without stopping readiness or the steps after it. /health
reports recovery as pending, running, done or failed. Shutdown cancels
recovery first and still releases the content-backfill gate.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-10-26 23:59:38 +08:00
27 changed files with 709 additions and 198 deletions

No files matched your search

+203 -101
View File
@@ -253,6 +253,11 @@ class HealthResponse(BaseModel):
# is answering. Reads fall back to exact LIKE while this is not ready.
content_candidate_backfill: Literal["idle", "running", "ready", "failed"] = "idle"
content_candidate_backfill_failure: str | None = None
# Picking up work an earlier run left unfinished starts after readiness.
# "running" is not degraded: Core is ready and answers throughout; the
# failed steps are named so a stuck recovery is visible without a log.
recovery: Literal["pending", "running", "done", "failed"] = "done"
recovery_failed_steps: tuple[str, ...] = ()
class BackupRequest(BaseModel):
@@ -685,28 +690,8 @@ def create_app(
# Only the maintenance Core (restore) may open such a home.
raise SdkMigrationRequired()
if app.state.memory_authority is MemoryAuthority.SDK:
from one_memory.hy_sdk_migration import (
repair_sdk_metadata_from_bundle,
)
try:
repair = await asyncio.to_thread(
repair_sdk_metadata_from_bundle,
data_dir=runtime_settings.data_dir,
database_path=database_path,
)
if repair.vector_rows or repair.coding_rows:
logger.info(
"sdk_metadata_repair_completed mapped=%d vector=%d coding=%d",
repair.mapped,
repair.vector_rows,
repair.coding_rows,
)
except Exception as error:
logger.warning(
"sdk_metadata_repair_failed error_type=%s",
type(error).__name__,
)
# The metadata repair after a cutover reads every bundle: it is
# recovery work, so it runs after readiness (`recover_after_ready`).
app.state.memory_governance_store = RetiredGovernanceStore(database_path)
app.state.evidence_store = RetiredEvidenceStore(database_path)
else:
@@ -921,8 +906,6 @@ def create_app(
extraction_settings=app.state.extraction_settings_store,
data_dir=runtime_settings.data_dir,
)
if app.state.memory_authority is MemoryAuthority.SDK:
app.state.sdk_memory_runtime.start_raw_retention()
# C2 owns the single production budget (handle 5s + reserve 250ms,
# total 5.25s, lease 10s, renew 1s, claim 250ms). The composition
# root only consumes it: no local formula, and the 250ms claim
@@ -1012,11 +995,9 @@ def create_app(
source_claim_enabled=lambda source_id: _sedimentation_claim_enabled(app, source_id),
)
app.state.memory_write_drain = memory_write_drain
app.state.memory_write_drain_task = watched_task(
asyncio.get_running_loop(),
lambda: memory_write_drain.run_forever(),
name="one-memory-durable-write-drain",
)
# Started by `recover_after_ready`: a running drain claims jobs and
# holds the single writer, which the rest of startup still needs.
app.state.memory_write_drain_task = None
# D52: every entry that already holds committed Evidence goes
# through the one durable queue. The intake turns one record into
# one complete-turn request; the drain stays the only consumer, so
@@ -1089,60 +1070,6 @@ def create_app(
sdk_authority=lambda: app.state.memory_authority is MemoryAuthority.SDK,
)
app.state.import_job_store.replace_never_published_snapshot_failures()
# A file's Evidence and its pending-dispatch row commit atomically,
# but the callback that turns them into a task runs after the
# transaction. A crash in that window leaves durable Evidence with
# no in-memory timer left to notice it; drain those rows once on
# startup through the same permission/debounce path as live work.
if (
runtime_settings.automatic_memory_processing
and app.state.memory_authority is MemoryAuthority.LEGACY
):
# Two-phase, like the live runner: enumerate the file
# identities, read each without consuming its pending marker,
# await the durable submit, and only then consume. A crash or
# a failed/cancelled submit leaves the marker for the next
# startup instead of dropping the committed Evidence. The
# legacy `take_all_stable_records()` consumes while reading and
# is deliberately no longer the startup path.
for user_id, source_id, file_key in await asyncio.to_thread(
app.state.evidence_store.list_pending_dispatch_files
):
try:
for dispatch in await asyncio.to_thread(
app.state.evidence_store.take_stable_file_dispatches,
user_id=user_id,
source_id=source_id,
file_key=file_key,
):
# Same intake as the live callback, so the crash
# window between Evidence commit and durable submit
# is closed by the same idempotent path rather than
# a second one.
token = live_sync_arrival.set(dispatch.priority == "live")
try:
await enqueue_stable_evidence(dispatch.record)
finally:
live_sync_arrival.reset(token)
except Exception:
# The submit did not succeed, so the pending marker is
# deliberately left in place: the next startup will
# replay this file. Startup continues for the other
# files instead of failing the whole composition root.
logger.warning(
"stable_evidence_startup_replay_deferred source=%s file=%s",
source_id,
file_key,
exc_info=True,
)
continue
await asyncio.to_thread(
app.state.evidence_store.consume_stable_file_records,
user_id=user_id,
source_id=source_id,
file_key=file_key,
)
app.state.import_supervisor.resume_interrupted()
app.state.source_connection_store = SourceConnectionStore(
app.state.source_grant_store,
app.state.import_job_store,
@@ -1185,11 +1112,6 @@ def create_app(
app.state.live_sync_supervisor.set_watched_sources(
watch_roots if watching and runtime_settings.live_sync_enabled else ()
)
if runtime_settings.live_sync_enabled:
app.state.live_sync_supervisor.start()
if app.state.live_sync_watch is not None:
app.state.live_sync_watch.start(prime=True)
await _reconcile_source_sync_from_config()
# Taking up the memory work again — the tasks left unfinished, then
# the conversations with no memory yet — reads every conversation
# ever imported: 13.5 seconds on this machine's real database, and
@@ -1203,9 +1125,6 @@ def create_app(
warm_bulk_pool()
app.state.memory_backfill = None
app.state.memory_visibility_task = None
if app.state.memory_authority is MemoryAuthority.LEGACY:
_start_backfill(app)
_start_visibility_recompute(app)
# Both times the loop was blocked here it was found from outside,
# by hand, days later. This says so at the time, and says what was
# holding it.
@@ -1224,15 +1143,182 @@ def create_app(
app.state.resource_diagnostics = watched_task(
asyncio.get_running_loop(), _emit_resource_snapshots
)
# Readiness is "Core answers": the stores above are open and
# /health, recall and the library work. Everything below picks up
# work an earlier run left unfinished -- the post-cutover metadata
# repair, the durable write queue, interrupted imports, live sync,
# source reconcile. On a library with pending work that took longer
# than the Runtime's readiness budget, so the Runtime killed a
# healthy Core and the next one began the same recovery again. It
# now runs only after readiness, one step at a time, and a failed
# step is reported without taking readiness or the other steps
# with it.
app.state.recovery = "pending"
app.state.recovery_failed_steps = ()
async def recover_after_ready() -> None:
app.state.recovery = "running"
recovery_started = time.monotonic()
failed: list[str] = []
async def step(name: str, action: Callable[[], Awaitable[None]]) -> None:
step_started = time.monotonic()
try:
await action()
except asyncio.CancelledError:
raise
except Exception as error:
failed.append(name)
app.state.recovery_failed_steps = tuple(failed)
logger.warning(
"core_recovery_step_failed step=%s error_type=%s",
name,
type(error).__name__,
)
diagnostic_event(
level="warn",
component="core.recovery",
event="core.recovery_step_failed",
outcome="failed",
error_type=type(error).__name__,
duration_ms=elapsed_ms(step_started),
details={"phase": name},
)
async def repair_sdk_metadata() -> None:
from one_memory.hy_sdk_migration import (
repair_sdk_metadata_from_bundle,
)
repair = await asyncio.to_thread(
repair_sdk_metadata_from_bundle,
data_dir=runtime_settings.data_dir,
database_path=database_path,
)
if repair.vector_rows or repair.coding_rows:
logger.info(
"sdk_metadata_repair_completed mapped=%d vector=%d coding=%d",
repair.mapped,
repair.vector_rows,
repair.coding_rows,
)
async def start_write_drain() -> None:
app.state.memory_write_drain_task = watched_task(
asyncio.get_running_loop(),
lambda: memory_write_drain.run_forever(),
name="one-memory-durable-write-drain",
)
async def start_raw_retention() -> None:
app.state.sdk_memory_runtime.start_raw_retention()
async def replay_pending_dispatch() -> None:
# A file's Evidence and its pending-dispatch row commit atomically,
# but the callback that turns them into a task runs after the
# transaction. A crash in that window leaves durable Evidence with
# no in-memory timer left to notice it; drain those rows once on
# startup through the same permission/debounce path as live work.
if (
runtime_settings.automatic_memory_processing
and app.state.memory_authority is MemoryAuthority.LEGACY
):
# Two-phase, like the live runner: enumerate the file
# identities, read each without consuming its pending marker,
# await the durable submit, and only then consume. A crash or
# a failed/cancelled submit leaves the marker for the next
# startup instead of dropping the committed Evidence. The
# legacy `take_all_stable_records()` consumes while reading and
# is deliberately no longer the startup path.
for user_id, source_id, file_key in await asyncio.to_thread(
app.state.evidence_store.list_pending_dispatch_files
):
try:
for dispatch in await asyncio.to_thread(
app.state.evidence_store.take_stable_file_dispatches,
user_id=user_id,
source_id=source_id,
file_key=file_key,
):
# Same intake as the live callback, so the crash
# window between Evidence commit and durable submit
# is closed by the same idempotent path rather than
# a second one.
token = live_sync_arrival.set(dispatch.priority == "live")
try:
await enqueue_stable_evidence(dispatch.record)
finally:
live_sync_arrival.reset(token)
except Exception:
# The submit did not succeed, so the pending marker is
# deliberately left in place: the next startup will
# replay this file. Startup continues for the other
# files instead of failing the whole composition root.
logger.warning(
"stable_evidence_startup_replay_deferred source=%s file=%s",
source_id,
file_key,
exc_info=True,
)
continue
await asyncio.to_thread(
app.state.evidence_store.consume_stable_file_records,
user_id=user_id,
source_id=source_id,
file_key=file_key,
)
async def resume_imports() -> None:
app.state.import_supervisor.resume_interrupted()
async def start_live_sync() -> None:
if runtime_settings.live_sync_enabled:
app.state.live_sync_supervisor.start()
if app.state.live_sync_watch is not None:
app.state.live_sync_watch.start(prime=True)
async def start_legacy_catch_up() -> None:
_start_backfill(app)
_start_visibility_recompute(app)
# Yield once first so the HTTP server finishes binding before
# any of this asks the loop for time.
await asyncio.sleep(0)
try:
sdk = app.state.memory_authority is MemoryAuthority.SDK
if sdk:
# Before the drain: the repair restores metadata the
# queued writes are read back against.
await step("sdk_metadata_repair", repair_sdk_metadata)
await step("write_drain", start_write_drain)
if sdk:
await step("raw_retention", start_raw_retention)
else:
await step("pending_dispatch_replay", replay_pending_dispatch)
await step("import_resume", resume_imports)
await step("live_sync", start_live_sync)
await step("source_reconcile", _reconcile_source_sync_from_config)
if not sdk:
await step("legacy_catch_up", start_legacy_catch_up)
finally:
# Startup writes are done. Only now let the derived-content
# rebuild take the single write checkout: it is a long
# background job, and on a large library it would otherwise
# hold the writer while the recovery above still needs it
# (ISSUE-CONTENT-BACKFILL-BLOCKS-STARTUP).
app.state.memory_governance_store.release_content_backfill_startup_gate()
app.state.recovery = "failed" if failed else "done"
diagnostic_event(
level="warn" if failed else "info",
component="core.recovery",
event="core.recovery_finished",
outcome="failed" if failed else "succeeded",
duration_ms=elapsed_ms(recovery_started),
details={"count": len(failed)},
)
app.state.recovery_task = None
try:
# Startup writes are done. Only now let the derived-content
# rebuild take the single write checkout: it is a long
# background job, and on a large library it would otherwise
# hold the writer while the store initializations above still
# need it, so startup would wait out the write budget and fail
# with `database is locked`
# (ISSUE-CONTENT-BACKFILL-BLOCKS-STARTUP).
app.state.memory_governance_store.release_content_backfill_startup_gate()
diagnostic_event(
level="info",
component="core.lifecycle",
@@ -1241,6 +1327,11 @@ def create_app(
instance_id=runtime_settings.instance_id,
duration_ms=elapsed_ms(lifecycle_started),
)
app.state.recovery_task = watched_task(
asyncio.get_running_loop(),
recover_after_ready,
name="one-memory-post-ready-recovery",
)
yield
finally:
shutdown_started = time.monotonic()
@@ -1251,6 +1342,14 @@ def create_app(
outcome="started",
instance_id=runtime_settings.instance_id,
)
# First: recovery may be about to start what is stopped below.
if app.state.recovery_task is not None:
app.state.recovery_task.cancel()
with suppress(asyncio.CancelledError):
await app.state.recovery_task
# Cancelled before it got that far, recovery never let the
# rebuild go; a held rebuild would hold shutdown below too.
app.state.memory_governance_store.release_content_backfill_startup_gate()
app.state.main_database_report.cancel()
app.state.loop_heartbeat.cancel()
with suppress(asyncio.CancelledError):
@@ -1278,8 +1377,9 @@ def create_app(
await app.state.live_sync_supervisor.shutdown()
await app.state.import_supervisor.shutdown()
app.state.memory_write_drain.stop()
with suppress(asyncio.CancelledError):
await app.state.memory_write_drain_task
if app.state.memory_write_drain_task is not None:
with suppress(asyncio.CancelledError):
await app.state.memory_write_drain_task
await app.state.sdk_memory_runtime.shutdown()
await app.state.sdk_write_coordinator.close()
if app.state.sdk_client_handle is not None:
@@ -1497,6 +1597,8 @@ def create_app(
content_candidate_backfill_failure=(
governance.content_candidate_backfill_failure() if governance is not None else None
),
recovery=getattr(app.state, "recovery", "pending"),
recovery_failed_steps=getattr(app.state, "recovery_failed_steps", ()),
)
@app.get("/health", response_model=HealthResponse, include_in_schema=False)
+2 -1
View File
@@ -68,6 +68,7 @@
| `test_conversation_search.py` | `integration` | Searching imported conversations before any generation model is configured. |
| `test_conversation_seed_ids.py` | `contract` | 覆盖 seed ids read metadata only without hydrating、seed eligibility keeps person and memory content only、latest activity includes nonseed turns and import time fallback 等 13 个场景。 |
| `test_conversation_turn_grouping.py` | `contract` | A proposal and the turn that adopts it must reach HY as one turn. |
| `test_core_ready_before_recovery.py` | `integration` | Readiness never waits for picking up work an earlier run left unfinished. |
| `test_core_test_reporting.py` | `contract` | 覆盖 report writes sanitized machine readable views、file and marker aggregates are stable、classification suggestions never guess unit contract or integration 等 6 个场景。 |
| `test_credential_responsiveness.py` | `integration` | 覆盖 credentials do not block the event loop、disabled automatic processing does not digest onboarding、answering reads credentials off loop 等 5 个场景。 |
| `test_cursor_default_root_windows.py` | `contract` | R-29 剩余:Cursor 默认根路径不能写死成 macOS。 |
@@ -302,4 +303,4 @@
| `test_workspace_recall.py` | `contract` | Workspace memories were unreachable from every host; these pin the way in. |
| `test_write_queue_lease_renewal.py` | `contract` | Rule 8: a lease must outlive the work it covers, and be renewed while it runs. |
共 297 个测试文件。
共 298 个测试文件。
@@ -14,8 +14,8 @@ from pathlib import Path
import pytest
PRIMARY_MARKERS = ("unit", "contract", "integration", "performance", "live")
EXPECTED_TEST_FILE_COUNT = 297
EXPECTED_TEST_FILES_SHA256 = "50fc934916818220efdd6259dafb8831c2e568a19fc63e2ea6a9b865d1a75c90"
EXPECTED_TEST_FILE_COUNT = 298
EXPECTED_TEST_FILES_SHA256 = "3b2bece1e20fc1db0e8a82db65bb2cd189317ca34922c97ae8849d38283761e8"
# Pure transformations, validation models, parity checks and state machines.
# This list is intentionally small: uncertain files default to contract.
@@ -51,6 +51,7 @@ INTEGRATION_FILES = frozenset(
{
"test_auth.py",
"test_backup_endpoint.py",
"test_core_ready_before_recovery.py",
"test_backup_restore_api.py",
"test_c1_import_via_api_path.py",
"test_capture_deadline.py",
@@ -0,0 +1,144 @@
"""Readiness never waits for picking up work an earlier run left unfinished.
A 1.0.4 home with pending imports and write jobs used to resume all of it
before the lifespan yielded, so /health did not answer within the Runtime's
60s budget and the Runtime killed a healthy Core, which then began the same
recovery again. Recovery now runs after readiness.
"""
import asyncio
import threading
import time
import pytest
from httpx import ASGITransport, AsyncClient
from pydantic import SecretStr
from one_memory import hy_sdk_migration
from one_memory.api import create_app
from one_memory.config import Settings
from one_memory.hy_sdk_migration import MetadataRepairResult
from one_memory.secrets import InMemorySecretStore
pytestmark = pytest.mark.fresh_sdk
def application(tmp_path):
return create_app(
Settings(
data_dir=tmp_path / "data",
source_home=tmp_path,
api_token=SecretStr("ready-test"),
imports_enabled=False,
automatic_memory_processing=False,
live_sync_enabled=False,
),
secret_store=InMemorySecretStore(),
)
def client_for(app):
return AsyncClient(
transport=ASGITransport(app=app),
base_url="http://test",
headers={"Authorization": "Bearer ready-test"},
)
async def health(client):
response = await client.get("/api/v1/health")
assert response.status_code == 200
return response.json()
async def wait_for_recovery(client, *, seconds=10.0):
deadline = time.monotonic() + seconds
while time.monotonic() < deadline:
body = await health(client)
if body["recovery"] in {"done", "failed"}:
return body
await asyncio.sleep(0.01)
raise AssertionError("recovery did not finish")
def test_core_is_ready_while_recovery_is_still_running(tmp_path, monkeypatch):
release = threading.Event()
entered = threading.Event()
def slow_repair(**_):
entered.set()
release.wait(10)
return MetadataRepairResult(None, 0, 0, 0)
monkeypatch.setattr(hy_sdk_migration, "repair_sdk_metadata_from_bundle", slow_repair)
async def scenario():
app = application(tmp_path)
try:
started = time.monotonic()
async with app.router.lifespan_context(app), client_for(app) as client:
# Startup finished although the first recovery step cannot.
assert time.monotonic() - started < 5
assert await asyncio.to_thread(entered.wait, 5)
body = await health(client)
assert body["status"] == "ok"
assert body["recovery"] == "running"
# Nothing after the blocked step has been started yet: the
# write drain in particular would hold the single writer.
assert app.state.memory_write_drain_task is None
# Reads answer during recovery.
assert (await client.get("/api/v1/sources")).status_code == 200
release.set()
body = await wait_for_recovery(client)
assert body["recovery"] == "done"
assert body["recovery_failed_steps"] == []
assert app.state.memory_write_drain_task is not None
finally:
release.set()
asyncio.run(scenario())
def test_a_failed_recovery_step_does_not_stop_readiness_or_later_steps(tmp_path, monkeypatch):
def broken_repair(**_):
raise OSError("bundle unreadable")
monkeypatch.setattr(hy_sdk_migration, "repair_sdk_metadata_from_bundle", broken_repair)
async def scenario():
app = application(tmp_path)
async with app.router.lifespan_context(app), client_for(app) as client:
body = await wait_for_recovery(client)
assert body["status"] == "ok"
assert body["recovery"] == "failed"
assert body["recovery_failed_steps"] == ["sdk_metadata_repair"]
# The steps after the failed one still ran.
assert app.state.memory_write_drain_task is not None
asyncio.run(scenario())
def test_shutdown_during_recovery_stops_it_without_starting_more(tmp_path, monkeypatch):
release = threading.Event()
entered = threading.Event()
def slow_repair(**_):
entered.set()
release.wait(10)
return MetadataRepairResult(None, 0, 0, 0)
monkeypatch.setattr(hy_sdk_migration, "repair_sdk_metadata_from_bundle", slow_repair)
async def scenario():
app = application(tmp_path)
try:
async with app.router.lifespan_context(app):
assert await asyncio.to_thread(entered.wait, 5)
recovery = app.state.recovery_task
# Shutdown cancelled recovery before it could start the drain.
assert recovery.done()
assert app.state.memory_write_drain_task is None
finally:
release.set()
asyncio.run(scenario())
+7
View File
@@ -45,6 +45,9 @@ def test_health_reports_the_single_core() -> None:
"memory_backfill_error": None,
"content_candidate_backfill": "idle",
"content_candidate_backfill_failure": None,
# No lifespan ran, so recovery has not begun.
"recovery": "pending",
"recovery_failed_steps": [],
}
@@ -210,6 +213,8 @@ def test_lifespan_drains_committed_pending_dispatch_through_enqueue(tmp_path, mo
async def scenario() -> None:
async with app.router.lifespan_context(app):
# Replayed by the recovery that runs after readiness.
await app.state.recovery_task
assert record.evidence_id in seen
assert evidence.take_all_stable_records() == ()
@@ -434,6 +439,7 @@ def test_a_backfill_that_fails_says_so_rather_than_disappearing(tmp_path, monkey
app.router.lifespan_context(app),
AsyncClient(transport=ASGITransport(app=app), base_url="http://test") as client,
):
await app.state.recovery_task
await app.state.memory_backfill
response = await client.get("/api/v1/health")
@@ -497,6 +503,7 @@ def test_a_failed_catch_up_leaves_a_traceback_behind(tmp_path, monkeypatch, capl
app.router.lifespan_context(app),
AsyncClient(transport=ASGITransport(app=app), base_url="http://test") as client,
):
await app.state.recovery_task
with suppress(BaseException):
await app.state.memory_backfill
return (await client.get("/api/v1/health")).json()
@@ -295,6 +295,8 @@ def test_startup_reconciles_enabled_source_from_config(tmp_path, monkeypatch):
secret_store=InMemorySecretStore(),
)
async with restored.router.lifespan_context(restored):
# The reconcile runs after readiness, in recovery.
await restored.state.recovery_task
history = [
job
for job in restored.state.import_job_store.list_jobs()
@@ -111,6 +111,7 @@ def test_large_import_status_does_not_scan_source_trees(tmp_path, monkeypatch):
app, catalog = build_app(tmp_path, monkeypatch)
seed_layouts(catalog)
async with app.router.lifespan_context(app), client_for(app) as client:
await app.state.recovery_task
await app.state.memory_backfill
await asyncio.to_thread(seed_evidence, app)
probes = []
@@ -62,6 +62,8 @@ def test_the_switch_on_starts_it(library, monkeypatch):
async def scenario() -> None:
app = _app(library)
async with app.router.lifespan_context(app):
# Started by the recovery that runs after readiness.
await app.state.recovery_task
assert app.state.memory_visibility_task is not None
asyncio.run(scenario())
@@ -234,9 +234,14 @@ describe("Core ownership", () => {
it("attaches when a standalone Core wins the race with the first spawn", async () => {
const dataDir = await mkdtemp(path.join(tmpdir(), "core-attach-race-"));
let probes = 0;
// The winner has finished publishing its receipt and token by the time
// our child loses the lock. Writing them from inside the spawn raced the
// readiness loop: on a slow runner the token was not there yet, the
// re-read rejected the receipt, and the test saw a false conflict.
let winner: Promise<void> = Promise.resolve();
const spawnProcess = vi.fn(() => {
const child = fakeChild();
void writeRuntime(dataDir, {
winner = writeRuntime(dataDir, {
schema_version: 1,
service: "one-memory-core",
instance_id: "cli-race-winner",
@@ -245,7 +250,9 @@ describe("Core ownership", () => {
host: "127.0.0.1",
port: 9236,
started_at: "2026-09-19T01:00:00Z",
}).then(() => child.emit("exit", 1, null));
}).then(() => {
child.emit("exit", 1, null);
});
return child;
});
const supervisor = new CoreSupervisor({
@@ -256,6 +263,10 @@ describe("Core ownership", () => {
spawnProcess,
probeHealth: async (port, token) => {
probes += 1;
// Our own child never answers: it lost the race. Waiting here for the
// winner's files keeps the readiness loop from concluding before the
// winner exists, which is the order this test is about.
await winner;
if (port !== 9236 || token !== "attached-secret")
throw new Error("not attached yet");
return {
+79 -7
View File
@@ -199,7 +199,7 @@ it("localizes failure and empty states without leaking errors", async () => {
).toBeTruthy();
expect(document.body.textContent).not.toContain("synthetic_private_error");
});
it("shows latest recorded call separately from newly generated memory and opens its history", async () => {
it("shows latest recorded call separately without exposing recent-update detail entries", async () => {
history.mockResolvedValue({
available: true,
recording_error: false,
@@ -233,12 +233,11 @@ it("shows latest recorded call separately from newly generated memory and opens
await screen.findByText("Codex 查到 3 条相关内容");
expect(document.querySelector(".r6-home-call-direction")).toBeTruthy();
expect(screen.queryByText("实际治理记忆")).toBeNull();
expect(screen.getByText(/今天 .+ · 查询成功/)).toBeTruthy();
expect(screen.getByText(/Codex · 今天 .+ · 查询成功/)).toBeTruthy();
fireEvent.click(screen.getByRole("tab", { name: "最近更新" }));
expect(screen.getByText("实际治理记忆")).toBeTruthy();
const memoryActions = screen.getAllByRole("button", { name: "查看记忆" });
fireEvent.click(memoryActions[memoryActions.length - 1]);
expect(onMemories).toHaveBeenCalledWith("m1");
expect(screen.queryByRole("button", { name: "查看记忆" })).toBeNull();
expect(onMemories).not.toHaveBeenCalled();
fireEvent.click(screen.getByRole("tab", { name: "最近调用" }));
expect(history).toHaveBeenCalledWith({ limit: 20, offset: 0 });
if (CALL_HISTORY_ENTRY_VISIBLE) {
@@ -247,11 +246,56 @@ it("shows latest recorded call separately from newly generated memory and opens
} else {
// D96: the recent call still shows on the home page; only the way into
// the call-history page is hidden.
expect(screen.getByText(/今天 .+ · 查询成功/)).toBeTruthy();
expect(screen.getByText(/Codex · 今天 .+ · 查询成功/)).toBeTruthy();
expect(screen.queryByRole("button", { name: "查看记录" })).toBeNull();
expect(onCalls).not.toHaveBeenCalled();
}
});
it("skips direct diagnostics and shows the latest attributed application call", async () => {
history.mockResolvedValue({
available: true,
recording_error: false,
items: [
{
event_id: "direct-diagnostic",
host_id: null,
state: "grounded",
occurred_at: new Date().toISOString(),
memory_count: 1,
raw_count: 0,
references: [],
},
{
event_id: "workbuddy-call",
host_id: "workbuddy",
state: "grounded",
occurred_at: new Date(Date.now() - 60_000).toISOString(),
memory_count: 5,
raw_count: 0,
references: [],
},
],
});
render(
<ApplicationsHome
imports={imports}
onManage={() => {}}
onMemories={() => {}}
onSettings={() => {}}
onCalls={() => {}}
/>,
);
fireEvent.click(await screen.findByRole("tab", { name: "最近调用" }));
expect(await screen.findByText("WorkBuddy 查到 5 条相关内容")).toBeTruthy();
expect(screen.queryByText(/直接调用/)).toBeNull();
expect(screen.getByText(/WorkBuddy · 今天 .+ · 查询成功/)).toBeTruthy();
const direction = document.querySelector(".r6-home-call-direction");
expect(direction?.querySelector("img")?.getAttribute("src")).toContain(
"/r6/workbuddy.png",
);
expect(direction?.querySelector(".r6-app-initial")).toBeNull();
});
it("shows only the host for memory hits whose references have no source", async () => {
history.mockResolvedValue({
available: true,
@@ -834,10 +878,38 @@ it.each([null, "", "not-a-date"])(
/>,
);
await screen.findByText("实际治理记忆");
expect(screen.getByText(/时间未知.*版本 2/)).toBeTruthy();
expect(screen.getByText("时间未知 · App 0")).toBeTruthy();
},
);
it("shows only time and source for an SDK recent update", async () => {
summary.mockResolvedValue({
memory_count: 1,
latest: {
memory_id: "sdk-memory-1",
content: "SDK 原生记忆",
layer: "l2_fact",
source: "app-0",
gmt_created: 1_761_000_000,
},
observed_at: "2026-09-20T09:00:00Z",
});
render(
<ApplicationsHome
imports={imports}
onManage={() => {}}
onMemories={() => {}}
onSettings={() => {}}
/>,
);
await screen.findByText("SDK 原生记忆");
expect(screen.getByText(/^\d{2}:\d{2} · App 0$/)).toBeTruthy();
expect(document.body.textContent).not.toContain("SDK l2_fact");
expect(screen.queryByRole("button", { name: "查看记忆" })).toBeNull();
});
it("does not treat an available adapter as an installed application", async () => {
render(
<ApplicationsHome
+22 -16
View File
@@ -83,13 +83,6 @@ function stamped(value: string, locale: GuiLocale, t: Translate, suffix = "") {
return `${parts.today ? t("apps.today") : ""}${parts.clock}${suffix}`;
}
function memoryAction(action: string | undefined, t: Translate) {
if (action === "create") return t("apps.newMemory");
if (action === "correct" || action === "restore")
return t("apps.updatedMemory");
return t("apps.saved");
}
type ActivityChoice = "call" | "update";
function sourceMatchesHost(sourceId: string, hostId: string | null) {
@@ -101,6 +94,10 @@ function successfulCall(item: CallEvent) {
return item.state === "grounded" || item.state === "empty";
}
function attributedSuccessfulCall(item: CallEvent) {
return item.host_id !== null && successfulCall(item);
}
function callAttention(events: CallEvent[]) {
const hosts = new Set<string>();
for (const event of events) {
@@ -219,7 +216,10 @@ export function ApplicationsHome({
const result = await getCallHistory({ limit: 20, offset: 0 });
if (cancelled) return;
setCallEvents(result.items);
setRecentCall(result.items.find(successfulCall) ?? null);
// The home card promises an application identity. Direct diagnostic
// calls have no host and remain available in call history, but cannot
// truthfully supply the icon and name required by this surface.
setRecentCall(result.items.find(attributedSuccessfulCall) ?? null);
setCallState(
!result.available
? "disabled"
@@ -604,6 +604,9 @@ export function ApplicationsHome({
const recentCallHost = imports.sources.find((source) =>
sourceMatchesHost(source.source_id, recentCall?.host_id ?? null),
);
const recentCallHostName = recentCall
? (recentCallHost?.display_name ?? callHostName(recentCall.host_id, locale))
: null;
const attention = callAttention(callEvents);
const attentionHost = attention ? callHostName(attention.host, locale) : null;
const activity = summaryUnavailable
@@ -665,6 +668,11 @@ export function ApplicationsHome({
: latest.source
? [latest.source]
: [];
const latestSourceNames = latestSourceIds.map(
(id) =>
imports.sources.find((source) => source.source_id === id)?.display_name ??
id,
);
const time = latestClock?.clock ?? (latest ? t("apps.unknownTime") : null);
const checked = visible ? clockParts(visible.observed_at, locale) : null;
// A failed read leaves the last health in place on purpose: clearing it would
@@ -936,8 +944,7 @@ export function ApplicationsHome({
: activity === "call" && recentCall
? callDetailHeadline(
recentCall,
recentCallHost?.display_name ??
callHostName(recentCall.host_id, locale),
recentCallHostName ?? undefined,
locale,
)
: activity === "memory" && latest
@@ -958,19 +965,18 @@ export function ApplicationsHome({
? t("apps.callReadFailure")
: t("apps.memoryReadFailure")
: activity === "call" && recentCall
? `${stamped(recentCall.occurred_at, locale, t)} · ${callRowDetail(recentCall, locale)}${callState === "incomplete" ? t("apps.incompleteCalls") : ""}`
? `${recentCallHostName} · ${stamped(recentCall.occurred_at, locale, t)} · ${callRowDetail(recentCall, locale)}${callState === "incomplete" ? t("apps.incompleteCalls") : ""}`
: activity === "memory" && latest
? "revision" in latest
? `${time} · ${memoryAction(latest.action, t)} · ${latestSourceIds.map((id) => imports.sources.find((s) => s.source_id === id)?.display_name ?? id).join(", ")} · ${t("apps.revision", { count: new Intl.NumberFormat(locale).format(latest.revision) })}`
: `${time} · SDK ${latest.layer} · ${latestSourceIds.map((id) => imports.sources.find((s) => s.source_id === id)?.display_name ?? id).join("、")}`
? [time, latestSourceNames.join(", ")]
.filter(Boolean)
.join(" · ")
: activity === "importing"
? t("apps.savingConversations")
: t("apps.waitingMemories")}
</small>
</section>
{(activity === "unavailable" ||
(activity === "call" && CALL_HISTORY_ENTRY_VISIBLE) ||
activity === "memory") && (
(activity === "call" && CALL_HISTORY_ENTRY_VISIBLE)) && (
<button
type="button"
className="r6-home-activity-link"
@@ -58,6 +58,40 @@ it.each(["zh-CN", "en", "es"] as const)(
},
);
it.each(["system", "zh-CN", "en", "es"] as const)(
"keeps one stable settings row for the %s preference",
(preference) => {
localStorage.setItem(LANGUAGE_STORAGE_KEY, preference);
render(
<LanguageProvider>
<LanguageSelector />
</LanguageProvider>,
);
const selector = screen.getByRole("combobox") as HTMLSelectElement;
const row = selector.closest(".r6-language-setting-row");
expect(selector.value).toBe(preference);
expect(row).toBeTruthy();
expect(row?.querySelector("label")?.hidden).toBe(false);
expect(
screen
.getByRole("option", {
name: /简体中文|Simplified Chinese|Chino simplificado/,
})
.getAttribute("lang"),
).toBe("zh-CN");
expect(
screen
.getByRole("option", { name: /英语|English|Inglés/ })
.getAttribute("lang"),
).toBe("en");
expect(
screen
.getByRole("option", { name: /西班牙语|Spanish|Español/ })
.getAttribute("lang"),
).toBe("es");
},
);
it("offers an accessible selector, persists and updates its label", async () => {
localStorage.setItem(LANGUAGE_STORAGE_KEY, "zh-CN");
render(
+18 -6
View File
@@ -13,8 +13,16 @@ export function LanguageSelector({ compact = false }: { compact?: boolean }) {
compact ? "r6-language-selector--compact" : "r6-language-setting"
}
>
{!compact && <h2 id={`${id}-title`}>{t("languageTitle")}</h2>}
<div className={compact ? "r6-language-selector-row" : "r6-setting-row"}>
{!compact && (
<h2 id={`${id}-title`} className="r6-language-setting-title">
{t("languageTitle")}
</h2>
)}
<div
className={
compact ? "r6-language-selector-row" : "r6-language-setting-row"
}
>
<label id={`${id}-label`} htmlFor={`${id}-select`} hidden={compact}>
{t(compact ? "languageTitle" : "languageLabel")}
</label>
@@ -36,18 +44,22 @@ export function LanguageSelector({ compact = false }: { compact?: boolean }) {
}}
>
<option value="system">{t("languageSystem")}</option>
<option value="zh-CN" lang={compact ? "zh-CN" : undefined}>
<option value="zh-CN" lang="zh-CN">
{compact ? "简体中文" : t("languageChinese")}
</option>
<option value="en" lang={compact ? "en" : undefined}>
<option value="en" lang="en">
{compact ? "English" : t("languageEnglish")}
</option>
<option value="es" lang={compact ? "es" : undefined}>
<option value="es" lang="es">
{compact ? "Español" : t("languageSpanish")}
</option>
</select>
</div>
{!compact && <p id={`${id}-description`}>{t("languageDescription")}</p>}
{!compact && (
<p id={`${id}-description`} className="r6-language-setting-description">
{t("languageDescription")}
</p>
)}
{error && (
<p role="alert" className="r6-settings-error">
{t(error)}
@@ -82,3 +82,38 @@ it("SYS-01: reduced motion skips animation and does not require animationend", (
);
expect(screen.queryByRole("dialog")).toBeNull();
});
it("starts a new or resumed detail at the top without resetting the current detail", () => {
vi.stubGlobal("matchMedia", () => ({
matches: false,
addEventListener() {},
removeEventListener() {},
}));
const content = (scrollResetKey: string, suspended = false) => (
<MemoryEvidencePanel
open
suspended={suspended}
scrollResetKey={scrollResetKey}
onClose={() => {}}
>
<p>{scrollResetKey}</p>
</MemoryEvidencePanel>
);
const view = render(content("memory-a"));
const panel = view.container.querySelector<HTMLElement>(".map-inspector");
expect(panel).toBeTruthy();
if (!panel) return;
panel.scrollTop = 180;
view.rerender(content("memory-a"));
expect(panel.scrollTop).toBe(180);
view.rerender(content("memory-b"));
expect(panel.scrollTop).toBe(0);
panel.scrollTop = 120;
view.rerender(content("memory-b", true));
expect(panel.scrollTop).toBe(120);
view.rerender(content("memory-b"));
expect(panel.scrollTop).toBe(0);
});
@@ -15,11 +15,14 @@ export function MemoryEvidencePanel({
onClose,
children,
suspended = false,
scrollResetKey,
}: {
open: boolean;
onClose(): void;
children: ReactNode;
suspended?: boolean;
/** A different detail starts at the top instead of inheriting old scroll. */
scrollResetKey?: string | null;
}) {
const { text } = useMemoryLanguage();
const compact = useMediaPreference("(max-width: 1100px)");
@@ -37,9 +40,21 @@ export function MemoryEvidencePanel({
reduceMotion,
reduceTransparency,
});
const priorScrollOwner = useRef({ key: scrollResetKey, suspended });
const present = !compact || open || phase !== "closed";
const modal = compact && present && !suspended;
useLayoutEffect(() => {
const previous = priorScrollOwner.current;
priorScrollOwner.current = { key: scrollResetKey, suspended };
if (
!suspended &&
(previous.key !== scrollResetKey || previous.suspended) &&
panel.current
)
panel.current.scrollTop = 0;
}, [scrollResetKey, suspended]);
useLayoutEffect(() => {
const element = panel.current;
if (!element) return;
+9 -4
View File
@@ -186,18 +186,17 @@ it("OBS-19: Enter in the memory filter submits it; submitting it again re-reads"
/>,
);
await act(async () => {});
await userEvent.click(screen.getByRole("button", { name: "刷新地图记忆" }));
expect(onRetry).toHaveBeenCalledTimes(1);
expect(screen.queryByRole("button", { name: "刷新地图记忆" })).toBeNull();
// Typing alone does not search; Enter submits the words.
const input = screen.getByRole("textbox", { name: "筛选地图记忆" });
await userEvent.type(input, "第一");
expect(onQuery).not.toHaveBeenCalled();
await userEvent.type(input, "{Enter}");
expect(onQuery).toHaveBeenCalledWith("第一");
// Submitting the words already applied re-reads, like the refresh button.
// Submitting the currently applied query re-reads without a separate row.
await userEvent.clear(input);
await userEvent.type(input, "{Enter}");
expect(onRetry).toHaveBeenCalledTimes(2);
expect(onRetry).toHaveBeenCalledOnce();
});
it("searches only when 搜索 is pressed, not on every keystroke", async () => {
@@ -510,6 +509,12 @@ it("D96: lists a memory by when its conversation happened, not when it was gener
/共 519 条,第 2 \/ 6 页/,
),
).toBeTruthy();
expect(screen.getAllByText(/共 519 条/)).toHaveLength(1);
expect(
screen
.getByRole("navigation", { name: "地图记忆分页" })
.classList.contains("map-pagination"),
).toBe(true);
});
it("names the SDK's no-source bucket 未识别来源, apart from a source under review", () => {
+13 -39
View File
@@ -16,7 +16,6 @@ import {
ChevronRight,
FileText,
Network,
RefreshCw,
Search,
X,
} from "lucide-react";
@@ -696,43 +695,17 @@ export function MemoryMap(props: Props) {
</button>
) : null}
</form>
{library && (
<div className="map-list-heading" aria-live="polite">
<span>
{library.stale
? library.page && library.page.total > 0
? text(
"共 {count} 条 · 正在核对来源…",
{ count: plain(library.page.total) },
library.page.total,
)
: text("正在核对来源…")
: library.failed
? text("读取失败")
: library.loading && library.page?.total === 0
? text("正在读取…")
: library.page
? `${text(
"共 {count} 条",
{ count: plain(library.page.total) },
library.page.total,
)}${refreshing ? text(" · 正在核对…") : ""}`
: text("正在读取…")}
</span>
{!library.stale && (
<button
type="button"
className="quiet map-tool"
aria-label={text("刷新地图记忆")}
title={text("刷新地图记忆")}
disabled={library.loading || hidden}
onClick={library.onRetry}
>
<RefreshCw size={16} aria-hidden="true" />
</button>
)}
</div>
)}
{library && (library.loading || library.stale || library.failed) ? (
<p className="map-list-status" aria-live="polite">
{library.failed
? text("读取失败")
: library.stale
? text("正在核对来源…")
: refreshing && library.page?.total !== 0
? text("正在核对…")
: text("正在读取…")}
</p>
) : null}
{!props.simplified && (
<div className="map-groups">
<button
@@ -873,7 +846,7 @@ export function MemoryMap(props: Props) {
!library.loading &&
!library.failed &&
!library.stale && (
<nav aria-label={text("地图记忆分页")} className="map-list-heading">
<nav aria-label={text("地图记忆分页")} className="map-pagination">
<button
type="button"
className="quiet map-tool"
@@ -1089,6 +1062,7 @@ export function MemoryMap(props: Props) {
<MemoryEvidencePanel
open={inspectorOpen && !!(activeId || visibleGroupNode)}
suspended={overlaysSuspended}
scrollResetKey={activeId ?? visibleGroupNode?.node_id ?? "empty"}
onClose={closeInspector}
>
<header className="map-inspector-heading">
@@ -26,7 +26,7 @@ it("opens a Spanish composer without saving, processing or enabling anything", a
screen.getByRole("textbox", { name: "¿Qué quieres recordar?" }),
).toBeTruthy();
expect(
(screen.getByRole("button", { name: "Guardar texto" }) as HTMLButtonElement)
(screen.getByRole("button", { name: "Guardar" }) as HTMLButtonElement)
.disabled,
).toBe(true);
await userEvent.click(
@@ -165,12 +165,12 @@ it("saves from an empty library with the linked-app scope by default", async ()
expect(document.activeElement).toBe(input);
await userEvent.type(input, note.text);
expect(
screen.getByRole("button", { name: "保存原话" }).hasAttribute("disabled"),
screen.getByRole("button", { name: "保存" }).hasAttribute("disabled"),
).toBe(false);
expect(
screen.queryByRole("checkbox", { name: /允许已关联应用读取/ }),
).toBeNull();
await userEvent.click(screen.getByRole("button", { name: "保存原话" }));
await userEvent.click(screen.getByRole("button", { name: "保存" }));
await waitFor(() =>
expect(api.saveMemoryNote).toHaveBeenCalledWith(
{
@@ -208,7 +208,7 @@ it("preserves draft and request identity after an uncertain save", async () => {
page();
await userEvent.click(screen.getByRole("button", { name: "添加记忆" }));
await userEvent.type(screen.getByRole("textbox"), note.text);
await userEvent.click(screen.getByRole("button", { name: "保存原话" }));
await userEvent.click(screen.getByRole("button", { name: "保存" }));
expect(await screen.findByRole("alert")).toBeTruthy();
expect((screen.getByRole("textbox") as HTMLTextAreaElement).value).toBe(
note.text,
@@ -219,7 +219,7 @@ it("preserves draft and request identity after an uncertain save", async () => {
expect(result?.status).toBe("unknown");
expect(result?.reason).toBe("save_result_unconfirmed");
expect(result?.status).not.toBe("failed");
await userEvent.click(screen.getByRole("button", { name: "保存原话" }));
await userEvent.click(screen.getByRole("button", { name: "保存" }));
expect(api.saveMemoryNote.mock.calls[0][0].request_id).toBe(
api.saveMemoryNote.mock.calls[1][0].request_id,
);
@@ -235,7 +235,7 @@ it("closes as saved when the POST response is lost but the operation exists", as
const callbacks = page();
await userEvent.click(screen.getByRole("button", { name: "添加记忆" }));
await userEvent.type(screen.getByRole("textbox"), note.text);
await userEvent.click(screen.getByRole("button", { name: "保存原话" }));
await userEvent.click(screen.getByRole("button", { name: "保存" }));
await waitFor(() =>
expect(api.getMemoryNoteOperation).toHaveBeenCalledOnce(),
);
@@ -292,7 +292,7 @@ it("does not ask for separate consent when an old note is waiting for permission
page();
await userEvent.click(screen.getByRole("button", { name: "添加记忆" }));
expect(await screen.findByText("原话已保存,等待整理")).toBeTruthy();
expect(await screen.findByText("正在等待整理")).toBeTruthy();
expect(screen.queryByRole("button", { name: "模型授权" })).toBeNull();
await userEvent.click(screen.getByRole("button", { name: "重新整理" }));
expect(api.processMemoryNote).toHaveBeenCalledExactlyOnceWith(
+3 -3
View File
@@ -16,8 +16,8 @@ import { isDefiniteRefusal } from "./write-outcome";
const states: Record<MemoryNoteReceipt["state"], string> = {
waiting_for_model: "原话已保存,等待可用模型",
waiting_for_permission: "原话已保存,等待整理",
pending: "原话已保存,等待整理",
waiting_for_permission: "正在等待整理",
pending: "正在等待整理",
running: "正在整理",
failed: "原话已保存,整理未完成",
ready: "整理已完成",
@@ -371,7 +371,7 @@ export function MemoryNoteComposer({
</button>
)}
<button type="submit" disabled={busy || !text.trim()}>
{copy(busy ? "保存中…" : "保存原话")}
{copy(busy ? "保存中…" : "保存")}
</button>
</div>
</form>
+2 -3
View File
@@ -25,9 +25,7 @@ it("translates memory filters and detail affordances live without refetching", a
expect(
screen.getByRole("textbox", { name: "Filtrar memorias del mapa" }),
).toBeTruthy();
expect(
screen.getByRole("button", { name: "Actualizar memorias" }),
).toBeTruthy();
expect(screen.getByRole("button", { name: "Buscar" })).toBeTruthy();
const calls = reads.sdkLibrary.mock.calls.length;
act(() => {
localStorage.setItem(LANGUAGE_STORAGE_KEY, "zh-CN");
@@ -36,6 +34,7 @@ it("translates memory filters and detail affordances live without refetching", a
);
});
expect(screen.getByRole("heading", { name: "记忆", level: 1 })).toBeTruthy();
expect(screen.getByRole("button", { name: "搜索" })).toBeTruthy();
expect(reads.sdkLibrary).toHaveBeenCalledTimes(calls);
localStorage.clear();
});
+25
View File
@@ -772,6 +772,31 @@ button.rail-button.active::before {
text-overflow: ellipsis;
white-space: nowrap;
}
.map-pagination {
display: flex;
flex: none;
align-items: center;
justify-content: space-between;
gap: 8px;
margin-top: auto;
padding: 12px 6px 0;
border-top: 1px solid var(--bd);
color: var(--tx3);
font-size: 12px;
}
.map-pagination > span {
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.map-list-status {
flex: none;
margin: 0;
padding: 4px;
color: var(--tx3);
font-size: 12px;
line-height: 20px;
}
.map-filter-notice {
font-size: 12px;
line-height: 1.65;
+26 -6
View File
@@ -24,14 +24,31 @@
border-radius: 16px;
background: #fff;
}
.r6-settings .r6-language-setting h2 {
margin: 0 0 12px;
.r6-language-setting-title {
margin: 0 0 18px;
font-size: 18px;
line-height: 28px;
}
.r6-settings .r6-language-setting-row {
display: grid;
grid-template-columns: 96px minmax(0, 1fr);
align-items: center;
gap: 16px;
min-height: 44px;
}
.r6-settings .r6-language-setting-row label {
min-width: 0;
font-size: 14px;
line-height: 24px;
white-space: nowrap;
}
.r6-settings .r6-language-setting select {
box-sizing: border-box;
width: 100%;
max-width: 100%;
min-height: 36px;
padding: 6px 10px;
min-width: 0;
min-height: 44px;
padding: 8px 12px;
border: 1px solid #dce4eb;
border-radius: 8px;
color: inherit;
@@ -42,8 +59,11 @@
outline: 2px solid #225f9d;
outline-offset: 2px;
}
.r6-settings .r6-language-setting > p {
margin: 12px 0 0;
.r6-language-setting-description {
margin: 16px 0 0;
color: #5e6e7a;
font-size: 13px;
line-height: 22px;
overflow-wrap: anywhere;
}
.r6-settings .r6-settings-layout {
+33
View File
@@ -112,6 +112,8 @@
.r6-shell .r6-memory .map-search {
height: 44px;
margin: 13px 0 21px;
gap: 7px;
padding: 0 4px 0 12px;
border: 1px solid #c6d2da;
background: rgb(255 255 255 / 50%);
border-radius: 8px;
@@ -120,7 +122,33 @@
min-height: 40px;
font-size: 13px;
}
.r6-shell .r6-memory .map-search-submit {
min-height: 34px;
padding: 0 12px;
border: 1px solid rgb(64 87 103 / 18%);
border-radius: 6px;
background: #4f6373;
color: #fff;
font:
500 12px / 20px "Noto Sans SC",
"PingFang SC",
sans-serif;
box-shadow: inset 0 1px 0 rgb(255 255 255 / 18%);
transition:
background-color var(--r6-fast),
border-color var(--r6-fast);
}
.r6-shell .r6-memory .map-search-submit:hover:not(:disabled) {
background: #435966;
border-color: rgb(48 71 87 / 28%);
}
.r6-shell .r6-memory .map-search-submit:focus-visible {
outline: 2px solid #557c93;
outline-offset: 2px;
}
.r6-shell .r6-memory .map-memory-list {
flex: 1 1 0;
min-height: 0;
margin: 0;
padding: 0;
}
@@ -208,6 +236,9 @@
font-weight: 500;
}
.selected-memory-detail {
display: flex;
min-width: 0;
flex-direction: column;
color: #384a57;
font-family: "Noto Sans SC", "PingFang SC", sans-serif;
}
@@ -228,6 +259,8 @@
}
.selected-memory-body {
display: block;
flex: none;
min-width: 0;
font-size: 17px;
line-height: 29px;
letter-spacing: normal;
+1 -1
View File
@@ -2,7 +2,7 @@
"name": "one-memory",
"version": "1.0.5",
"release": {
"build_number": 22,
"build_number": 30,
"macos_min_version": "13.0",
"app_update": "manual",
"runtime_manifest_schema": 1,
+7
View File
@@ -65,6 +65,13 @@ export interface HealthResponse {
*/
memory_backfill?: "running" | "done" | "failed";
memory_backfill_error?: string | null;
/**
* Work an earlier run left unfinished (write queue, interrupted imports,
* live sync) is picked up after Core is ready. "running" is not degraded:
* readiness never waits for it. Absent from cores older than this field.
*/
recovery?: "pending" | "running" | "done" | "failed";
recovery_failed_steps?: string[];
}
export type ImportStatus =
@@ -1722,6 +1722,7 @@ const baseEnglishMessages = {
"memory:原话已保存,等待可用模型":
"Original saved; waiting for an available model",
"memory:原话已保存,等待整理": "Original saved; waiting for processing",
"memory:正在等待整理": "Awaiting processing",
"memory:正在整理": "Processing",
"memory:原话已保存,整理未完成": "Original saved; processing incomplete",
"memory:整理已完成": "Processing complete",
@@ -288,6 +288,7 @@ export const memoryChineseMessages = {
"memory:重新整理": "重新整理",
"memory:原话已保存,等待可用模型": "原话已保存,等待可用模型",
"memory:原话已保存,等待整理": "原话已保存,等待整理",
"memory:正在等待整理": "正在等待整理",
"memory:正在整理": "正在整理",
"memory:原话已保存,整理未完成": "原话已保存,整理未完成",
"memory:整理已完成": "整理已完成",
@@ -696,6 +697,7 @@ export const memorySpanishMessages: Record<
"memory:重新整理": "Volver a procesar",
"memory:原话已保存,等待可用模型": "Texto guardado; esperando un modelo",
"memory:原话已保存,等待整理": "Texto guardado; pendiente de procesar",
"memory:正在等待整理": "Pendiente de procesar",
"memory:正在整理": "Procesando",
"memory:原话已保存,整理未完成": "Texto guardado; procesamiento incompleto",
"memory:整理已完成": "Procesamiento completado",