Compare commits

...
Author SHA1 Message Date
kristinheandCursor 996368ee32 fix(memhub): let Lite onboarding complete immediately
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-11-05 01:29:45 +08:00
kristinhe1111 df5a65797c fix(memhub): page Lite raw turns without full scans 2026-11-05 00:55:07 +08:00
kristinhe1111 2ea48e68a6 Merge pull request #326 from Tencent-Hunyuan/fix/home-recent-update-bottom-align
fix(memhub): align recent activity to card bottom
2026-11-05 00:31:55 +08:00
kristinhe1111 b8fdde7d0b Merge pull request #327 from Tencent-Hunyuan/fix/model-technical-hover
fix(web): preserve model details hover contrast
2026-11-05 00:31:36 +08:00
kristinhe1111 5134524743 Merge pull request #328 from Tencent-Hunyuan/fix/ultra-evolve-time-picker
fix(memhub): replace native evolve time input
2026-11-05 00:31:20 +08:00
kristinhe1111 e34101cb0b Merge pull request #329 from Sthreal/fix/301-model-probe-pending-state
fix(memhub): fail model probe reservations when execution never dispatches
2026-11-05 00:31:00 +08:00
kristinhe1111 995c7e6c32 Merge pull request #330 from Tencent-Hunyuan/fix/memhub-complete-log-export
fix(memhub): export complete logs from Settings
2026-11-05 00:30:38 +08:00
kristinheandCursor f1afdb03cd fix(memhub): export complete logs from settings
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-11-04 23:49:21 +08:00
Sthreal 4df4ce72bd fix(memhub): fail model probe reservations when execution never dispatches 2026-11-04 23:33:06 +08:00
kristinheandCursor a6d9894975 fix(memhub): replace native evolve time input
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-11-04 22:31:00 +08:00
kristinheandCursor bce58e638d fix(memhub): align recent activity to card bottom
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-11-04 21:47:57 +08:00
43 changed files with 1976 additions and 221 deletions

No files matched your search

+195 -1
View File
@@ -644,6 +644,9 @@ class SdkRawPage(BaseModel):
offset: int = Field(ge=0)
limit: int = Field(ge=1, le=500)
unit: Literal["raw", "turn"] = "raw"
next_cursor: str | None = None
has_more: bool = False
total_exact: bool = True
#: Set when a time filter was applied in Core. For storage `raw`, `total`
#: remains the SDK count; for product `turn`, it is the filtered turn count.
time_filtered: bool = False
@@ -4491,6 +4494,8 @@ def create_app(
offset: int = Query(default=0, ge=0),
limit: int = Query(default=100, ge=1, le=500),
unit: Literal["raw", "turn"] = Query(default="raw"),
paging: Literal["offset", "cursor"] = Query(default="offset"),
cursor: str | None = Query(default=None, max_length=160),
user_id: str = Query(default=DEFAULT_USER_ID, pattern=USER_ID_PATTERN),
exclude_sources: list[str] | None = Query(default=None), # noqa: B008
session_id: list[str] | None = Query(default=None), # noqa: B008
@@ -4516,9 +4521,52 @@ def create_app(
_library_values(statuses or [], MEMORY_STATUSES, "memory_status_unknown")
or raw_list_filters()["statuses"]
)
if paging == "cursor" and unit != "turn":
raise HTTPException(status_code=422, detail="raw_cursor_requires_turn_unit")
if cursor is not None and paging != "cursor":
raise HTTPException(status_code=422, detail="raw_cursor_requires_cursor_paging")
if paging == "cursor" and (offset or after is not None or before is not None):
raise HTTPException(status_code=422, detail="raw_cursor_filter_unsupported")
raw_cursor = (0, 0, 0, 0, "", "")
if cursor:
parts = cursor.split(":")
if (
len(parts) != 7
or parts[0] != "turn"
or any(re.fullmatch(r"[0-9]+", part) is None for part in parts[1:5])
or re.fullmatch(r"(?:p?[0-9a-f]{16})?", parts[5]) is None
or re.fullmatch(r"[0-9a-f]{16}", parts[6]) is None
):
raise HTTPException(status_code=422, detail="raw_cursor_invalid")
raw_cursor = (
int(parts[1]),
int(parts[2]),
int(parts[3]),
int(parts[4]),
parts[5],
parts[6],
)
if any(value > 100_000_000 for value in raw_cursor[:4]):
raise HTTPException(status_code=422, detail="raw_cursor_invalid")
sources = split_csv(source)
excluded = split_csv(exclude_sources)
sessions = split_csv(session_id)
cursor_scope = hashlib.sha256(
json.dumps(
{
"user_id": user_id,
"statuses": sorted(chosen_statuses),
"sources": sorted(sources),
"excluded": sorted(excluded),
"sessions": sorted(sessions),
"order": order,
},
sort_keys=True,
separators=(",", ":"),
).encode()
).hexdigest()[:16]
if cursor and raw_cursor[5] != cursor_scope:
raise HTTPException(status_code=422, detail="raw_cursor_scope_mismatch")
try:
client = await _sdk_client_handle(app).client()
query = {
@@ -4527,8 +4575,154 @@ def create_app(
"include_sources": sources or None,
"exclude_sources": excluded or None,
"session_ids": sessions or None,
"order": order if unit == "raw" else "desc",
"order": order if unit == "raw" or paging == "cursor" else "desc",
}
if unit == "turn" and paging == "cursor":
(
raw_offset,
turn_offset,
emitted,
previous_total,
expected_raw,
_scope,
) = raw_cursor
value = await asyncio.to_thread(
client.list_memories,
**query,
offset=raw_offset,
limit=min(limit + 1, 500),
)
raw_page = HySdkRawPage.from_memory_list(
value, limit=min(limit + 1, 500), offset=raw_offset
)
def raw_anchor(record: HySdkRawRecord) -> str:
return hashlib.sha256(
f"{record.raw_id}\0{record.content}".encode()
).hexdigest()[:16]
if cursor:
if raw_page.total < previous_total:
raise HTTPException(status_code=409, detail="raw_cursor_stale")
observed_total = raw_page.total
added = raw_page.total - previous_total
if added and order == "desc":
raw_offset += added
value = await asyncio.to_thread(
client.list_memories,
**query,
offset=raw_offset,
limit=min(limit + 1, 500),
)
raw_page = HySdkRawPage.from_memory_list(
value,
limit=min(limit + 1, 500),
offset=raw_offset,
)
if raw_page.total != observed_total:
raise HTTPException(
status_code=409, detail="raw_cursor_stale"
)
if expected_raw.startswith("p"):
if raw_offset == 0:
raise HTTPException(
status_code=409, detail="raw_cursor_stale"
)
previous_value = await asyncio.to_thread(
client.list_memories,
**query,
offset=raw_offset - 1,
limit=1,
)
previous_page = HySdkRawPage.from_memory_list(
previous_value, limit=1, offset=raw_offset - 1
)
valid_anchor = (
previous_page.total == raw_page.total
and bool(previous_page.raw)
and raw_anchor(previous_page.raw[0])
== expected_raw[1:]
)
else:
valid_anchor = (
bool(raw_page.raw)
and raw_anchor(raw_page.raw[0]) == expected_raw
)
if expected_raw and not valid_anchor:
raise HTTPException(status_code=409, detail="raw_cursor_stale")
selected_turns: list[tuple[HySdkRawRecord, RawDisplayTurn]] = []
next_cursor: str | None = None
for record_index, record in enumerate(raw_page.raw):
turns = raw_display_turns(record)
ordered_turns = (
tuple(reversed(turns)) if order == "desc" else turns
)
start = turn_offset if record_index == 0 else 0
for index in range(start, len(ordered_turns)):
selected_turns.append((record, ordered_turns[index]))
if len(selected_turns) < limit:
continue
absolute_raw = raw_offset + record_index
next_raw = absolute_raw
next_turn = index + 1
if next_turn >= len(ordered_turns):
next_raw += 1
next_turn = 0
if next_raw < raw_page.total:
if next_turn:
anchor_record = record
else:
next_index = record_index + 1
anchor_record = (
raw_page.raw[next_index]
if next_index < len(raw_page.raw)
else None
)
anchor = (
raw_anchor(anchor_record)
if anchor_record is not None
else f"p{raw_anchor(record)}"
)
next_cursor = (
f"turn:{next_raw}:{next_turn}:"
f"{emitted + len(selected_turns)}:{raw_page.total}:"
f"{anchor}:{cursor_scope}"
)
break
if len(selected_turns) >= limit:
break
if (
next_cursor is None
and raw_offset + len(raw_page.raw) < raw_page.total
):
if not raw_page.raw:
raise HTTPException(
status_code=409, detail="raw_cursor_stale"
)
next_raw = raw_offset + len(raw_page.raw)
previous_anchor = raw_anchor(raw_page.raw[-1])
next_cursor = (
f"turn:{next_raw}:0:{emitted + len(selected_turns)}:"
f"{raw_page.total}:p{previous_anchor}:{cursor_scope}"
)
return SdkRawPage(
user_id=user_id,
items=tuple(
SdkRawItem.of(record, turn)
for record, turn in selected_turns
),
# Cursor consumers do not use a precise total. This lower
# bound preserves the legacy numeric field without forcing
# a full scan merely to count variable-size turn batches.
total=emitted + len(selected_turns) + (1 if next_cursor else 0),
offset=emitted,
limit=limit,
unit="turn",
next_cursor=next_cursor,
has_more=next_cursor is not None,
total_exact=False,
)
if unit == "raw":
value = await asyncio.to_thread(
client.list_memories,
@@ -1,6 +1,6 @@
import hashlib
import os
from collections.abc import AsyncIterator, Iterator
from collections.abc import AsyncIterator, Callable, Iterator
from dataclasses import dataclass
from pathlib import Path
@@ -28,6 +28,7 @@ def discovered_off_loop(
suffixes: frozenset[str],
max_targets: int,
excluded_directory_names: frozenset[str] = frozenset(),
directory_predicate: Callable[[Path], bool] | None = None,
) -> AsyncIterator[SourceFileDiscoveryEvent]:
"""The same walk, for callers that have an event loop to keep free.
@@ -43,6 +44,7 @@ def discovered_off_loop(
suffixes=suffixes,
max_targets=max_targets,
excluded_directory_names=excluded_directory_names,
directory_predicate=directory_predicate,
),
scope_key=grant.source_id,
)
@@ -54,6 +56,7 @@ def discover_source_files(
suffixes: frozenset[str],
max_targets: int,
excluded_directory_names: frozenset[str] = frozenset(),
directory_predicate: Callable[[Path], bool] | None = None,
) -> Iterator[SourceFileDiscoveryEvent]:
"""Metadata only: one lstat per candidate, no open and no per-file grant lookup.
@@ -71,10 +74,16 @@ def discover_source_files(
grant.root,
followlinks=False,
):
current = Path(directory)
child_directories[:] = sorted(
child
for child in child_directories
if not (Path(directory) / child).is_symlink() and child not in excluded_directory_names
if not (current / child).is_symlink()
and child not in excluded_directory_names
and (
directory_predicate is None
or directory_predicate((current / child).relative_to(grant.root))
)
)
for filename in sorted(filenames):
if Path(filename).suffix.lower() not in suffixes:
@@ -591,7 +591,11 @@ class PollingLiveSyncSupervisor:
async def _poll_source(self, source_id: str) -> None:
while True:
await asyncio.sleep(self.interval_seconds)
# A watched source is normally triggered by its filesystem event.
# Its timer is only a missed-event backstop; recursively walking
# every multi-gigabyte host root once a minute starved unrelated
# Core reads even when no source had changed.
await asyncio.sleep(self._poll_interval(source_id))
try:
await self.trigger(source_id)
except asyncio.CancelledError:
@@ -608,6 +612,11 @@ class PollingLiveSyncSupervisor:
except Exception as again:
self._note_internal_error(source_id, again)
def _poll_interval(self, source_id: str) -> float:
if source_id in (self._watched_sources or ()):
return max(self.interval_seconds, 15 * 60)
return self.interval_seconds
def _note_worker_still_running(self, source_id: str) -> None:
current = self.health(source_id)
self._set_health(
@@ -226,6 +226,9 @@ class JsonlSourceAdapter:
self.header_gate = header_gate
self._header_cache: dict[str, tuple[str, dict[str, JsonValue] | None]] = {}
self._history_listing_cache: SourceListing | None = None
self._probe_listing_cache: tuple[
dict[str, SourceCursor], int | None, SourceListing
] | None = None
@property
def descriptor(self) -> SourceDescriptor:
@@ -281,6 +284,11 @@ class JsonlSourceAdapter:
predicate=self.file_predicate,
cursors=options.cursors,
)
self._probe_listing_cache = (
dict(options.cursors),
options.max_scan_targets,
listing,
)
return bool(listing.changed or listing.failures)
async def live_baseline(self) -> dict[str, SourceCursor]:
@@ -315,9 +323,11 @@ class JsonlSourceAdapter:
async for event in self._scan_frozen(options):
yield event
return
listing = await asyncio.to_thread(
self._history_listing if options.reuse_listing else self._fresh_listing,
options,
listing = (
await asyncio.to_thread(self._history_listing, options)
if options.reuse_listing
else self._take_probe_listing(options)
or await asyncio.to_thread(self._fresh_listing, options)
)
for failure in listing.failures:
yield ScanFailure(reason_code=discovery_failure_code(failure))
@@ -349,6 +359,16 @@ class JsonlSourceAdapter:
self._history_listing_cache = listing
return listing
def _take_probe_listing(self, options: ScanOptions) -> "SourceListing | None":
cached = self._probe_listing_cache
self._probe_listing_cache = None
if cached is None:
return None
cursors, max_scan_targets, listing = cached
if cursors != options.cursors or max_scan_targets != options.max_scan_targets:
return None
return listing
def _fresh_listing(self, options: ScanOptions) -> "SourceListing":
return list_changed_files(
self.grant,
@@ -9,6 +9,7 @@ from collections.abc import AsyncIterator, Iterator
from dataclasses import dataclass, replace
from datetime import UTC, datetime, timedelta, timezone
from functools import partial
from pathlib import Path
from pydantic import JsonValue
@@ -175,6 +176,9 @@ class CursorUnifiedSourceAdapter:
self.grant = grant
self.source_version = source_version
self._history_listing_cache: _CursorListing | None = None
self._probe_listing_cache: tuple[
dict[str, SourceCursor], int | None, _CursorListing
] | None = None
@property
def descriptor(self) -> SourceDescriptor:
@@ -207,6 +211,20 @@ class CursorUnifiedSourceAdapter:
state = await self.install_state()
return min(state.session_count, limit) if limit is not None else state.session_count
async def has_changes(self, options: ScanOptions) -> bool:
listing = await asyncio.to_thread(self._fresh_listing, options)
self._probe_listing_cache = (
dict(options.cursors),
options.max_scan_targets,
listing,
)
return bool(
listing.transcripts
or listing.stores
or listing.legacy
or listing.failures
)
async def live_baseline(self) -> dict[str, SourceCursor]:
"""Capture Cursor's current tail so the realtime lane never backfills history."""
listing = await asyncio.to_thread(
@@ -222,9 +240,11 @@ class CursorUnifiedSourceAdapter:
async for event in self._scan_frozen(options):
yield event
return
listing = await asyncio.to_thread(
self._history_listing if options.reuse_listing else self._fresh_listing,
options,
listing = (
await asyncio.to_thread(self._history_listing, options)
if options.reuse_listing
else self._take_probe_listing(options)
or await asyncio.to_thread(self._fresh_listing, options)
)
for failure in listing.failures:
yield ScanFailure(reason_code=discovery_failure_code(failure))
@@ -329,6 +349,16 @@ class CursorUnifiedSourceAdapter:
self._history_listing_cache = listing
return listing
def _take_probe_listing(self, options: ScanOptions) -> "_CursorListing | None":
cached = self._probe_listing_cache
self._probe_listing_cache = None
if cached is None:
return None
cursors, max_scan_targets, listing = cached
if cursors != options.cursors or max_scan_targets != options.max_scan_targets:
return None
return listing
def _fresh_listing(self, options: ScanOptions) -> "_CursorListing":
return _list_cursor_files(
self.grant,
@@ -376,6 +406,7 @@ class CursorUnifiedSourceAdapter:
self.grant,
suffixes=frozenset({".db"}),
max_targets=options.max_scan_targets or 100_000,
directory_predicate=_cursor_data_directory,
):
if isinstance(discovered, SourceFileDiscoveryFailure):
yield ScanFailure(reason_code=discovery_failure_code(discovered))
@@ -477,7 +508,10 @@ def _list_cursor_files(
legacy: list[AuthorizedSourceFile] = []
failures: list[SourceFileDiscoveryFailure] = []
for item in discover_source_files(
grant, suffixes=frozenset({".jsonl", ".db", ".vscdb"}), max_targets=max_targets
grant,
suffixes=frozenset({".jsonl", ".db", ".vscdb"}),
max_targets=max_targets,
directory_predicate=_cursor_data_directory,
):
if isinstance(item, SourceFileDiscoveryFailure):
failures.append(item)
@@ -586,6 +620,25 @@ def _is_cursor_data_file(source_file: AuthorizedSourceFile) -> bool:
)
def _cursor_data_directory(relative_path: Path) -> bool:
"""Enter only Cursor directories that can contain conversation authority.
The granted root also contains extensions, skills, caches, logs and agent
artifacts. Recursively walking all of them every live-sync poll made the
reader monopolize Core even though none can pass the file predicates.
"""
parts = relative_path.parts
if not parts:
return False
if parts[0] in {"chats", "acp-sessions"}:
return True
if parts[0] == "projects":
return len(parts) <= 2 or parts[2] == "agent-transcripts"
if parts[0] == "User":
return len(parts) == 1 or parts[1] in {"globalStorage", "workspaceStorage"}
return False
def _is_transcript_file(source_file: AuthorizedSourceFile) -> bool:
parts = source_file.relative_path.parts
return source_file.relative_path.suffix == ".jsonl" and "agent-transcripts" in parts
@@ -634,6 +687,7 @@ def _select_transcript_files(
grant,
suffixes=frozenset({".jsonl"}),
max_targets=100_000,
directory_predicate=_cursor_data_directory,
):
if not isinstance(item, AuthorizedSourceFile) or not _is_transcript_file(item):
continue
@@ -654,6 +708,7 @@ def _subagent_conversation_ids(grant: AuthorizedSourceRoot) -> set[str]:
grant,
suffixes=frozenset({".jsonl"}),
max_targets=100_000,
directory_predicate=_cursor_data_directory,
)
if isinstance(item, AuthorizedSourceFile)
and _is_transcript_file(item)
+16 -3
View File
@@ -26,6 +26,7 @@ if TYPE_CHECKING:
_operation: ContextVar[tuple["ModelProbeOperations", str] | None] = ContextVar(
"model_probe_operation", default=None
)
_dispatch_started: ContextVar[bool] = ContextVar("model_probe_dispatch_started", default=False)
class ProbeCode(StrEnum):
@@ -113,16 +114,26 @@ class ModelProber:
if existing is not None:
return existing
token = _operation.set((operations, request.operation_id))
dispatch_token = _dispatch_started.set(False)
try:
await self._probe(profile_id)
except ProbeOperationConflict:
await asyncio.to_thread(
operations.fail, request.operation_id, "model_probe_profile_changed"
)
except Exception:
# Unknown dispatch/storage outcomes stay reserved, including across restart.
raise ProbeOperationUnavailable("model_probe_operation_unavailable") from None
except Exception as exc:
if _dispatch_started.get():
# Once a request may have reached the provider, preserve the
# reservation so a retry cannot duplicate a possibly billed call.
raise ProbeOperationUnavailable("model_probe_operation_unavailable") from None
logging.getLogger(__name__).warning(
"model probe failed before dispatch: %s", type(exc).__name__
)
await asyncio.to_thread(
operations.fail, request.operation_id, "model_probe_execution_failed"
)
finally:
_dispatch_started.reset(dispatch_token)
_operation.reset(token)
return await asyncio.to_thread(operations.lookup, request.operation_id)
@@ -317,6 +328,8 @@ class ModelProber:
listing_optional: bool = False,
) -> ProbeStage:
await self._check_operation()
if _operation.get() is not None:
_dispatch_started.set(True)
started = monotonic()
try:
response = await client.request(
@@ -293,6 +293,11 @@ def test_health_states_that_watching_is_layered_on_the_timer(tmp_path: Path) ->
assert live.health(PI_DESCRIPTOR.source_id).mode == "watch_and_polling"
assert live.health("unknown-source").mode == "watch_and_polling"
live.set_watched_sources((PI_DESCRIPTOR.source_id,))
assert live._poll_interval(PI_DESCRIPTOR.source_id) == max(
live.interval_seconds, 15 * 60
)
assert live._poll_interval("unknown-source") == live.interval_seconds
await imports.shutdown()
asyncio.run(scenario())
@@ -91,6 +91,51 @@ def test_reserved_before_transport_and_atomic_result_reopens_without_resending(e
asyncio.run(scenario())
def test_pre_dispatch_failure_marks_operation_failed_and_unfences(env, monkeypatch):
store, secrets, profile, ops, calls, respond = env
operation = str(uuid4())
def fail_require(*args, **kwargs):
raise RuntimeError("profile load failed")
prober = ModelProber(store=store, secrets=secrets, transport=httpx.MockTransport(respond))
with monkeypatch.context() as patch:
patch.setattr(store, "require", fail_require)
receipt = asyncio.run(
prober.probe(
profile.profile_id,
operation_id=operation,
expected_profile_revision=profile.revision,
)
)
assert receipt.state == "failed"
assert receipt.code == "model_probe_execution_failed"
assert receipt.result is None
assert calls == []
assert ops.lookup(operation) == receipt
assert (
asyncio.run(
prober.probe(
profile.profile_id,
operation_id=operation,
expected_profile_revision=profile.revision,
)
)
== receipt
)
completed = asyncio.run(
prober.probe(
profile.profile_id,
operation_id=str(uuid4()),
expected_profile_revision=profile.revision,
)
)
assert completed.state == "completed"
assert len(calls) == 3
def test_same_id_and_same_profile_concurrency_refused_before_second_call(env):
store, secrets, profile, ops, calls, respond = env
@@ -77,6 +77,36 @@ def test_workbuddy_record_parsing_never_blocks_the_event_loop(tmp_path) -> None:
asyncio.run(scenario())
def test_jsonl_live_probe_listing_is_reused_by_the_import(tmp_path, monkeypatch) -> None:
root = tmp_path / ".workbuddy" / "projects"
write_jsonl(
root / "project" / "session.jsonl",
[{"role": "user", "sessionId": "session", "content": "new conversation"}],
)
adapter = create_workbuddy_adapter(grant("workbuddy", root))
from one_memory.imports.sources import common
calls = 0
original = common.list_changed_files
def counted(*args, **kwargs):
nonlocal calls
calls += 1
return original(*args, **kwargs)
monkeypatch.setattr(common, "list_changed_files", counted)
async def probe_then_scan():
options = ScanOptions()
assert await adapter.has_changes(options)
return [event async for event in adapter.scan(options)]
records = evidence(asyncio.run(probe_then_scan()))
assert [item.evidence.verbatim for item in records] == ["new conversation"]
assert calls == 1
def test_cursor_store_user_info_envelope_is_not_attributed_to_the_person(tmp_path) -> None:
"""A Cursor store blob can be the host's session context, not the user's words.
@@ -1352,6 +1382,73 @@ def test_cursor_transcripts_with_the_same_name_in_two_projects_are_both_kept(
assert len({item.evidence.source_ordinal for item in records}) == 2
def test_cursor_discovery_does_not_enter_non_conversation_trees(tmp_path) -> None:
root = tmp_path / ".cursor"
write_jsonl(
root / "projects" / "project" / "agent-transcripts" / "kept" / "kept.jsonl",
[
{
"role": "user",
"message": {"content": [{"type": "text", "text": "kept conversation"}]},
}
],
)
write_jsonl(
root
/ "extensions"
/ "large-cache"
/ "agent-transcripts"
/ "ignored"
/ "ignored.jsonl",
[
{
"role": "user",
"message": {"content": [{"type": "text", "text": "must not be scanned"}]},
}
],
)
records = evidence(asyncio.run(scan(CursorUnifiedSourceAdapter(grant=grant("cursor", root)))))
assert [item.evidence.verbatim for item in records] == ["kept conversation"]
def test_cursor_live_probe_listing_is_reused_by_the_import(tmp_path, monkeypatch) -> None:
root = tmp_path / ".cursor"
write_jsonl(
root / "projects" / "project" / "agent-transcripts" / "new" / "new.jsonl",
[
{
"role": "user",
"message": {"content": [{"type": "text", "text": "new conversation"}]},
}
],
)
adapter = CursorUnifiedSourceAdapter(grant=grant("cursor", root))
from one_memory.imports.adapters import ScanOptions
from one_memory.imports.sources import cursor_unified
calls = 0
original = cursor_unified._list_cursor_files
def counted(*args, **kwargs):
nonlocal calls
calls += 1
return original(*args, **kwargs)
monkeypatch.setattr(cursor_unified, "_list_cursor_files", counted)
async def probe_then_scan():
options = ScanOptions()
assert await adapter.has_changes(options)
return [event async for event in adapter.scan(options)]
records = evidence(asyncio.run(probe_then_scan()))
assert [item.evidence.verbatim for item in records] == ["new conversation"]
assert calls == 1
def test_cursor_bounded_scans_reuse_the_authorized_file_listing(tmp_path, monkeypatch) -> None:
root = tmp_path / ".cursor"
transcript = root / "projects" / "project" / "agent-transcripts" / "session" / "session.jsonl"
+78 -1
View File
@@ -214,7 +214,15 @@ class RecordingSdk:
)
and (not kwargs.get("session_ids") or row["session_id"] in kwargs["session_ids"])
]
return {"vdb": {"memories": rows, "total": len(rows)}}
total = len(rows)
offset = kwargs.get("offset", 0)
limit = kwargs.get("limit", 100)
return {
"vdb": {
"memories": rows[offset : offset + limit],
"total": total,
}
}
rows = [_chat(user_id, *row[:2], *row[3:]) for row in CHAT_ROWS.get(user_id, [])]
if layers:
rows = [row for row in rows if row["layer"] in layers]
@@ -712,6 +720,75 @@ def test_raw_lists_active_and_shadow_turns_with_roles_and_hashes(tmp_path, monke
["我喜欢绿茶", "也记住了。"],
["我叫费天翔", "好的,记住了。"],
]
cursor = None
first_cursor = None
cursor_turns = []
cursor_calls = len(sdk.all("list_memories"))
while True:
params = {"unit": "turn", "paging": "cursor", "limit": 1}
if cursor is not None:
params["cursor"] = cursor
response = await client.get("/api/v2/raw", params=params)
assert response.status_code == 200, response.text
cursor_page = response.json()
assert cursor_page["total_exact"] is False
assert len(cursor_page["items"]) == 1
cursor_turns.append(cursor_page["items"][0]["messages"][0]["content"])
cursor = cursor_page["next_cursor"]
if first_cursor is None:
first_cursor = cursor
if cursor is None:
assert cursor_page["has_more"] is False
break
assert cursor_page["has_more"] is True
assert cursor_turns == ["我喜欢绿茶", "我叫费天翔", "你好"]
assert len(sdk.all("list_memories")) == cursor_calls + 3
inserted = {
"memory_id": "raw-new",
"content": "[user]: 新消息",
"status": "active",
"session_id": "s-new",
"source": "cursor",
"memory_at": 1_790_200_000,
"gmt_created": 1_790_200_100,
}
RAW_ROWS["local"].insert(0, inserted)
try:
continued = await client.get(
"/api/v2/raw",
params={
"unit": "turn",
"paging": "cursor",
"limit": 1,
"cursor": first_cursor,
},
)
assert continued.status_code == 200, continued.text
assert continued.json()["items"][0]["messages"][0]["content"] == "我叫费天翔"
finally:
RAW_ROWS["local"].remove(inserted)
assert (
await client.get(
"/api/v2/raw",
params={
"unit": "turn",
"paging": "cursor",
"cursor": first_cursor,
"source": "hermes",
},
)
).status_code == 422
assert (
await client.get(
"/api/v2/raw",
params={
"unit": "turn",
"paging": "cursor",
"cursor": "turn:²:0:0:2::0000000000000000",
},
)
).status_code == 422
calls_before_refresh = len(sdk.all("list_memories"))
await client.get("/api/v2/raw", params={"unit": "turn"})
assert len(sdk.all("list_memories")) == calls_before_refresh + 1
@@ -366,7 +366,7 @@ it("refuses a ui trace from an untrusted window", async () => {
).rejects.toThrow(/untrusted_window/);
});
it("marks a problem and exports a redacted local bundle", async () => {
it("marks a problem and exports the complete local log bundle", async () => {
const value = await fixture();
const mark = await value.handlers.get(
"one-memory:mark-diagnostic-incident",
@@ -375,6 +375,14 @@ it("marks a problem and exports a redacted local bundle", async () => {
incident_id: expect.any(String),
session_id: expect.any(String),
});
await writeFile(
path.join(value.dataDir, "logs", "core.log"),
"2026-10-14 10:31:00 WARNING database wait\n",
);
await writeFile(
path.join(value.dataDir, "logs", "service-error.log"),
"worker failed\n",
);
const exported = await value.handlers.get("one-memory:export-diagnostics")?.(
{},
);
@@ -398,6 +406,16 @@ it("marks a problem and exports a redacted local bundle", async () => {
),
);
expect(summary).toMatchObject({ build: "3800" });
const entries = execFileSync(
"/usr/bin/unzip",
["-Z1", path.join(value.dataDir, zip)],
{ encoding: "utf8" },
)
.trim()
.split("\n");
expect(entries).toContain("logs/core.log");
expect(entries).toContain("logs/service-error.log");
expect(entries).toContain("logs/diagnostic-desktop.jsonl");
value.dispose();
});
+3 -1
View File
@@ -196,6 +196,7 @@ export function resolveDiagnosticBuildIdentity(options: {
export async function exportDesktopDiagnostics(
options: DiagnosticExportOptions,
exportOptions: { includeRawLogs?: boolean } = {},
) {
const build = options.buildNumber ?? options.productVersion;
const target = path.join(
@@ -209,6 +210,7 @@ export async function exportDesktopDiagnostics(
dataDir: options.dataDir,
target,
productVersion: build,
includeRawLogs: exportOptions.includeRawLogs === true,
...(options.commit === undefined ? {} : { commit: options.commit }),
...(options.coreSha256 === undefined
? {}
@@ -644,7 +646,7 @@ export function installDiagnosticsIpc(
options.ipc.handle(EXPORT, async (event, ...args) => {
if (args.length) throw new Error("diagnostic_arguments_rejected");
options.requireTrustedWindow(event);
return exportDesktopDiagnostics(options);
return exportDesktopDiagnostics(options, { includeRawLogs: true });
});
return () => {
+1
View File
@@ -226,6 +226,7 @@ describe("HealthPanel", () => {
});
vi.spyOn(window, "prompt").mockReturnValue("蓝莓没出来");
render(<HealthPanel imports={imports()} />);
expect(screen.getByText(/可能包含会话正文和本机路径/)).toBeTruthy();
fireEvent.click(screen.getByRole("button", { name: "记录问题时间点" }));
expect(await screen.findByText(/问题编号已复制/)).toBeTruthy();
+1
View File
@@ -298,6 +298,7 @@ export function HealthPanel({ imports }: { imports: ImportState }) {
<Download size={16} aria-hidden="true" />
{t("nativeExportLogs")}
</R6Button>
<p>{t("把本机运行日志打包成一个文件,发给开发排查。")}</p>
</div>
<section className="health-block" aria-label={t("healthLocal")}>
+78 -56
View File
@@ -110,12 +110,15 @@ type Props = {
settledSource?: string;
settledQuery?: string;
requestedOffset?: number;
cursorPaging?: boolean;
hasMore?: 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;
onPage(offset: number): void;
onMore?(): void;
onRetry(): void;
};
};
@@ -1131,62 +1134,81 @@ export function MemoryMap(props: Props) {
aria-busy={pageTransitioning || undefined}
className="map-pagination"
>
<button
type="button"
className="quiet map-tool"
aria-label={text("上一页记忆")}
title={text("上一页记忆")}
disabled={pageTransitioning || library.page.offset === 0}
onClick={() => {
if (axis === "topic") setGroupKey("");
if (library.page) {
const offset = library.page.offset - library.page.limit;
setPageRetrying(false);
pageRetrySawLoading.current = false;
setPendingPageOffset(offset);
library.onPage(offset);
}
}}
>
<ChevronLeft size={16} aria-hidden="true" />
</button>
<span>
{text(
"共 {count} 条,第 {page} / {pages} 页",
{
count: count(library.page.total),
page:
library.page.total === 0
? 0
: Math.floor(library.page.offset / library.page.limit) +
1,
pages: Math.ceil(library.page.total / library.page.limit),
},
library.page.total,
)}
</span>
<button
type="button"
className="quiet map-tool"
aria-label={text("下一页记忆")}
title={text("下一页记忆")}
disabled={
pageTransitioning ||
library.page.offset + library.page.limit >= library.page.total
}
onClick={() => {
if (axis === "topic") setGroupKey("");
if (library.page) {
const offset = library.page.offset + library.page.limit;
setPageRetrying(false);
pageRetrySawLoading.current = false;
setPendingPageOffset(offset);
library.onPage(offset);
}
}}
>
<ChevronRight size={16} aria-hidden="true" />
</button>
{library.cursorPaging ? (
library.hasMore ? (
<R6Button
disabled={pageTransitioning}
onClick={() => library.onMore?.()}
>
{pageTransitioning ? text("正在读取…") : text("加载更多")}
</R6Button>
) : (
<span>{text("已加载全部")}</span>
)
) : (
<>
<button
type="button"
className="quiet map-tool"
aria-label={text("上一页记忆")}
title={text("上一页记忆")}
disabled={pageTransitioning || library.page.offset === 0}
onClick={() => {
if (axis === "topic") setGroupKey("");
if (library.page) {
const offset = library.page.offset - library.page.limit;
setPageRetrying(false);
pageRetrySawLoading.current = false;
setPendingPageOffset(offset);
library.onPage(offset);
}
}}
>
<ChevronLeft size={16} aria-hidden="true" />
</button>
<span>
{text(
"共 {count} 条,第 {page} / {pages} 页",
{
count: count(library.page.total),
page:
library.page.total === 0
? 0
: Math.floor(
library.page.offset / library.page.limit,
) + 1,
pages: Math.ceil(
library.page.total / library.page.limit,
),
},
library.page.total,
)}
</span>
<button
type="button"
className="quiet map-tool"
aria-label={text("下一页记忆")}
title={text("下一页记忆")}
disabled={
pageTransitioning ||
library.page.offset + library.page.limit >=
library.page.total
}
onClick={() => {
if (axis === "topic") setGroupKey("");
if (library.page) {
const offset = library.page.offset + library.page.limit;
setPageRetrying(false);
pageRetrySawLoading.current = false;
setPendingPageOffset(offset);
library.onPage(offset);
}
}}
>
<ChevronRight size={16} aria-hidden="true" />
</button>
</>
)}
</nav>
)}
{!library && loaded && !filtered.length && (search || groupKey) && (
+122 -4
View File
@@ -300,13 +300,131 @@ it("shows the default nightly evolve controls in ultra", async () => {
"aria-checked",
),
).toBe("true");
expect((screen.getByLabelText("执行时间") as HTMLInputElement).value).toBe(
"03:00",
);
const timePicker = screen.getByRole("button", {
name: "执行时间,03:00",
});
expect(timePicker.tagName).toBe("BUTTON");
expect(timePicker.textContent).toContain("03:00");
expect(screen.getByText(/只运行这一次,不改变每天的计划/)).toBeTruthy();
expect(screen.getByRole("button", { name: "立即整合" })).toBeTruthy();
});
it("opens the time picker from the whole control and saves a complete time", async () => {
prepare("ultra");
vi.mocked(setEvolveSchedule).mockResolvedValue({
enabled: true,
time: "04:30",
});
const user = userEvent.setup();
render(<MemoryModeSettings hasActiveModel onConfigureModel={vi.fn()} />);
const trigger = await screen.findByRole("button", {
name: "执行时间,03:00",
});
await user.click(trigger);
const picker = screen.getByRole("dialog", { name: "执行时间" });
await user.selectOptions(
within(picker).getByRole("listbox", { name: "执行时间 HH" }),
"04",
);
await user.selectOptions(
within(picker).getByRole("listbox", { name: "执行时间 MM" }),
"30",
);
expect(setEvolveSchedule).not.toHaveBeenCalled();
await user.click(within(picker).getByRole("button", { name: "保存修改" }));
await waitFor(() =>
expect(setEvolveSchedule).toHaveBeenCalledExactlyOnceWith({
time: "04:30",
}),
);
await waitFor(() =>
expect(screen.getByRole("button", { name: "执行时间,04:30" })).toBe(
document.activeElement,
),
);
expect(screen.queryByRole("dialog", { name: "执行时间" })).toBeNull();
});
it("keeps Tab inside the time picker and restores focus on Escape", async () => {
prepare("ultra");
const user = userEvent.setup();
render(<MemoryModeSettings hasActiveModel onConfigureModel={vi.fn()} />);
const trigger = await screen.findByRole("button", {
name: "执行时间,03:00",
});
await user.click(trigger);
const picker = screen.getByRole("dialog", { name: "执行时间" });
const hours = within(picker).getByRole("listbox", {
name: "执行时间 HH",
});
const save = within(picker).getByRole("button", { name: "保存修改" });
expect(document.activeElement).toBe(hours);
await user.keyboard("{Shift>}{Tab}{/Shift}");
expect(document.activeElement).toBe(save);
await user.keyboard("{Tab}");
expect(document.activeElement).toBe(hours);
await user.keyboard("{Escape}");
expect(screen.queryByRole("dialog", { name: "执行时间" })).toBeNull();
expect(document.activeElement).toBe(trigger);
});
it("keeps the selected time open for retry when saving fails", async () => {
prepare("ultra");
vi.mocked(setEvolveSchedule).mockRejectedValue(new Error("offline"));
const user = userEvent.setup();
render(<MemoryModeSettings hasActiveModel onConfigureModel={vi.fn()} />);
await user.click(
await screen.findByRole("button", { name: "执行时间,03:00" }),
);
const picker = screen.getByRole("dialog", { name: "执行时间" });
const minutes = within(picker).getByRole("listbox", {
name: "执行时间 MM",
});
await user.selectOptions(minutes, "30");
await user.click(within(picker).getByRole("button", { name: "保存修改" }));
expect(
await screen.findByText("自动整合设置未能保存,请重试。"),
).toBeTruthy();
expect(screen.getByRole("dialog", { name: "执行时间" })).toBeTruthy();
expect((minutes as HTMLSelectElement).value).toBe("30");
await waitFor(() =>
expect(document.activeElement).toBe(
within(picker).getByRole("button", { name: "保存修改" }),
),
);
});
it("keeps the time dialog and focus stable while saving is pending", async () => {
prepare("ultra");
vi.mocked(setEvolveSchedule).mockReturnValue(new Promise(() => {}));
const user = userEvent.setup();
render(<MemoryModeSettings hasActiveModel onConfigureModel={vi.fn()} />);
await user.click(
await screen.findByRole("button", { name: "执行时间,03:00" }),
);
const picker = screen.getByRole("dialog", { name: "执行时间" });
await user.selectOptions(
within(picker).getByRole("listbox", { name: "执行时间 MM" }),
"30",
);
await user.click(within(picker).getByRole("button", { name: "保存修改" }));
await waitFor(() => expect(setEvolveSchedule).toHaveBeenCalledTimes(1));
expect(document.activeElement).toBe(picker);
await user.keyboard("{Escape}");
expect(screen.getByRole("dialog", { name: "执行时间" })).toBe(picker);
expect(document.activeElement).toBe(picker);
});
it("hides the time field when automatic evolve is off and keeps the saved time visible", async () => {
prepare("ultra");
vi.mocked(setEvolveSchedule).mockResolvedValue({
@@ -322,7 +440,7 @@ it("hides the time field when automatic evolve is off and keeps the saved time v
await user.click(automatic);
await screen.findByText(/重新开启后仍按 03:00 运行/);
expect(screen.queryByLabelText("执行时间")).toBeNull();
expect(screen.queryByRole("button", { name: /执行时间/ })).toBeNull();
expect(screen.getByRole("button", { name: "立即整合" })).toBeTruthy();
});
+6 -5
View File
@@ -29,6 +29,7 @@ import { MemoryModeChoiceList } from "./MemoryModeChoiceList";
import { useOnboardingTranslation } from "./onboarding-language";
import { R6Button, R6Switch } from "./R6Controls";
import { R6StateRecovery } from "./R6StateRecovery";
import { TimePicker } from "./TimePicker";
const MODE_COPY: Record<
MemoryPipelineMode,
@@ -332,8 +333,10 @@ export function MemoryModeSettings({
setError("");
try {
setScheduleState(await setEvolveSchedule(update));
return true;
} catch {
setError("自动整合设置未能保存,请重试。");
return false;
} finally {
setBusy(false);
}
@@ -455,14 +458,12 @@ export function MemoryModeSettings({
</label>
<p>{text("使用这台设备的本地时间。")}</p>
</div>
<input
<TimePicker
id={`${id}-evolve-time`}
type="time"
label={text("执行时间")}
value={schedule.time}
disabled={busy || maintenance}
onChange={(event) =>
void changeSchedule({ time: event.target.value })
}
onChange={(time) => changeSchedule({ time })}
/>
</div>
) : null}
+28 -12
View File
@@ -320,9 +320,9 @@ it("pages and filters the raw conversation data plane in lite", async () => {
setup();
reads.processing.mockResolvedValue(modeProcessing("lite"));
reads.raw.mockImplementation(async (options = {}) => {
const offset = "offset" in options ? (options.offset ?? 0) : 0;
const cursor = "cursor" in options ? options.cursor : null;
const source = "source" in options ? options.source : undefined;
const rawId = `${source ?? "all"}-${offset}`;
const rawId = `${source ?? "all"}-${cursor ? "more" : "first"}`;
return {
items: [
{
@@ -333,14 +333,17 @@ it("pages and filters the raw conversation data plane in lite", async () => {
source: source ?? "cursor",
session_id: `session-${rawId}`,
memory_at: null,
gmt_created: 1_761_000_000 - offset,
gmt_created: 1_761_000_000,
custom: {},
messages: [{ role: "user", content: `原始会话 ${rawId}` }],
},
],
total: source ? 1 : 21,
total: cursor ? 2 : 1,
limit: 20,
offset,
offset: cursor ? 1 : 0,
next_cursor: !source && !cursor ? "turn:1:0:1" : null,
has_more: !source && !cursor,
total_exact: false,
};
});
const user = userEvent.setup();
@@ -359,30 +362,43 @@ it("pages and filters the raw conversation data plane in lite", async () => {
/>,
);
expect(await screen.findAllByText("原始会话 all-0")).toHaveLength(2);
await user.click(screen.getByRole("button", { name: "下一页记忆" }));
expect(await screen.findAllByText("原始会话 all-20")).toHaveLength(2);
expect(await screen.findAllByText("原始会话 all-first")).toHaveLength(2);
await user.click(screen.getByRole("button", { name: "加载更多" }));
expect(await screen.findAllByText("原始会话 all-more")).toHaveLength(1);
expect(screen.getAllByText("原始会话 all-first")).toHaveLength(2);
expect(reads.raw).toHaveBeenLastCalledWith(
expect.objectContaining({ source: undefined, offset: 20, limit: 20 }),
expect.objectContaining({
source: undefined,
cursor: "turn:1:0:1",
paging: "cursor",
limit: 20,
}),
);
await user.click(screen.getByRole("combobox", { name: "来源" }));
await user.click(screen.getByRole("option", { name: "Cursor" }));
expect(await screen.findAllByText("原始会话 cursor-0")).toHaveLength(2);
expect(await screen.findAllByText("原始会话 cursor-first")).toHaveLength(2);
expect(reads.raw).toHaveBeenLastCalledWith(
expect.objectContaining({ source: "cursor", offset: 0, limit: 20 }),
expect.objectContaining({
source: "cursor",
cursor: null,
paging: "cursor",
limit: 20,
}),
);
await user.click(screen.getByRole("combobox", { name: "来源" }));
await user.click(screen.getByRole("option", { name: "手动添加" }));
expect(
await screen.findAllByText("原始会话 one-memory-manual-0"),
await screen.findAllByText("原始会话 one-memory-manual-first"),
).toHaveLength(2);
expect(reads.raw).toHaveBeenLastCalledWith(
expect.objectContaining({
source: "one-memory-manual",
offset: 0,
limit: 20,
paging: "cursor",
cursor: null,
}),
);
});
+96 -21
View File
@@ -123,6 +123,7 @@ export function MemorySpace({
const selectedRawRef = useRef<RawMapRecord | null>(null);
selectedRawRef.current = selectedRaw;
const rawRecords = useRef(new Map<string, RawMapRecord>());
const rawRecordsScope = useRef("");
const [detailLoad, setDetailLoad] = useState<{
memoryId: string;
status: "loading" | "failed";
@@ -209,6 +210,7 @@ export function MemorySpace({
const [sdkLibraryQuery, setSdkLibraryQuery] = useState("");
const [sdkLibrarySource, setSdkLibrarySource] = useState("");
const [sdkLibraryOffset, setSdkLibraryOffset] = useState(0);
const [sdkRawCursor, setSdkRawCursor] = useState<string | null>(null);
const [processing, setProcessing] = useState<MemoryProcessingSummary | null>(
null,
);
@@ -226,6 +228,10 @@ export function MemorySpace({
evolve_available: pipelineMode === "ultra",
}
: null;
const refreshSdkMap = useCallback(() => {
if (lite) setSdkRawCursor(null);
bumpSdkMap();
}, [bumpSdkMap, lite]);
const requestedQuery = sdkLibraryQuery.trim();
const sdkPageSettled =
sdkLibrary.settledMode === pipelineMode &&
@@ -276,7 +282,10 @@ export function MemorySpace({
if (first) setSdkLibrary((current) => ({ ...current, loading: true }));
try {
const rememberRaw = (items: RawMapRecord[]) => {
const next = new Map<string, RawMapRecord>();
const next =
sdkRawCursor && rawRecordsScope.current === windowKey
? new Map(rawRecords.current)
: new Map<string, RawMapRecord>();
if (selectedRawRef.current)
next.set(
rawRecordId(selectedRawRef.current),
@@ -284,6 +293,7 @@ export function MemorySpace({
);
for (const item of items) next.set(rawRecordId(item), item);
rawRecords.current = next;
rawRecordsScope.current = windowKey;
};
const [page, graph] = lite
? await Promise.all([
@@ -297,8 +307,10 @@ export function MemorySpace({
})
: getSdkRaw({
source,
offset: sdkLibraryOffset,
offset: 0,
limit: SDK_MEMORY_PAGE_SIZE,
paging: "cursor",
cursor: sdkRawCursor,
signal: request.signal,
}).then((rawPage) => {
rememberRaw(rawPage.items);
@@ -346,17 +358,64 @@ export function MemorySpace({
};
// The window expired: the server searched again and answered page 1.
if (page.window_reset && sdkLibraryOffset !== 0) setSdkLibraryOffset(0);
setSdkLibrary({
page,
loading: false,
failed: false,
settledSource: sdkLibrarySource,
settledQuery: sdkLibraryQuery,
settledMode: pipelineMode,
setSdkLibrary((current) => {
const currentPage = current.page;
const append =
lite &&
sdkRawCursor !== null &&
currentPage !== null &&
current.settledSource === sdkLibrarySource &&
current.settledQuery === sdkLibraryQuery &&
current.settledMode === pipelineMode;
if (!append)
return {
page,
loading: false,
failed: false,
settledSource: sdkLibrarySource,
settledQuery: sdkLibraryQuery,
settledMode: pipelineMode,
};
const items = [...currentPage.items];
const known = new Set(items.map((item) => item.memory_id));
for (const item of page.items)
if (!known.has(item.memory_id)) {
known.add(item.memory_id);
items.push(item);
}
return {
page: {
...page,
items,
total: items.length + (page.has_more ? 1 : 0),
offset: 0,
source_ids_by_memory: {
...currentPage.source_ids_by_memory,
...page.source_ids_by_memory,
},
source_time_by_memory: {
...currentPage.source_time_by_memory,
...page.source_time_by_memory,
},
},
loading: false,
failed: false,
settledSource: sdkLibrarySource,
settledQuery: sdkLibraryQuery,
settledMode: pipelineMode,
};
});
setSdkGraph({ scope: windowKey, ...graph });
} catch {
} catch (reason) {
if (cancelled || request.signal.aborted) return;
if (
lite &&
sdkRawCursor !== null &&
apiErrorCode(reason) === "raw_cursor_stale"
) {
setSdkRawCursor(null);
return;
}
setSdkLibrary((current) => ({
...current,
loading: false,
@@ -365,10 +424,13 @@ export function MemorySpace({
}
};
void read(true);
const timer = setInterval(() => void read(false), 30_000);
const timer =
lite && sdkRawCursor !== null
? undefined
: setInterval(() => void read(false), 30_000);
return () => {
cancelled = true;
clearInterval(timer);
if (timer !== undefined) clearInterval(timer);
controller?.abort();
};
}, [
@@ -377,6 +439,7 @@ export function MemorySpace({
sdkLibraryOffset,
sdkLibraryQuery,
sdkLibrarySource,
sdkRawCursor,
lite,
pipelineMode,
]);
@@ -525,6 +588,7 @@ export function MemorySpace({
rawRecords.current.clear();
sdkSearchWindow.current = { key: "", id: null };
setSdkLibraryOffset(0);
setSdkRawCursor(null);
setSdkLibraryQuery("");
setSdkGraph(null);
setSdkLibrary((current) => ({
@@ -804,7 +868,7 @@ export function MemorySpace({
);
trace.done("succeeded");
closeDetail();
bumpSdkMap();
refreshSdkMap();
} catch (reason) {
trace.done(writeTraceStatus(reason), {
reason: "memory_action_failed",
@@ -833,7 +897,7 @@ export function MemorySpace({
trace.done("succeeded");
setDetail(sdkDetail(saved));
setDraft(sdkEditableText(saved));
bumpSdkMap();
refreshSdkMap();
} catch (reason) {
trace.done(writeTraceStatus(reason), {
reason: isDefiniteRefusal(reason)
@@ -898,10 +962,12 @@ export function MemorySpace({
? t("memory:记忆暂时无法读取")
: lite
? sdkPage
? `${t("memory:原始对话")} · ${tn(
"sdk.raw.found",
sdkPage.total,
)}`
? sdkPage.total_exact === false
? t("memory:原始对话")
: `${t("memory:原始对话")} · ${tn(
"sdk.raw.found",
sdkPage.total,
)}`
: sdkLibrary.failed
? null
: t("memory:正在读取…")
@@ -934,7 +1000,7 @@ export function MemorySpace({
onSaved={() => {
// A finished addition is new page content: re-read now rather than
// leave it for the next poll.
bumpSdkMap();
refreshSdkMap();
}}
onOpenMemory={(id) => {
if (!simplified) setMode("library");
@@ -1201,12 +1267,21 @@ export function MemorySpace({
settledSource: sdkLibrary.settledSource,
settledQuery: sdkLibrary.settledQuery,
requestedOffset: sdkLibraryOffset,
cursorPaging: lite && !sdkLibraryQuery.trim(),
hasMore: !!sdkPage?.has_more && !!sdkPage?.next_cursor,
unattributedSource: UNATTRIBUTED_SOURCE_ID,
onQuery: setSdkLibraryQuery,
onSource: setSdkLibrarySource,
resetPage: () => setSdkLibraryOffset(0),
resetPage: () => {
setSdkLibraryOffset(0);
setSdkRawCursor(null);
},
onPage: (offset) => setSdkLibraryOffset(offset),
onRetry: bumpSdkMap,
onMore: () => {
if (sdkPage?.next_cursor)
setSdkRawCursor(sdkPage.next_cursor);
},
onRetry: refreshSdkMap,
}
: undefined
}
+276 -1
View File
@@ -100,6 +100,18 @@ vi.mock("./OnboardingModelController", () => ({
>
模型启用回执已确认
</button>
<button
type="button"
onClick={() => {
onProfilePersisted("8be6d465-4cf2-4c72-93f7-15f3c012bc80");
onModelActiveConfirmed(
generationToken,
"8be6d465-4cf2-4c72-93f7-15f3c012bc80",
);
}}
>
第二个模型启用回执已确认
</button>
<button type="button" onClick={onSkip ?? onBack}>
跳过模型进入应用
</button>
@@ -445,6 +457,155 @@ describe("OnboardingFlow S1.3", () => {
).toBeTruthy();
});
it("accepts Lite once while the import submission is still in flight", async () => {
const user = userEvent.setup();
let resolveImport!: (
value: Awaited<
ReturnType<ImportSourcesController["connectForOnboarding"]>
>,
) => void;
const controller = imports({
connectForOnboarding: vi.fn(
() =>
new Promise<
Awaited<ReturnType<ImportSourcesController["connectForOnboarding"]>>
>((resolve) => {
resolveImport = resolve;
}),
),
});
const finish = vi.fn();
setMemoryMode.mockResolvedValueOnce({ mode: "lite" });
window.oneMemory = {} as NonNullable<typeof window.oneMemory>;
render(
<OnboardingFlow
state={onboarding}
imports={controller}
onFinish={finish}
/>,
);
await choose(user);
await approve(user);
await user.click(screen.getByRole("button", { name: START }));
await user.click(
await screen.findByRole("button", { name: "选择记忆模式" }),
);
await user.click(screen.getByRole("radio", { name: /lite/i }));
const enter = screen.getByRole("button", { name: "使用 Lite 进入" });
expect(enter).toHaveProperty("disabled", false);
await user.click(enter);
await user.click(enter);
await waitFor(() =>
expect(finish).toHaveBeenCalledExactlyOnceWith("sources"),
);
expect(setMemoryMode).toHaveBeenCalledExactlyOnceWith("lite");
expect(completeOnboarding).toHaveBeenCalledOnce();
await act(async () =>
resolveImport({
accepted_source_ids: ["claude"],
failed_sources: {},
jobs: [],
}),
);
expect(finish).toHaveBeenCalledOnce();
expect(completeOnboarding).toHaveBeenCalledOnce();
});
it("shows distinct saving and completion feedback after one Lite click", async () => {
const user = userEvent.setup();
let resolveMode!: (value: { mode: "lite" }) => void;
let resolveCompletion!: (value: OnboardingState) => void;
setMemoryMode.mockReturnValueOnce(
new Promise((resolve) => {
resolveMode = resolve;
}),
);
completeOnboarding.mockReturnValueOnce(
new Promise((resolve) => {
resolveCompletion = resolve;
}),
);
const finish = vi.fn();
render(
<OnboardingFlow
state={onboarding}
imports={imports({ sources: [] })}
onFinish={finish}
/>,
);
await openApps();
await user.click(screen.getByRole("button", { name: SKIP }));
await user.click(screen.getByRole("radio", { name: /lite/i }));
await user.click(screen.getByRole("button", { name: "使用 Lite 进入" }));
expect(
screen.getByRole("button", { name: "正在保存模式…" }),
).toHaveProperty("disabled", true);
await act(async () => resolveMode({ mode: "lite" }));
await waitFor(() =>
expect(
screen.getByRole("button", { name: "正在完成首次设置…" }),
).toHaveProperty("disabled", true),
);
expect(finish).not.toHaveBeenCalled();
await act(async () =>
resolveCompletion({
...onboarding,
status: "completed",
}),
);
await waitFor(() =>
expect(finish).toHaveBeenCalledExactlyOnceWith("sources"),
);
});
it("fences duplicate Pro activation receipts while completion is pending", async () => {
const user = userEvent.setup();
let resolveCompletion!: (value: OnboardingState) => void;
completeOnboarding.mockReturnValueOnce(
new Promise((resolve) => {
resolveCompletion = resolve;
}),
);
const finish = vi.fn();
render(
<OnboardingFlow
state={onboarding}
imports={imports({ sources: [] })}
onFinish={finish}
/>,
);
await openApps();
await user.click(screen.getByRole("button", { name: SKIP }));
await user.click(screen.getByRole("radio", { name: /pro/i }));
await user.click(screen.getByRole("button", { name: "继续配置模型" }));
const receipt = screen.getByRole("button", {
name: "模型启用回执已确认",
});
fireEvent.click(receipt);
fireEvent.click(receipt);
await waitFor(() => expect(completeOnboarding).toHaveBeenCalledOnce());
expect(setMemoryMode).toHaveBeenCalledExactlyOnceWith("pro");
expect(finish).not.toHaveBeenCalled();
await act(async () =>
resolveCompletion({
...onboarding,
status: "completed",
}),
);
await waitFor(() =>
expect(finish).toHaveBeenCalledExactlyOnceWith("sources"),
);
});
it("D47 entering the same-page model step does not finish setup or grant processing", async () => {
const finish = vi.fn();
render(
@@ -862,6 +1023,7 @@ describe("OnboardingFlow S1.3", () => {
it("serializes an in-flight Pro PUT before a later Lite choice so Lite wins", async () => {
let resolvePro!: (value: { mode: "pro" }) => void;
let resolveLite!: (value: { mode: "lite" }) => void;
setMemoryMode
.mockImplementationOnce(
() =>
@@ -869,7 +1031,12 @@ describe("OnboardingFlow S1.3", () => {
resolvePro = resolve;
}),
)
.mockResolvedValueOnce({ mode: "lite" });
.mockImplementationOnce(
() =>
new Promise((resolve) => {
resolveLite = resolve;
}),
);
const user = userEvent.setup();
const finish = vi.fn();
render(
@@ -900,10 +1067,118 @@ describe("OnboardingFlow S1.3", () => {
"pro",
"lite",
]);
expect(
screen.getByRole("button", { name: "正在保存模式…" }),
).toHaveProperty("disabled", true);
await act(async () => resolveLite({ mode: "lite" }));
await waitFor(() => expect(finish).toHaveBeenCalledWith("sources"));
expect(completeOnboarding).toHaveBeenCalledOnce();
});
it("accepts a new Pro generation while an older Pro write is pending", async () => {
let resolveFirst!: (value: { mode: "pro" }) => void;
let resolveSecond!: (value: { mode: "pro" }) => void;
setMemoryMode
.mockImplementationOnce(
() =>
new Promise((resolve) => {
resolveFirst = resolve;
}),
)
.mockImplementationOnce(
() =>
new Promise((resolve) => {
resolveSecond = resolve;
}),
);
const user = userEvent.setup();
const finish = vi.fn();
render(
<OnboardingFlow
state={onboarding}
imports={imports({ sources: [] })}
onFinish={finish}
/>,
);
await openApps();
await user.click(screen.getByRole("button", { name: SKIP }));
await user.click(screen.getByRole("radio", { name: /pro/i }));
await user.click(screen.getByRole("button", { name: "继续配置模型" }));
await user.click(
screen.getByRole("button", { name: "模型启用回执已确认" }),
);
await waitFor(() => expect(setMemoryMode).toHaveBeenCalledTimes(1));
await user.click(screen.getByRole("button", { name: "返回导入" }));
await user.click(screen.getByRole("button", { name: "继续配置模型" }));
await user.click(
screen.getByRole("button", { name: "模型启用回执已确认" }),
);
expect(setMemoryMode).toHaveBeenCalledTimes(1);
await act(async () => resolveFirst({ mode: "pro" }));
await waitFor(() => expect(setMemoryMode).toHaveBeenCalledTimes(2));
expect(finish).not.toHaveBeenCalled();
await act(async () => resolveSecond({ mode: "pro" }));
await waitFor(() =>
expect(finish).toHaveBeenCalledExactlyOnceWith("sources"),
);
expect(completeOnboarding).toHaveBeenCalledOnce();
});
it("accepts a new profile receipt within the same Pro generation", async () => {
let resolveFirst!: (value: { mode: "pro" }) => void;
let resolveSecond!: (value: { mode: "pro" }) => void;
setMemoryMode
.mockImplementationOnce(
() =>
new Promise((resolve) => {
resolveFirst = resolve;
}),
)
.mockImplementationOnce(
() =>
new Promise((resolve) => {
resolveSecond = resolve;
}),
);
const user = userEvent.setup();
const finish = vi.fn();
render(
<OnboardingFlow
state={onboarding}
imports={imports({ sources: [] })}
onFinish={finish}
/>,
);
await openApps();
await user.click(screen.getByRole("button", { name: SKIP }));
await user.click(screen.getByRole("radio", { name: /pro/i }));
await user.click(screen.getByRole("button", { name: "继续配置模型" }));
await user.click(
screen.getByRole("button", { name: "模型启用回执已确认" }),
);
await waitFor(() => expect(setMemoryMode).toHaveBeenCalledTimes(1));
await user.click(
screen.getByRole("button", { name: "第二个模型启用回执已确认" }),
);
expect(setMemoryMode).toHaveBeenCalledTimes(1);
await act(async () => resolveFirst({ mode: "pro" }));
await waitFor(() => expect(setMemoryMode).toHaveBeenCalledTimes(2));
expect(finish).not.toHaveBeenCalled();
await act(async () => resolveSecond({ mode: "pro" }));
await waitFor(() =>
expect(finish).toHaveBeenCalledExactlyOnceWith("sources"),
);
expect(completeOnboarding).toHaveBeenCalledOnce();
});
it("drops an in-flight Pro response after returning to mode without confirming or completing", async () => {
let resolvePro!: (value: { mode: "pro" }) => void;
setMemoryMode.mockImplementationOnce(
+97 -70
View File
@@ -112,9 +112,8 @@ export function OnboardingFlow({
const [locationCheck, setLocationCheck] = useState<LocationCheck>("checking");
const [selected, setSelected] = useState<SourceResponse[]>([]);
const [custom, setCustom] = useState<SourceResponse | null>(null);
const [busy, setBusy] = useState<
"checking" | "submitting" | "leaving" | null
>(null);
const [busy, setBusy] = useState<"checking" | "submitting" | null>(null);
const [completionBusy, setCompletionBusy] = useState(false);
const [error, setError] = useState<string | null>(null);
const [itemErrors, setItemErrors] = useState<Record<string, string>>({});
const [exitDestination, setExitDestination] =
@@ -138,7 +137,10 @@ export function OnboardingFlow({
);
const [modeDestination, setModeDestination] =
useState<OnboardingDestination>("sources");
const [modeSaving, setModeSaving] = useState(false);
const [modePhase, setModePhase] = useState<"idle" | "saving" | "completing">(
"idle",
);
const modeSaving = modePhase !== "idle";
const [modeError, setModeError] = useState<string | null>(null);
const [modeFocusToken, setModeFocusToken] = useState(
resumedStep === "mode" ? 1 : 0,
@@ -181,7 +183,10 @@ export function OnboardingFlow({
)
.join("、"),
});
const flight = useRef(false);
const sourceFlight = useRef(false);
const modeOperation = useRef(0);
const proConfirmationOwner = useRef<string | null>(null);
const completionFlight = useRef(false);
const connectionAttempt = useRef<string | null>(null);
const connectionQueue = useRef(Promise.resolve());
const connectionQueueDepth = useRef(0);
@@ -368,7 +373,10 @@ export function OnboardingFlow({
selected.some((chosen) => chosen.source_id === item.source_id),
);
const locked =
busy !== null || exitDestination !== null || state.status === "importing";
busy !== null ||
completionBusy ||
exitDestination !== null ||
state.status === "importing";
useEffect(() => {
alive.current = true;
@@ -456,7 +464,7 @@ export function OnboardingFlow({
locationCheck !== "ready" ||
locked ||
reviewingImport ||
flight.current
sourceFlight.current
)
return;
if (!scopeMatches(selected, sources)) return;
@@ -493,7 +501,11 @@ export function OnboardingFlow({
}, []);
const leaveModelStep = useCallback(
(nextIntent: OnboardingMemoryMode, nextStep: "review" | "mode") => {
(
nextIntent: OnboardingMemoryMode,
nextStep: "review" | "mode",
resetModePhase = true,
) => {
// This fence is synchronous: hidden model work must observe invalidation
// before React renders the replacement step.
modelGeneration.current += 1;
@@ -502,7 +514,10 @@ export function OnboardingFlow({
setModeChoice(nextIntent);
setSetupStep(nextStep);
if (nextStep === "mode") setModeFocusToken((value) => value + 1);
setModeSaving(false);
if (resetModePhase) {
modeOperation.current += 1;
setModePhase("idle");
}
},
[],
);
@@ -611,12 +626,15 @@ export function OnboardingFlow({
const leave = useCallback(
async (destination: OnboardingDestination) => {
if (flight.current || finished.current) return;
flight.current = true;
setBusy("leaving");
await persistFinish(destination);
flight.current = false;
if (alive.current) setBusy(null);
if (completionFlight.current || finished.current) return;
completionFlight.current = true;
setCompletionBusy(true);
try {
await persistFinish(destination);
} finally {
completionFlight.current = false;
if (alive.current) setCompletionBusy(false);
}
},
[persistFinish],
);
@@ -640,14 +658,11 @@ export function OnboardingFlow({
);
const saveModeChoice = useCallback(async () => {
if (
!modeChoice ||
modeSaving ||
finished.current ||
(modeChoice === "lite" && flight.current)
)
return;
setModeSaving(true);
if (!modeChoice || modeSaving || finished.current || completionBusy) return;
const operation = ++modeOperation.current;
const ownsModeOperation = () =>
alive.current && !finished.current && modeOperation.current === operation;
setModePhase("saving");
setModeError(null);
const modeTicket = issueConfirmedModeTicket("onboarding");
try {
@@ -655,8 +670,7 @@ export function OnboardingFlow({
intentRef.current = "pro";
setupStepRef.current = "mode";
const selectionStillCurrent = () =>
alive.current &&
!finished.current &&
ownsModeOperation() &&
intentRef.current === "pro" &&
setupStepRef.current === "mode";
const savedProgress = await queueProgressWrite(
@@ -673,10 +687,9 @@ export function OnboardingFlow({
enterModelStep();
return;
}
leaveModelStep("lite", "mode");
leaveModelStep("lite", "mode", false);
const liteStillCurrent = () =>
alive.current &&
!finished.current &&
ownsModeOperation() &&
intentRef.current === "lite" &&
setupStepRef.current === "mode";
await queueProgressWrite(
@@ -688,19 +701,20 @@ export function OnboardingFlow({
}),
liteStillCurrent,
);
if (!alive.current) return;
const changed = await queueModeWrite("lite");
if (!alive.current) return;
if (!liteStillCurrent()) return;
const changed = await queueModeWrite("lite", liteStillCurrent);
if (!liteStillCurrent()) return;
commitConfirmedMemoryMode(changed.mode, modeTicket);
setModePhase("completing");
await leave(modeDestination);
} catch (reason) {
if (isCompletedProgressConflict(reason)) {
finishOnce(modeDestination);
} else if (alive.current) {
} else if (ownsModeOperation() && !isGenerationInvalid(reason)) {
setModeError("模式未能保存。你的选择已保留,请重试。");
}
} finally {
if (alive.current) setModeSaving(false);
if (ownsModeOperation()) setModePhase("idle");
}
}, [
enterModelStep,
@@ -709,6 +723,7 @@ export function OnboardingFlow({
modeChoice,
modeDestination,
modeSaving,
completionBusy,
finishOnce,
queueModeWrite,
queueProgressWrite,
@@ -731,6 +746,7 @@ export function OnboardingFlow({
const confirmProMode = useCallback(
async (token: number, ownerProfileId: string) => {
const confirmationOwner = `${token}:${ownerProfileId}`;
const ownsModelStep = () =>
alive.current &&
!finished.current &&
@@ -738,16 +754,25 @@ export function OnboardingFlow({
intentRef.current === "pro" &&
setupStepRef.current === "model" &&
modelProfileId.current === ownerProfileId;
if (!ownsModelStep()) return;
setModeSaving(true);
if (
!ownsModelStep() ||
proConfirmationOwner.current === confirmationOwner ||
completionBusy
)
return;
proConfirmationOwner.current = confirmationOwner;
const operation = ++modeOperation.current;
const ownsConfirmation = () =>
ownsModelStep() && modeOperation.current === operation;
setModePhase("saving");
setModeError(null);
const modeTicket = issueConfirmedModeTicket("onboarding");
try {
const plan = await planMemoryMode("pro");
if (!ownsModelStep()) return;
if (!ownsConfirmation()) return;
if (plan.blockers.length > 0) throw new Error(plan.blockers.join(","));
const changed = await queueModeWrite("pro", ownsModelStep);
if (!ownsModelStep()) return;
const changed = await queueModeWrite("pro", ownsConfirmation);
if (!ownsConfirmation()) return;
commitConfirmedMemoryMode(changed.mode, modeTicket);
await queueProgressWrite(
() =>
@@ -756,31 +781,35 @@ export function OnboardingFlow({
setup_step: "model",
model_profile_id: ownerProfileId,
}),
ownsModelStep,
ownsConfirmation,
);
if (!ownsModelStep()) return;
await persistFinish(modeDestination);
if (!ownsConfirmation()) return;
setModePhase("completing");
await leave(modeDestination);
} catch (reason) {
if (isCompletedProgressConflict(reason)) {
finishOnce(modeDestination);
} else if (alive.current && !isGenerationInvalid(reason))
} else if (ownsConfirmation() && !isGenerationInvalid(reason))
setModeError("Pro 模式未能确认保存。模型配置已保留,请重试。");
} finally {
if (alive.current) setModeSaving(false);
if (proConfirmationOwner.current === confirmationOwner)
proConfirmationOwner.current = null;
if (ownsConfirmation()) setModePhase("idle");
}
},
[
completionBusy,
finishOnce,
leave,
modeDestination,
persistFinish,
queueModeWrite,
queueProgressWrite,
],
);
async function checkLocations() {
if (flight.current || locked) return;
flight.current = true;
if (sourceFlight.current || locked) return;
sourceFlight.current = true;
scopeVersion.current += 1;
setBusy("checking");
setLocationCheck("checking");
@@ -805,7 +834,7 @@ export function OnboardingFlow({
setLocationCheck("failed");
setError("位置检测未完成,尚未读取历史。可以重新检测,或先进入工作台。");
} finally {
flight.current = false;
sourceFlight.current = false;
if (alive.current) setBusy(null);
}
}
@@ -813,7 +842,7 @@ export function OnboardingFlow({
function toggleSource(source: SourceResponse, checked: boolean) {
if (
flight.current ||
sourceFlight.current ||
locked ||
locationCheck !== "ready" ||
!isAppSource(source) ||
@@ -836,7 +865,7 @@ export function OnboardingFlow({
}
function selectAllSources() {
if (flight.current || locked || locationCheck !== "ready") return;
if (sourceFlight.current || locked || locationCheck !== "ready") return;
scopeVersion.current += 1;
setError(null);
setItemErrors({});
@@ -849,7 +878,7 @@ export function OnboardingFlow({
async function startImport() {
if (
flight.current ||
sourceFlight.current ||
locked ||
locationCheck !== "ready" ||
!selected.length
@@ -863,7 +892,7 @@ export function OnboardingFlow({
}
async function submitImport(approved: SourceResponse[]) {
flight.current = true;
sourceFlight.current = true;
if (window.oneMemory) setStartingImport(approved);
const version = scopeVersion.current;
const retryFailed = Object.keys(itemErrors).length > 0;
@@ -917,7 +946,7 @@ export function OnboardingFlow({
}
} finally {
connectSent.current = false;
flight.current = false;
sourceFlight.current = false;
if (alive.current) setBusy(null);
}
}
@@ -987,10 +1016,10 @@ export function OnboardingFlow({
jobs={reviewJobs}
failedSources={accepted?.failed_sources}
sourceSnapshot={imports.sourceSnapshot}
busy={busy === "leaving"}
busy={completionBusy}
error={error && text(error)}
onContinue={(destination) => {
if (busy === "leaving") return;
if (completionBusy) return;
if (completionFailed) {
void leave(destination);
return;
@@ -1031,7 +1060,7 @@ export function OnboardingFlow({
<>
<R6Button
variant="quiet"
disabled={modeSaving || busy === "leaving"}
disabled={modeSaving || completionBusy}
onClick={() => {
setModeError(null);
intentRef.current = modeChoice;
@@ -1056,21 +1085,19 @@ export function OnboardingFlow({
className="onboarding-r6-submit"
variant="primary"
pending={modeSaving}
disabled={
!modeChoice ||
busy === "leaving" ||
(modeChoice === "lite" && flight.current)
}
disabled={!modeChoice || completionBusy}
onClick={() => void saveModeChoice()}
>
{text(
modeSaving
? "正在保存…"
: modeChoice === "lite"
? "使用 Lite 进入"
: modeChoice === "pro"
? "继续配置模型"
: "继续",
modePhase === "saving"
? "正在保存模式…"
: modePhase === "completing"
? "正在完成首次设置…"
: modeChoice === "lite"
? "使用 Lite 进入"
: modeChoice === "pro"
? "继续配置模型"
: "继续",
)}
</R6Button>
</>
@@ -1104,7 +1131,7 @@ export function OnboardingFlow({
},
]}
selected={modeChoice}
disabled={modeSaving || busy === "leaving"}
disabled={modeSaving || completionBusy}
recommended={{ mode: "pro", badge: text("推荐") }}
onChange={(mode) => {
modeChoiceTouched.current = true;
@@ -1188,7 +1215,7 @@ export function OnboardingFlow({
<>
<R6Button
variant="quiet"
disabled={busy !== null}
disabled={busy !== null || completionBusy}
onClick={() => finishOnce(exitDestination)}
>
{text(
@@ -1197,7 +1224,7 @@ export function OnboardingFlow({
</R6Button>
<R6Button
variant="primary"
disabled={busy !== null}
disabled={busy !== null || completionBusy}
onClick={() => void leave(exitDestination)}
>
{text("重试保存并进入")}
+1
View File
@@ -169,6 +169,7 @@ it("shows the help and diagnostics group by default and hides it when opted out"
await user.click(screen.getByRole("button", { name: "帮助与诊断" }));
expect(screen.getByRole("heading", { name: "帮助与诊断" })).toBeTruthy();
expect(screen.getByRole("heading", { name: "导出日志" })).toBeTruthy();
expect(screen.getByText(/可能包含会话正文和本机路径/)).toBeTruthy();
expect(screen.getByRole("heading", { name: "关于" })).toBeTruthy();
expect(screen.getByText("1.0.4")).toBeTruthy();
expect(screen.getByText("8")).toBeTruthy();
+265
View File
@@ -0,0 +1,265 @@
import { Check, ChevronDown, Clock3 } from "lucide-react";
import {
useCallback,
useEffect,
useLayoutEffect,
useRef,
useState,
} from "react";
import { useOnboardingTranslation } from "./onboarding-language";
import { R6Button } from "./R6Controls";
const HOURS = Array.from({ length: 24 }, (_, value) =>
value.toString().padStart(2, "0"),
);
const MINUTES = Array.from({ length: 60 }, (_, value) =>
value.toString().padStart(2, "0"),
);
function splitTime(value: string): [string, string] {
const match = /^([01]\d|2[0-3]):([0-5]\d)$/.exec(value);
return match ? [match[1], match[2]] : ["00", "00"];
}
export function TimePicker({
id,
label,
value,
disabled = false,
onChange,
}: {
id: string;
label: string;
value: string;
disabled?: boolean;
onChange(value: string): Promise<boolean>;
}) {
const { locale, text } = useOnboardingTranslation();
const [open, setOpen] = useState(false);
const [saving, setSaving] = useState(false);
const [placement, setPlacement] = useState<"above" | "below">("above");
const [hour, setHour] = useState(() => splitTime(value)[0]);
const [minute, setMinute] = useState(() => splitTime(value)[1]);
const root = useRef<HTMLDivElement>(null);
const trigger = useRef<HTMLButtonElement>(null);
const panel = useRef<HTMLElement>(null);
const hourSelect = useRef<HTMLSelectElement>(null);
const minuteSelect = useRef<HTMLSelectElement>(null);
const restoreTriggerFocus = useRef(false);
const restoreSaveFocus = useRef(false);
const savingRef = useRef(false);
savingRef.current = saving;
const panelId = `${id}-picker`;
const close = useCallback((restoreFocus = true) => {
setOpen(false);
restoreTriggerFocus.current = restoreFocus;
}, []);
function show() {
const [nextHour, nextMinute] = splitTime(value);
setHour(nextHour);
setMinute(nextMinute);
setOpen(true);
}
useEffect(() => {
if (!open) return;
const closeOutside = (event: MouseEvent) => {
if (savingRef.current) return;
const target = event.target as Element | null;
const label = target?.closest("label");
if (label instanceof HTMLLabelElement && label.htmlFor === id) return;
if (!root.current?.contains(target)) close(false);
};
const closeOnEscape = (event: KeyboardEvent) => {
if (event.key !== "Escape" || savingRef.current) return;
event.preventDefault();
close();
};
document.addEventListener("mousedown", closeOutside);
document.addEventListener("keydown", closeOnEscape);
hourSelect.current?.focus();
hourSelect.current?.selectedOptions[0]?.scrollIntoView?.({
block: "nearest",
});
minuteSelect.current?.selectedOptions[0]?.scrollIntoView?.({
block: "nearest",
});
return () => {
document.removeEventListener("mousedown", closeOutside);
document.removeEventListener("keydown", closeOnEscape);
};
}, [close, id, open]);
useEffect(() => {
if (disabled && open && !saving) close(false);
if (!disabled && !open && restoreTriggerFocus.current) {
restoreTriggerFocus.current = false;
trigger.current?.focus();
}
if (!disabled && open && restoreSaveFocus.current) {
restoreSaveFocus.current = false;
panel.current
?.querySelector<HTMLButtonElement>("[data-time-picker-save]")
?.focus();
}
if (saving && open) panel.current?.focus();
}, [close, disabled, open, saving]);
useLayoutEffect(() => {
if (!open) return;
const place = () => {
const anchor = trigger.current?.getBoundingClientRect();
const picker = panel.current?.getBoundingClientRect();
if (!anchor || !picker) return;
const scroller = trigger.current?.closest(
".app-content, .onboarding-frame",
);
const bounds = scroller?.getBoundingClientRect();
const topEdge = (bounds ? Math.max(0, bounds.top) : 0) + 12;
const bottomEdge =
(bounds
? Math.min(window.innerHeight, bounds.bottom)
: window.innerHeight) - 12;
const above = anchor.top - topEdge;
const below = bottomEdge - anchor.bottom;
setPlacement(
above >= picker.height || above >= below ? "above" : "below",
);
};
place();
window.addEventListener("resize", place);
window.addEventListener("scroll", place, true);
return () => {
window.removeEventListener("resize", place);
window.removeEventListener("scroll", place, true);
};
}, [open]);
async function save() {
const next = `${hour}:${minute}`;
if (next === value) {
close();
return;
}
setSaving(true);
const saved = await onChange(next);
setSaving(false);
if (saved) close();
else restoreSaveFocus.current = true;
}
return (
<div className="r6-time-picker" ref={root}>
<button
ref={trigger}
id={id}
type="button"
className="r6-time-picker-trigger"
aria-label={
locale.startsWith("zh") ? `${label},${value}` : `${label}: ${value}`
}
aria-haspopup="dialog"
aria-expanded={open}
aria-controls={panelId}
disabled={disabled}
onClick={() => (open ? close() : show())}
>
<span>{value}</span>
<span className="r6-time-picker-icons" aria-hidden="true">
<Clock3 size={15} />
<ChevronDown size={14} />
</span>
</button>
{open ? (
<section
ref={panel}
id={panelId}
className="r6-time-picker-panel"
role="dialog"
aria-modal="true"
aria-label={label}
tabIndex={-1}
data-placement={placement}
onKeyDown={(event) => {
if (event.key !== "Tab") return;
const controls = Array.from(
event.currentTarget.querySelectorAll<HTMLElement>(
"select:not(:disabled), button:not(:disabled)",
),
);
const first = controls[0];
const last = controls.at(-1);
if (!first || !last) {
event.preventDefault();
event.currentTarget.focus();
return;
}
if (event.shiftKey && document.activeElement === first) {
event.preventDefault();
last.focus();
} else if (!event.shiftKey && document.activeElement === last) {
event.preventDefault();
first.focus();
}
}}
>
<div className="r6-time-picker-columns">
<label className="r6-time-picker-column">
<span aria-hidden="true">HH</span>
<select
ref={hourSelect}
size={6}
aria-label={`${label} HH`}
value={hour}
disabled={saving}
onChange={(event) => setHour(event.target.value)}
>
{HOURS.map((option) => (
<option key={option} value={option}>
{option}
</option>
))}
</select>
</label>
<span className="r6-time-picker-separator" aria-hidden="true">
:
</span>
<label className="r6-time-picker-column">
<span aria-hidden="true">MM</span>
<select
ref={minuteSelect}
size={6}
aria-label={`${label} MM`}
value={minute}
disabled={saving}
onChange={(event) => setMinute(event.target.value)}
>
{MINUTES.map((option) => (
<option key={option} value={option}>
{option}
</option>
))}
</select>
</label>
</div>
<footer>
<R6Button disabled={saving} onClick={() => close()}>
{text("取消")}
</R6Button>
<R6Button
variant="primary"
pending={saving}
data-time-picker-save
onClick={() => void save()}
>
<Check size={15} aria-hidden="true" />
{text("保存修改")}
</R6Button>
</footer>
</section>
) : null}
</div>
);
}
+2
View File
@@ -33,6 +33,8 @@ it("lists raw conversations for the selected application", async () => {
offset: 20,
limit: 20,
unit: "turn",
paging: undefined,
cursor: undefined,
},
signal,
});
+13 -1
View File
@@ -987,6 +987,8 @@ export async function getSdkRaw({
offset = 0,
limit = 100,
unit = "turn",
paging,
cursor,
signal,
}: {
source?: string;
@@ -994,10 +996,20 @@ export async function getSdkRaw({
offset?: number;
limit?: number;
unit?: "raw" | "turn";
paging?: "offset" | "cursor";
cursor?: string | null;
signal?: AbortSignal;
} = {}): Promise<SdkRawPage> {
const response = await client.get<SdkRawPage>("/api/v2/raw", {
params: { source, session_id: sessionId, offset, limit, unit },
params: {
source,
session_id: sessionId,
offset,
limit,
unit,
paging,
cursor: cursor || undefined,
},
signal,
});
return response.data;
@@ -42,6 +42,8 @@
margin-bottom: 24px;
}
.r6-home-overview {
display: flex;
flex-direction: column;
min-width: 0;
min-height: 346px;
padding: 26px;
@@ -150,6 +152,7 @@
padding: 8px 0;
}
.r6-home-activity {
margin-top: auto;
border-top: 1px solid #d5dfe8;
padding-top: 12px;
}
+137 -6
View File
@@ -295,7 +295,7 @@
padding-top: 16px;
border-top: 1px solid #e1e8ed;
}
.r6-settings .r6-evolve-time label {
.r6-settings .r6-evolve-time > div > label {
color: #304757;
font-size: 13px;
font-weight: 600;
@@ -304,20 +304,146 @@
.r6-evolve-time p {
margin-top: 1px;
}
.r6-evolve-settings input[type="time"] {
.r6-time-picker {
position: relative;
width: 180px;
}
.r6-time-picker-trigger {
box-sizing: border-box;
width: 180px;
min-height: 44px;
padding: 0 10px;
height: 44px;
display: flex;
align-items: center;
justify-content: space-between;
gap: 12px;
padding: 0 12px 0 14px;
border: 1px solid #c6d2da;
border-radius: 8px;
color: #304757;
background: #fff;
font:
500 14px / 22px "Noto Sans SC",
"PingFang SC",
sans-serif;
font-variant-numeric: tabular-nums;
cursor: pointer;
transition:
border-color 140ms ease-out,
background-color 140ms ease-out,
box-shadow 140ms ease-out;
}
.r6-evolve-settings input[type="time"]:focus-visible {
.r6-time-picker-trigger:hover:not(:disabled) {
border-color: #9eafbb;
background: #f8fafb;
}
.r6-time-picker-trigger[aria-expanded="true"] {
border-color: #7892a6;
box-shadow: 0 0 0 3px rgb(85 124 147 / 12%);
}
.r6-time-picker-trigger:focus-visible {
outline: 2px solid var(--r6-focus);
outline-offset: 2px;
}
.r6-time-picker-trigger:disabled {
color: #71808b;
background: #edf0f3;
cursor: default;
}
.r6-time-picker-trigger:active:not(:disabled) {
transform: none;
}
.r6-time-picker-icons {
display: inline-flex;
align-items: center;
gap: 5px;
color: #607888;
}
.r6-time-picker-trigger[aria-expanded="true"]
.r6-time-picker-icons
svg:last-child {
transform: rotate(180deg);
}
.r6-time-picker-panel {
box-sizing: border-box;
position: absolute;
z-index: 1200;
right: 0;
bottom: calc(100% + 8px);
width: min(280px, calc(100vw - 32px));
padding: 14px;
border: 1px solid #c6d2da;
border-radius: 10px;
background: #fff;
box-shadow: 0 14px 36px rgb(48 71 87 / 16%);
}
.r6-time-picker-panel[data-placement="below"] {
top: calc(100% + 8px);
bottom: auto;
}
.r6-time-picker-columns {
display: grid;
grid-template-columns: minmax(0, 1fr) auto minmax(0, 1fr);
align-items: end;
gap: 8px;
}
.r6-time-picker-column {
display: grid;
gap: 6px;
color: #5e6e7a;
font-size: 11px;
font-weight: 600;
line-height: 18px;
text-align: center;
}
.r6-time-picker-columns select {
box-sizing: border-box;
width: 100%;
height: 174px;
padding: 4px;
border: 1px solid #d5dfe8;
border-radius: 7px;
color: #304757;
background: #f8fafb;
font:
500 14px / 24px "Noto Sans SC",
"PingFang SC",
sans-serif;
font-variant-numeric: tabular-nums;
scrollbar-width: thin;
scrollbar-color: #b6c3cc transparent;
}
.r6-time-picker-columns select:focus-visible {
outline: 2px solid var(--r6-focus);
outline-offset: 1px;
}
.r6-time-picker-columns option {
min-height: 28px;
padding: 5px 8px;
border-radius: 5px;
}
.r6-time-picker-columns option:checked {
color: #304757;
background: #dfe9ef;
}
.r6-time-picker-separator {
align-self: center;
margin-top: 22px;
color: #7892a6;
font-size: 18px;
font-weight: 600;
}
.r6-time-picker-panel footer {
display: flex;
justify-content: flex-end;
gap: 8px;
margin-top: 12px;
padding-top: 12px;
border-top: 1px solid #e1e8ed;
flex-wrap: wrap;
}
.r6-time-picker-panel footer .r6-button {
min-width: 68px;
}
.r6-state-recovery {
display: flex;
align-items: center;
@@ -1059,9 +1185,14 @@
.r6-evolve-time {
gap: 8px;
}
.r6-evolve-settings input[type="time"] {
.r6-time-picker,
.r6-time-picker-trigger {
width: min(220px, 100%);
}
.r6-time-picker-panel {
right: auto;
left: 0;
}
.r6-state-recovery {
align-items: flex-start;
flex-direction: column;
+11 -1
View File
@@ -72,6 +72,11 @@ function pageFor(
total: number,
offset: number,
limit: number,
cursor?: {
next_cursor?: string | null;
has_more?: boolean;
total_exact?: boolean;
},
): MemoryLibraryPage {
const sourceIds: Record<string, string[]> = {};
const sourceTime: Record<
@@ -97,6 +102,7 @@ function pageFor(
total,
offset,
limit,
...cursor,
source_ids_by_memory: sourceIds,
source_time_by_memory: sourceTime,
search_window: null,
@@ -107,7 +113,11 @@ function pageFor(
}
export function rawLibraryPage(page: SdkRawPage): MemoryLibraryPage {
return pageFor(page.items, page.total, page.offset, page.limit);
return pageFor(page.items, page.total, page.offset, page.limit, {
next_cursor: page.next_cursor,
has_more: page.has_more,
total_exact: page.total_exact,
});
}
export function rawSearchPage(items: SdkRawContextItem[]): MemoryLibraryPage {
+6 -5
View File
@@ -10,9 +10,10 @@
新增受限标量标签,仍应按私人数据处理,不能直接对外发送。`debug` 会让 HY 的
`hy_memory.log` 记录 DEBUG 行,其中可能包含完整 prompt/response 和获授权的对话正文。
排查时只通过 App 的“导出日志”提供文件:导出过程会按安全白名单重新生成诊断事件,不会打包
原始日志。不要直接发送 `diagnostic-*.jsonl`、整个 `~/.one-memory` 数据目录或 debug 级别的
`hy_memory.log`;后者如确需分享,必须由开发人员另行确认并脱敏。
App 的“导出日志”会同时打包安全投影和 `~/.one-memory/logs/` 下的完整运行日志,供可信开发人员
还原本机故障。原始日志可能包含会话正文和本机路径,发送前必须确认接收方;不要公开分享,也不要
发送整个 `~/.one-memory` 数据目录。SDK 的 debug 级 `hy_memory.log` 不在该 ZIP 中;如确需分享,
必须由开发人员另行确认并脱敏。只需要脱敏事件时使用 `memhub logs export`。
## 1. Log level 设什么
@@ -41,8 +42,8 @@ debug 可能把完整模型 prompt/response 写入本机调试日志,不得长
## 2. 日志在哪里
App 内推荐直接使用“运行诊断 → 导出日志”。开发人员远程指导时,优先使用
`memhub logs`,它会再次过滤不安全字段。原始结构化日志只用于本机现场查看,不得作为附件发送。
App 内推荐直接使用“运行诊断 → 导出日志”,它会生成包含完整运行日志的 ZIP。开发人员远程指导
且不需要原始日志时,优先使用 `memhub logs`,它会过滤不安全字段。不要手工打包整个数据目录。
Core 日志默认位于:
```text
+10 -5
View File
@@ -45,13 +45,18 @@ host-bridge、desktop 和 cli。单文件 4 MiB,保留 3 份轮转;额外清
读取器只枚举上述固定文件名的 regular file,不跟随符号链接,不接受任意路径。日常本地查询
遇到损坏行时跳过并计数,不让一行损坏遮蔽其余证据。
导出是独立的安全边界:它重新读取并校验全部候选事件,只把明确列入导出白名单的字段投影为
CLI 导出是独立的安全边界:它重新读取并校验全部候选事件,只把明确列入导出白名单的字段投影为
新的 `diagnostics/events.jsonl`;details 不只检查 key,还按字段约束 value 类型,并拒绝自由文本、
路径和凭证形态的字符串。绝不复制原始 `diagnostic-*.jsonl`、普通运行日志或崩溃报告。
导出包还可以包含固定 schema 的系统摘要和问题时间点,但不接受调用方传入的任意对象展开。
路径和凭证形态的字符串。CLI 不复制原始日志或崩溃报告。
Desktop 设置页的“导出日志”用于开发人员排查本机运行故障。除上述安全投影和状态摘要外,它还会
把 One Memory Home 的 `logs/` 目录中全部普通文件及轮转文件放入 ZIP 的 `logs/` 目录。它不跟随
符号链接、不递归读取其他目录,也不包含数据库、SDK 数据目录或系统崩溃报告。原始日志可能包含
会话正文、本机路径或其他私人信息,ZIP 的 README 必须明确提示只发送给可信的开发人员。
任何事件、marker、安全投影或日志目录读取失败时,导出必须 fail-closed:删除临时文件、不发布
诊断包,更不能回退为打包原始日志。发布使用已设好权限的临时文件原子创建最终目标,已存在的
目标不得覆盖,发布后也不存在再 chmod 失败留下伪成功文件的窗口。
诊断包。发布使用已设好权限的临时文件原子创建最终目标,已存在的目标不得覆盖,发布后也不存在
再 chmod 失败留下伪成功文件的窗口。
## 覆盖与节流
+7
View File
@@ -510,6 +510,10 @@ export interface MemoryLibraryPage {
total: number;
offset: number;
limit: number;
/** Cursor paging: opaque continuation for a page whose exact total is not read. */
next_cursor?: string | null;
has_more?: boolean;
total_exact?: boolean;
/** Active evidence sources for each returned memory, from the same authorized page snapshot. */
source_ids_by_memory?: Record<string, string[]>;
/** When each returned memory's cited conversation happened (D96). `import`
@@ -709,6 +713,9 @@ export interface SdkRawPage {
limit: number;
offset: number;
unit?: "raw" | "turn";
next_cursor?: string | null;
has_more?: boolean;
total_exact?: boolean;
/** A time range was applied to this page in Core (the SDK list has none). */
time_filtered?: boolean;
}
+1 -1
View File
@@ -877,7 +877,7 @@ const baseEnglishMessages = {
"Keep syncing, processing and providing memories without opening the app.",
导出日志: "Export logs",
"把本机运行日志打包成一个文件,发给开发排查。":
"Package local diagnostic logs into a file to share with support.",
"Package all local logs. They may contain conversation text or local paths; share only with trusted developers.",
"确定退出 One Memory 界面吗?": "Quit the MemHub window?",
"后台服务会继续同步、整理和提供记忆。":
"The background service will continue syncing, processing and providing memories.",
@@ -251,7 +251,7 @@ export const settingsSpanishMessages = {
"Sincroniza, organiza y ofrece memorias sin abrir la interfaz.",
导出日志: "Exportar registros",
"把本机运行日志打包成一个文件,发给开发排查。":
"Empaqueta los registros locales en un archivo para su diagnóstico.",
"Empaqueta todos los registros locales. Pueden contener conversaciones o rutas privadas; compártelos solo con personal de desarrollo de confianza.",
"确定退出 One Memory 界面吗?": "¿Cerrar la interfaz de MemHub?",
"后台服务会继续同步、整理和提供记忆。":
"El servicio en segundo plano seguirá sincronizando, organizando y ofreciendo memorias.",
@@ -362,6 +362,8 @@ export const settingsSpanishMessages = {
const chineseOverrides: Partial<
Record<keyof typeof settingsSpanishMessages, string>
> = {
"把本机运行日志打包成一个文件,发给开发排查。":
"打包完整本机日志,可能包含会话正文和本机路径;只发送给可信的开发人员。",
"确定退出 One Memory 界面吗?": "确定退出 MemHub 界面吗?",
appSettingsTitle: "{name} 应用设置",
appMemoryScope: "让 {name} 查询已授权范围内的记忆。",
@@ -1065,7 +1065,7 @@ export const germanMessages: Record<MessageKey, string> = {
"Erinnerungen weiter synchronisieren, verarbeiten und bereitstellen, ohne die App zu öffnen.",
导出日志: "Protokolle exportieren",
"把本机运行日志打包成一个文件,发给开发排查。":
"Packen Sie lokale Diagnoseprotokolle in eine Datei, um sie an den Support zu senden.",
"Packt alle lokalen Protokolle. Sie können Gesprächsinhalte oder lokale Pfade enthalten; nur mit vertrauenswürdigen Entwicklern teilen.",
"确定退出 One Memory 界面吗?": "MemHub-Fenster beenden?",
"后台服务会继续同步、整理和提供记忆。":
"Der Hintergrunddienst synchronisiert, verarbeitet und liefert Erinnerungen weiter.",
@@ -1078,7 +1078,7 @@ export const frenchMessages: Record<MessageKey, string> = {
"Continuez à synchroniser, traiter et fournir les mémoires sans ouvrir l’application.",
导出日志: "Exporter les journaux",
"把本机运行日志打包成一个文件,发给开发排查。":
"Regroupez les journaux de diagnostic locaux dans un fichier à transmettre à l’assistance.",
"Regroupe tous les journaux locaux. Ils peuvent contenir des conversations ou des chemins locaux ; partagez-les uniquement avec des développeurs de confiance.",
"确定退出 One Memory 界面吗?": "Quitter la fenêtre MemHub ?",
"后台服务会继续同步、整理和提供记忆。":
"Le service en arrière-plan continuera de synchroniser, traiter et fournir les mémoires.",
@@ -1048,7 +1048,7 @@ export const indonesianMessages: Record<MessageKey, string> = {
"Terus sinkronkan, proses, dan sediakan memori tanpa membuka aplikasi.",
导出日志: "Ekspor log",
"把本机运行日志打包成一个文件,发给开发排查。":
"Kemas log diagnostik lokal menjadi satu file untuk dibagikan ke tim dukungan.",
"Kemas semua log lokal. Log dapat memuat isi percakapan atau jalur lokal; bagikan hanya kepada pengembang tepercaya.",
"确定退出 One Memory 界面吗?": "Tutup jendela MemHub?",
"后台服务会继续同步、整理和提供记忆。":
"Layanan latar belakang akan terus menyinkronkan, memproses, dan menyediakan memori.",
@@ -1045,7 +1045,7 @@ export const japaneseMessages: Record<MessageKey, string> = {
"アプリを開かなくても、記憶の同期、処理、提供を続けます。",
导出日志: "ログを書き出す",
"把本机运行日志打包成一个文件,发给开发排查。":
"ローカルの診断ログを 1 つのファイルにまとめ、サポートに共有できます。",
"ローカルログをすべてまとめます。会話本文やローカルパスが含まれる場合があるため、信頼できる開発者にのみ共有してください。",
"确定退出 One Memory 界面吗?": "MemHub のウインドウを終了しますか?",
"后台服务会继续同步、整理和提供记忆。":
"バックグラウンドサービスは記憶の同期、処理、提供を続けます。",
@@ -1021,7 +1021,7 @@ export const koreanMessages: Record<MessageKey, string> = {
"앱을 열지 않아도 기억을 계속 동기화, 정리하고 제공합니다.",
导出日志: "로그 내보내기",
"把本机运行日志打包成一个文件,发给开发排查。":
"로컬 진단 로그를 파일 하나로 묶어 지원팀에 보냅니다.",
"모든 로컬 로그를 묶습니다. 대화 내용이나 로컬 경로가 포함될 수 있으므로 신뢰할 수 있는 개발자에게만 공유하세요.",
"确定退出 One Memory 界面吗?": "MemHub 창을 종료하시겠습니까?",
"后台服务会继续同步、整理和提供记忆。":
"백그라운드 서비스는 계속 기억을 동기화, 정리하고 제공합니다.",
@@ -1069,7 +1069,7 @@ export const portugueseMessages: Record<MessageKey, string> = {
"Continue sincronizando, processando e fornecendo memórias sem abrir o app.",
导出日志: "Exportar logs",
"把本机运行日志打包成一个文件,发给开发排查。":
"Empacote os logs de diagnóstico locais em um arquivo para compartilhar com o suporte.",
"Empacota todos os logs locais. Eles podem conter conversas ou caminhos locais; compartilhe apenas com desenvolvedores de confiança.",
"确定退出 One Memory 界面吗?": "Fechar a janela do MemHub?",
"后台服务会继续同步、整理和提供记忆。":
"O serviço em segundo plano continuará sincronizando, processando e fornecendo memórias.",
@@ -1053,7 +1053,7 @@ export const russianMessages: Record<MessageKey, string> = {
"Синхронизация, обработка и предоставление памяти без открытия приложения.",
导出日志: "Экспорт журналов",
"把本机运行日志打包成一个文件,发给开发排查。":
"Упакуйте локальные журналы диагностики в файл, чтобы отправить в поддержку.",
"Упаковывает все локальные журналы. Они могут содержать текст диалогов или локальные пути; отправляйте их только доверенным разработчикам.",
"确定退出 One Memory 界面吗?": "Закрыть окно MemHub?",
"后台服务会继续同步、整理和提供记忆。":
"Фоновая служба продолжит синхронизацию, обработку и предоставление памяти.",
@@ -951,7 +951,7 @@ export const traditionalChineseMessages: Record<MessageKey, string> = {
"不需開啟視窗,也能持續同步、整理和提供記憶。",
导出日志: "匯出日誌",
"把本机运行日志打包成一个文件,发给开发排查。":
"把本機執行日誌打包成一個檔案,提供給開發人員排查。",
"打包完整本機日誌,可能包含對話正文和本機路徑;只傳送給可信的開發人員。",
"确定退出 One Memory 界面吗?": "要關閉 MemHub 視窗嗎?",
"后台服务会继续同步、整理和提供记忆。":
"背景服務會繼續同步、整理和提供記憶。",
@@ -735,6 +735,76 @@ describe("diagnostic log", () => {
expect(all).not.toContain("/Users/");
});
it("optionally includes every regular file from the local logs directory", async () => {
const dataDir = await root();
const outputDir = await root();
const logs = path.join(dataDir, "logs");
await mkdir(logs, { recursive: true });
await writeFile(
path.join(logs, "diagnostic-core.jsonl"),
`${JSON.stringify({
schema_version: 1,
timestamp: "2026-10-05T01:00:00.000Z",
level: "error",
service: "core",
component: "core.database",
event: "database.error",
outcome: "failed",
session_id: "11111111-1111-4111-8111-111111111111",
error_code: "database_locked",
})}\n`,
);
await writeFile(
path.join(logs, "core.log"),
"raw private conversation and full local path\n",
);
await writeFile(path.join(logs, "service-error.log.1"), "rotated error\n");
await writeFile(path.join(logs, ".hidden log"), "hidden diagnostic\n");
await writeFile(path.join(logs, "服务 日志.log"), "unicode diagnostic\n");
await writeFile(path.join(logs, "empty.log"), "");
await writeFile(
path.join(logs, "large.log"),
Buffer.alloc(256 * 1024, 120),
);
const outside = path.join(await root(), "outside.log");
await writeFile(outside, "must not be followed\n");
await symlink(outside, path.join(logs, "linked.log"));
const target = path.join(outputDir, "MemHub.zip");
const result = await exportDiagnosticZip({
dataDir,
target,
productVersion: "3800",
includeRawLogs: true,
});
expect(result).toEqual({ filename: "MemHub.zip", file_count: 10 });
const files = unzipSync(await readFile(target));
expect(Object.keys(files).sort()).toEqual([
"README.txt",
"diagnostics/events.jsonl",
"logs/.hidden log",
"logs/core.log",
"logs/diagnostic-core.jsonl",
"logs/empty.log",
"logs/large.log",
"logs/service-error.log.1",
"logs/服务 日志.log",
"summary.json",
]);
expect(strFromU8(files["logs/core.log"] ?? new Uint8Array())).toContain(
"raw private conversation",
);
expect(strFromU8(files["README.txt"] ?? new Uint8Array())).toContain(
"原始日志可能包含会话正文",
);
expect(files["logs/linked.log"]).toBeUndefined();
expect(files["logs/empty.log"]).toHaveLength(0);
expect(files["logs/large.log"]).toHaveLength(256 * 1024);
if (process.platform !== "win32")
expect((await lstat(target)).mode & 0o777).toBe(0o600);
});
it("fails closed without publishing a ZIP when a diagnostic line is invalid", async () => {
const dataDir = await root();
await mkdir(path.join(dataDir, "logs"), { recursive: true });
@@ -1,8 +1,10 @@
import { randomUUID } from "node:crypto";
import { once } from "node:events";
import {
appendFileSync,
chmodSync,
constants,
createWriteStream,
lstatSync,
mkdirSync,
renameSync,
@@ -12,6 +14,7 @@ import {
import {
appendFile,
chmod,
type FileHandle,
link,
lstat,
mkdir,
@@ -25,7 +28,7 @@ import {
} from "node:fs/promises";
import os from "node:os";
import path from "node:path";
import { strToU8, zipSync } from "fflate";
import { strToU8, Zip, ZipPassThrough, zipSync } from "fflate";
export const DIAGNOSTIC_SCHEMA_VERSION = 1 as const;
export const DIAGNOSTIC_MAX_FILE_BYTES = 4 * 1024 * 1024;
@@ -627,6 +630,7 @@ export async function exportDiagnosticZip(options: {
now?: Date;
markers?: DiagnosticMarker[];
summary?: Record<string, unknown>;
includeRawLogs?: boolean;
}): Promise<{ filename: string; file_count: number }> {
const now = options.now ?? new Date();
const home = options.home ?? os.homedir();
@@ -672,7 +676,14 @@ export async function exportDiagnosticZip(options: {
"MemHub diagnostics",
"",
"包含:最近 7 天通过安全白名单重新投影的诊断事件,以及不含正文的状态摘要。",
"不包含:原始日志、崩溃报告、对话原文、记忆内容、API Key、token 或宿主凭证。",
...(options.includeRawLogs
? [
"同时包含:One Memory Home 的 logs/ 目录中全部普通日志文件及轮转文件。",
"注意:原始日志可能包含会话正文、本机路径或其他私人信息,只应发送给可信的开发人员。",
]
: [
"不包含:原始日志、崩溃报告、对话原文、记忆内容、API Key、token 或宿主凭证。",
]),
...(markers.length
? [
"",
@@ -689,20 +700,30 @@ export async function exportDiagnosticZip(options: {
const directory = path.dirname(options.target);
await mkdir(directory, { recursive: true });
const temporary = path.join(directory, `.memhub-logs-${randomUUID()}.tmp`);
const rawLogs = options.includeRawLogs
? await openRawLogSnapshots(options.dataDir)
: [];
try {
await writeFile(temporary, zipSync(archive, { level: 6 }), {
mode: 0o600,
flag: "wx",
});
if (options.includeRawLogs)
await writeStreamingDiagnosticZip(temporary, archive, rawLogs);
else
await writeFile(temporary, zipSync(archive, { level: 6 }), {
mode: 0o600,
flag: "wx",
});
await chmod(temporary, 0o600);
await publishTemporaryFile(temporary, options.target);
} catch (error) {
await rm(temporary, { force: true }).catch(() => undefined);
throw error;
} finally {
await Promise.all(
rawLogs.map((file) => file.handle.close().catch(() => undefined)),
);
}
return {
filename: path.basename(options.target),
file_count: Object.keys(archive).length,
file_count: Object.keys(archive).length + rawLogs.length,
};
}
@@ -902,6 +923,151 @@ async function readRegularFileNoFollow(
}
}
interface RawLogSnapshot {
name: string;
handle: FileHandle;
size: number;
}
async function openRawLogSnapshots(dataDir: string): Promise<RawLogSnapshot[]> {
const directory = diagnosticDirectory(dataDir);
let directoryInfo: Awaited<ReturnType<typeof lstat>>;
try {
directoryInfo = await lstat(directory);
} catch (error) {
if ((error as NodeJS.ErrnoException).code === "ENOENT") return [];
throw error;
}
if (!directoryInfo.isDirectory() || directoryInfo.isSymbolicLink())
fail("diagnostic_directory_unsafe");
const entries = await readdir(directory, { withFileTypes: true });
const snapshots: RawLogSnapshot[] = [];
try {
for (const entry of entries) {
if (entry.isSymbolicLink()) continue;
if (
entry.name === "." ||
entry.name === ".." ||
[...entry.name].some((character) => {
const code = character.charCodeAt(0);
return (
character === "/" || character === "\\" || code < 32 || code === 127
);
})
)
fail("diagnostic_log_name_unsafe");
const target = path.join(directory, entry.name);
const before = await lstat(target).catch(() => null);
if (!before?.isFile() || before.isSymbolicLink()) continue;
const handle = await open(
target,
constants.O_RDONLY | (constants.O_NOFOLLOW ?? 0),
);
let retained = false;
try {
const opened = await handle.stat();
if (
!opened.isFile() ||
opened.dev !== before.dev ||
opened.ino !== before.ino
)
fail("diagnostic_file_unsafe");
snapshots.push({ name: entry.name, handle, size: opened.size });
retained = true;
} finally {
if (!retained) await handle.close().catch(() => undefined);
}
}
const after = await lstat(directory);
if (
!after.isDirectory() ||
after.isSymbolicLink() ||
after.dev !== directoryInfo.dev ||
after.ino !== directoryInfo.ino
)
fail("diagnostic_directory_unsafe");
return snapshots.sort((left, right) => left.name.localeCompare(right.name));
} catch (error) {
await Promise.all(
snapshots.map((file) => file.handle.close().catch(() => undefined)),
);
throw error;
}
}
async function writeStreamingDiagnosticZip(
target: string,
archive: Record<string, Uint8Array>,
rawLogs: RawLogSnapshot[],
): Promise<void> {
const output = createWriteStream(target, { flags: "wx", mode: 0o600 });
let outputError: Error | null = null;
let backpressure = Promise.resolve();
const finished = new Promise<void>((resolve, reject) => {
output.once("finish", resolve);
output.once("error", (error) => {
outputError = error;
reject(error);
});
});
// The producer may still be reading a source file when the output stream
// fails. Attach a rejection handler immediately; the awaited promise below
// still carries the same error into the export's fail-closed cleanup.
void finished.catch(() => undefined);
const zip = new Zip((error, chunk, final) => {
if (error) {
outputError = error;
output.destroy(error);
return;
}
if (chunk.length > 0 && !output.write(chunk))
backpressure = once(output, "drain").then(() => undefined);
if (final) output.end();
});
const ensureWritable = async () => {
await backpressure;
if (outputError) throw outputError;
};
try {
for (const [name, content] of Object.entries(archive)) {
await ensureWritable();
const input = new ZipPassThrough(name);
zip.add(input);
input.push(content, true);
}
for (const file of rawLogs) {
await ensureWritable();
const input = new ZipPassThrough(`logs/${file.name}`);
zip.add(input);
let position = 0;
while (position < file.size) {
await ensureWritable();
const buffer = Buffer.allocUnsafe(
Math.min(64 * 1024, file.size - position),
);
const { bytesRead } = await file.handle.read(
buffer,
0,
buffer.length,
position,
);
if (bytesRead < 1) fail("diagnostic_file_changed");
position += bytesRead;
input.push(buffer.subarray(0, bytesRead), position === file.size);
}
if (file.size === 0) input.push(new Uint8Array(), true);
}
await ensureWritable();
zip.end();
await finished;
} catch (error) {
zip.terminate();
output.destroy(error instanceof Error ? error : undefined);
await finished.catch(() => undefined);
throw error;
}
}
export async function diagnosticLogFiles(dataDir: string): Promise<string[]> {
const directory = diagnosticDirectory(dataDir);
let directoryInfo: Awaited<ReturnType<typeof lstat>>;