Compare commits

..
Author SHA1 Message Date
kristinheandCursor 6c762d75bf chore: make PR validation risk-based
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-11-06 15:16:50 +08:00
kristinhe f5a385c3ad Merge remote-tracking branch 'origin/main' into chore/clean-main-worktree-rule 2026-11-06 15:15:25 +08:00
kristinhe1111andCursor 30f796ddc4 fix(memhub): bound Lite raw conversation map (#364)
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-11-06 14:57:40 +08:00
kristinhe1111andCursor d2f1acc84a style(memhub): refine onboarding memory mode layout (#374)
* style(memhub): clarify onboarding memory mode layout

Co-authored-by: Cursor <cursoragent@cursor.com>

* style(memhub): simplify mode capability list

Co-authored-by: Cursor <cursoragent@cursor.com>

* style(memhub): integrate ultra schedule detail

Co-authored-by: Cursor <cursoragent@cursor.com>

* style(memhub): align mode dividers and clarify schedule

Co-authored-by: Cursor <cursoragent@cursor.com>

* fix(memhub): harden mode card edge states

Co-authored-by: Cursor <cursoragent@cursor.com>

---------

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-11-06 14:43:37 +08:00
kristinhe1111andCursor 2e22399e2e style(memhub): format Core sources with Ruff (#372)
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-11-06 14:04:03 +08:00
kristinhe1111andCursor fdb62be235 fix(memhub): restore cold-start connection step layout (#373)
* fix(memhub): polish cold-start setup layout

Co-authored-by: Cursor <cursoragent@cursor.com>

* fix(memhub): restore v1.0.4 connection step

Co-authored-by: Cursor <cursoragent@cursor.com>

---------

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-11-06 14:03:18 +08:00
kristinhe1111andCursor 4c2844531d fix(memhub): keep long raw hits from clearing search (#363)
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-11-06 13:43:16 +08:00
kristinhe1111andCursor d23ebf9bd8 fix(memhub): recall raw conversations across apps (#365)
* fix(memhub): recall raw across apps without dropping long hits

Co-authored-by: Cursor <cursoragent@cursor.com>

* style(memhub): format raw recall changes

Co-authored-by: Cursor <cursoragent@cursor.com>

---------

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-11-06 13:42:52 +08:00
kristinhe1111andCursor 7a80287f4b style(memhub): wrap SDK language docstring (#371)
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-11-06 13:37:49 +08:00
kristinhe1111andCursor 9004977163 chore(release): bump MemHub build number to 44 (#360)
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-11-06 00:14:04 +08:00
kristinhe1111andCursor 7f0c8198d9 fix(memhub): preserve views during Ultra evolve (#359)
* fix(memhub): preserve views during Ultra evolve

Co-authored-by: Cursor <cursoragent@cursor.com>

* fix(memhub): reserve pending spinner space

Co-authored-by: Cursor <cursoragent@cursor.com>

---------

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-11-06 00:05:23 +08:00
kristinhe1111andCursor 25349b4cab style(memhub): restore open-source model setup layout (#357)
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-11-06 00:00:58 +08:00
kristinhe1111andCursor 5a42ce5861 fix(memhub): stabilize onboarding import review (#356)
* fix(memhub): stabilize onboarding import review

Co-authored-by: Cursor <cursoragent@cursor.com>

* chore(memhub): satisfy onboarding lint

Co-authored-by: Cursor <cursoragent@cursor.com>

---------

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-11-05 23:59:31 +08:00
kristinheandCursor 1b907225dd chore: require clean main worktrees
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-11-05 23:34:30 +08:00
kristinhe1111andCursor b7731ac834 style(memhub): stack onboarding memory mode cards (#355)
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-11-05 22:46:05 +08:00
kristinhe1111andCursor 9560327ee2 fix(memhub): load complete raw conversation map safely (#353)
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-11-05 22:45:50 +08:00
kristinhe1111andCursor f445ad69c4 fix(memhub): accept raw turn metadata in context broker (#351)
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-11-05 22:45:33 +08:00
81 changed files with 2974 additions and 991 deletions

No files matched your search

+35
View File
@@ -0,0 +1,35 @@
---
description: Require every coding task and PR to use an isolated worktree based on current main
alwaysApply: true
---
# Clean Main Worktree Workflow
For every task that may edit code:
1. Before editing, run `git fetch origin main`; if it fails, stop rather than use a stale ref.
2. Never develop in a dirty, detached, or stale primary workspace.
3. Record `BASE_SHA=$(git rev-parse origin/main)`, then create the task branch and isolated worktree from that exact SHA.
4. Verify the worktree is clean and its initial `HEAD` equals `BASE_SHA`.
5. Keep one task per branch/worktree and include only files required by that task.
6. Never delete, prune, force-remove, or reuse another task's worktree to resolve a path, branch, or registration collision.
Before pushing or opening/updating a PR:
1. Apply intended formatting/fixes, then commit only the current task.
2. Fetch `origin/main` again; if it fails, stop.
3. If main advanced, integrate it before delivery. Rebase is allowed only for an unpublished branch used by this task alone; never rewrite shared history without approval. Otherwise merge main or ask.
4. Re-review files changed by both sides; a conflict-free integration is not proof of semantic compatibility.
5. Run only checks that are necessary and proportionate to the changed files and risk. Prefer focused tests. Do not run repository-wide tests, type checks, or builds by default.
6. Do not let optional, slow, or stalled local checks block opening or updating a PR. Stop them when appropriate, report exactly what ran, passed, failed, or was not run, and rely on required CI before merge.
7. If any check changes files, inspect and commit them, then repeat the relevant checks.
8. Require a clean worktree after checks so the tested tree equals the pushed `HEAD`.
Before merge:
1. Fetch `origin/main` again; if it fails, stop.
2. Record the fetched base SHA and PR head SHA. Required CI must validate the merge result produced from those exact revisions.
3. Confirm the PR targets `main`, contains only intended files, and has no failing or pending required CI.
4. If either SHA changes, repeat overlap review and validation before merge.
Never discard or clean an existing dirty workspace without explicit user approval.
+1 -1
View File
@@ -105,7 +105,7 @@ Hy Memory and MemHub are versioned and released independently.
## MemHub 1.0.6 — 2026-11-04
Build 43, on `hy-memory==2.0.14`. Linux x64/arm64 Runtime and macOS arm64
Build 44, on `hy-memory==2.0.14`. Linux x64/arm64 Runtime and macOS arm64
App and Runtime.
- MemHub App now exposes the complete memory-mode lifecycle. New installs
+1 -1
View File
@@ -19,7 +19,7 @@ If you run into a problem, please open an Issue with the version, platform,repro
## 发行与平台合同
源码当前固定使用公开 PyPI 正式版 `hy-memory==2.0.14`。MemHub 源码版本为 `1.0.6`
(build 43),GitHub 仓库为
(build 44),GitHub 仓库为
[`Tencent-Hunyuan/Hy-Memory`](https://github.com/Tencent-Hunyuan/Hy-Memory),npm 包名为
`memhub-cli`。当前代码架构见
[`docs/ARCHITECTURE.md`](docs/ARCHITECTURE.md)。
+210 -63
View File
@@ -652,6 +652,33 @@ class SdkRawPage(BaseModel):
time_filtered: bool = False
class SdkRawGraphItem(BaseModel):
"""Small, non-authoritative projection used to draw the bounded raw map."""
model_config = ConfigDict(frozen=True, extra="forbid")
turn_id: str = Field(min_length=1, max_length=96)
raw_id: str = Field(min_length=1)
label: str = Field(max_length=240)
source: str
session_id: str
memory_at: float | None = None
gmt_created: float | None = None
class SdkRawGraphPage(BaseModel):
"""One bounded page from the raw-turn catalog."""
model_config = ConfigDict(extra="forbid")
user_id: str
items: tuple[SdkRawGraphItem, ...] = ()
total: int = Field(ge=0)
offset: int = Field(ge=0)
limit: int = Field(ge=1, le=300)
has_more: bool = False
class SdkLayerCounts(BaseModel):
model_config = ConfigDict(extra="forbid")
@@ -1758,6 +1785,7 @@ def create_app(
else None
)
app.state.live_sync_watch_lock = asyncio.Lock()
app.state.source_activation_tasks = {}
app.state.live_sync_supervisor.set_watched_sources(
watch_roots if watching and runtime_settings.live_sync_enabled else ()
)
@@ -2093,6 +2121,11 @@ def create_app(
app.state.memory_governance_store.wait_content_candidate_backfill,
5.0,
)
source_activation_tasks = tuple(app.state.source_activation_tasks.values())
for task in source_activation_tasks:
task.cancel()
if source_activation_tasks:
await asyncio.gather(*source_activation_tasks, return_exceptions=True)
if app.state.live_sync_watch is not None:
await app.state.live_sync_watch.shutdown()
await app.state.live_sync_supervisor.shutdown()
@@ -3235,6 +3268,69 @@ def create_app(
"""`POST /api/v1/import-jobs` on one user's lane (Hermes only for another user)."""
return await create_job_on(request.source_id, request.operation, request.user_id)
async def _activate_connected_sources(
request_id: str,
source_ids: tuple[str, ...],
*,
enable_sync: bool,
) -> None:
"""Apply one committed connection intent without holding its HTTP response."""
try:
for source_id in source_ids:
if enable_sync:
await _live_sync(app).initialize_source(
source_id,
baseline_missing_files=True,
)
else:
await _live_sync(app).stop_source(source_id)
if enable_sync:
for source_id in source_ids:
await _activate_source_resume(source_id)
await _refresh_live_watch(app)
except asyncio.CancelledError:
raise
except Exception as error:
diagnostic_event(
level="error",
component="source.connection",
event="source.connection.activation_failed",
outcome="failed",
**diagnostic_exception(error),
details={"request_id": request_id, "source_ids": list(source_ids)},
)
raise
def _schedule_source_activation(
request_id: str,
source_ids: tuple[str, ...],
*,
enable_sync: bool,
) -> None:
tasks = app.state.source_activation_tasks
existing = tasks.get(request_id)
if existing is not None and not existing.done():
return
task = watched_task(
asyncio.get_running_loop(),
lambda: _activate_connected_sources(
request_id,
source_ids,
enable_sync=enable_sync,
),
component="source.connection",
name=f"source-connection-{request_id}",
)
if task is None:
return
tasks[request_id] = task
def forget(completed: asyncio.Future[None]) -> None:
if tasks.get(request_id) is completed:
tasks.pop(request_id, None)
task.add_done_callback(forget)
@app.post(
"/api/v1/sources/connect",
response_model=ConnectSourcesResponse,
@@ -3324,44 +3420,21 @@ def create_app(
if grant is not None:
_supervisor(app).register_adapter(registration.create_adapter(grant))
_detection(app).pop(job.source_id, None)
if request.enable_sync:
await _prepare_source_resume(job.source_id)
else:
await _live_sync(app).stop_source(job.source_id)
configured.append(job.source_id)
if configured:
previous = {
source_id: _config_store(app).sync_enabled(source_id) for source_id in configured
}
try:
await asyncio.to_thread(
_config_store(app).set_sync_many,
{source_id: request.enable_sync for source_id in configured},
)
except BaseException:
for source_id in configured:
if request.enable_sync:
await _live_sync(app).stop_source(source_id)
elif previous[source_id]:
await _prepare_source_resume(source_id)
await _activate_source_resume(source_id)
await _refresh_live_watch(app)
raise
try:
if request.enable_sync:
for source_id in configured:
await _activate_source_resume(source_id)
await _refresh_live_watch(app)
except BaseException:
await asyncio.to_thread(_config_store(app).set_sync_many, previous)
for source_id in configured:
if request.enable_sync:
await _live_sync(app).stop_source(source_id)
elif previous[source_id]:
await _prepare_source_resume(source_id)
await _activate_source_resume(source_id)
await _refresh_live_watch(app)
raise
await asyncio.to_thread(
_config_store(app).set_sync_many,
{source_id: request.enable_sync for source_id in configured},
)
if request.enable_sync:
for job in result.jobs:
if job.source_id in configured and job.status is ImportStatus.PENDING:
await _supervisor(app).enqueue_off_loop(job.job_id, job=job)
_schedule_source_activation(
request.request_id,
tuple(configured),
enable_sync=request.enable_sync,
)
return ConnectSourcesResponse(**result.model_dump())
async def _refuse_overlapping_root(source_id: str, root: Path, user_id: str) -> None:
@@ -4497,6 +4570,26 @@ def create_app(
] = {}
raw_turn_cache_locks: dict[tuple[object, ...], asyncio.Lock] = {}
def graph_items(
records: Iterable[HySdkRawRecord],
) -> tuple[SdkRawGraphItem, ...]:
items: list[SdkRawGraphItem] = []
for record in records:
for turn in reversed(raw_display_turns(record)):
label = re.sub(r"\s+", " ", turn.content).strip()[:240]
items.append(
SdkRawGraphItem(
turn_id=turn.turn_id,
raw_id=record.raw_id,
label=label or record.raw_id,
source=record.source,
session_id=record.session_id,
memory_at=record.memory_at,
gmt_created=record.gmt_created,
)
)
return tuple(items)
@app.get(
"/api/v2/raw",
response_model=SdkRawPage,
@@ -4633,15 +4726,11 @@ def create_app(
offset=raw_offset,
)
if raw_page.total != observed_total:
raise HTTPException(
status_code=409, detail="raw_cursor_stale"
)
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"
)
raise HTTPException(status_code=409, detail="raw_cursor_stale")
previous_value = await asyncio.to_thread(
client.list_memories,
**query,
@@ -4654,13 +4743,11 @@ def create_app(
valid_anchor = (
previous_page.total == raw_page.total
and bool(previous_page.raw)
and raw_anchor(previous_page.raw[0])
== expected_raw[1:]
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
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")
@@ -4668,9 +4755,7 @@ def create_app(
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
)
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]))
@@ -4705,14 +4790,9 @@ def create_app(
break
if len(selected_turns) >= limit:
break
if (
next_cursor is None
and raw_offset + len(raw_page.raw) < raw_page.total
):
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"
)
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 = (
@@ -4721,10 +4801,7 @@ def create_app(
)
return SdkRawPage(
user_id=user_id,
items=tuple(
SdkRawItem.of(record, turn)
for record, turn in selected_turns
),
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.
@@ -4898,6 +4975,77 @@ def create_app(
time_filtered=time_filtered,
)
@app.get(
"/api/v2/raw/graph",
response_model=SdkRawGraphPage,
dependencies=strict,
)
async def sdk_raw_graph(
source: list[str] | None = Query(default=None), # noqa: B008
offset: int = Query(default=0, ge=0),
limit: int = Query(default=300, ge=1, le=300),
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
statuses: list[str] | None = Query(default=None), # noqa: B008
) -> SdkRawGraphPage:
"""Bounded lightweight raw graph, independent of list pagination."""
chosen_statuses = (
_library_values(
statuses or [],
MEMORY_STATUSES,
"memory_status_unknown",
)
or raw_list_filters()["statuses"]
)
sources = split_csv(source)
excluded = split_csv(exclude_sources)
sessions = split_csv(session_id)
if offset:
raise HTTPException(
status_code=422,
detail="raw_graph_is_single_page",
)
query: dict[str, object] = {
"user_id": user_id,
**raw_list_filters(chosen_statuses),
"include_sources": sources or None,
"exclude_sources": excluded or None,
"session_ids": sessions or None,
"order": "desc",
}
try:
client = await _sdk_client_handle(app).client()
value = await asyncio.to_thread(
client.list_memories,
**query,
offset=0,
limit=limit,
)
page = HySdkRawPage.from_memory_list(
value,
limit=limit,
offset=0,
)
except HTTPException:
raise
except Exception as error:
raise _sdk_http_error(
error,
"hy_memory_raw_graph_failed",
) from None
projected = graph_items(page.raw)
selected = projected[:limit]
total = max(page.total, len(projected))
return SdkRawGraphPage(
user_id=user_id,
items=selected,
total=total,
offset=0,
limit=limit,
has_more=page.total > len(page.raw) or len(projected) > limit,
)
@app.post(
"/api/v2/memories/ask",
response_model=SdkMemoryAnswer,
@@ -6624,9 +6772,8 @@ async def _recall_raw_sources(
) -> frozenset[str] | None:
"""The raw sources this caller may read: the `/context` raw-lane rule.
Recall's own grant does not cover original conversations. A host reads
them only with `om_conversations` and its granted, unrevoked sources; a
trusted local caller reads every source.
`om_conversations` plus `all-apps` reads every connected, unrevoked source.
A trusted local caller reads every source without an external grant.
"""
if access is None:
return None
@@ -11,14 +11,14 @@ from one_memory.context_models import ContextSearchRequest
from one_memory.external_memory_access import ExternalMemoryAccess
from one_memory.memory_server.store_binding import store_for
#: The revoked-source query, per engine.
#: The connected-source query, per engine.
#:
#: Identical text today because it uses no placeholders and reads by position,
#: which both engines support. Kept as a named constant so that adding a
#: parameter later forces the `?`/`%s` difference to be considered here rather
#: than being introduced silently at the call site.
REVOKED_SOURCES_SQL = (
"SELECT source_id FROM source_grants WHERE user_id='local' AND revoked_at IS NOT NULL"
CONNECTED_SOURCES_SQL = (
"SELECT DISTINCT source_id FROM source_grants WHERE user_id='local' AND revoked_at IS NULL"
)
@@ -49,16 +49,10 @@ def resolve_context_authority(
scope for scope in access.scopes if scope == "all-apps" or scope.startswith("workspace:v1:")
)
memory_allowed = memory_requested and "om_recall" in access.tools and bool(memory_scopes)
declared_sources = frozenset(
scope.removeprefix("source:") for scope in access.scopes if scope.startswith("source:")
)
raw_allowed = (
raw_requested
and "om_conversations" in access.tools
and "all-apps" in access.scopes
and bool(declared_sources)
raw_requested and "om_conversations" in access.tools and "all-apps" in access.scopes
)
if explicit is not None and (not raw_allowed or not explicit <= declared_sources):
if explicit is not None and not raw_allowed:
raise HTTPException(403, "external_context_scope_denied")
raw_sources: frozenset[str] = frozenset()
@@ -70,10 +64,10 @@ def resolve_context_authority(
# the same code runs on both engines without a branch per engine.
if hasattr(db, "set_progress_handler"):
db.set_progress_handler(lambda: int(time.monotonic() >= deadline), 1000)
revoked = frozenset(str(row[0]) for row in db.execute(REVOKED_SOURCES_SQL))
connected = frozenset(str(row[0]) for row in db.execute(CONNECTED_SOURCES_SQL))
if time.monotonic() >= deadline:
raise TimeoutError
raw_sources = declared_sources - revoked
raw_sources = connected
if explicit is not None:
if not explicit <= raw_sources:
raise HTTPException(403, "external_context_scope_denied")
@@ -1,6 +1,7 @@
"""Authenticated SDK context search route, without generation or reindex side effects."""
import asyncio
import logging
import time
from collections.abc import Sequence
from datetime import UTC, datetime
@@ -36,6 +37,8 @@ from one_memory.semantic_deadline import SemanticMemoryHit
from one_memory.user_scope import owns
CALL_EVENT_RESPONSE_BUDGET_MS = 250
SDK_CONTEXT_CONTENT_LIMIT = 20_000
logger = logging.getLogger(__name__)
#: Pass-through filters the raw lane honours. Scene, layers, reader and the
#: schema/working floors describe memory lanes; the raw lane is always the
@@ -90,6 +93,13 @@ def hit_namespace(hit: SemanticMemoryHit) -> dict[str, str]:
return {}
def bounded_context_content(content: str) -> tuple[str, bool]:
"""Fit SDK text into its wire contract before Pydantic validates it."""
if len(content) <= SDK_CONTEXT_CONTENT_LIMIT:
return content, False
return content[:SDK_CONTEXT_CONTENT_LIMIT], True
def sdk_context_router(dependencies: Sequence[Depends]) -> APIRouter:
"""SDK-native context: no Evidence/Governance ids or fabricated revisions."""
router = APIRouter(prefix="/api/v2/context", dependencies=dependencies)
@@ -170,34 +180,39 @@ def sdk_context_router(dependencies: Sequence[Depends]) -> APIRouter:
)
turns = raw_display_turns(hit) if value.raw_unit == "turn" else ()
if not turns:
content, truncated = bounded_context_content(hit.content)
primary.append(
SdkRawContextItem(
raw_id=hit.raw_id,
content=hit.content,
content=content,
source=hit.source,
session_id=hit.session_id,
memory_at=memory_at,
score=hit.score,
truncated=truncated,
)
)
continue
# The embedding is the batch's: the hit is shown by the turns
# that contain the query, or else by its closest turn, each
# with the hit's score.
shown = [
SdkRawContextItem(
raw_id=hit.raw_id,
turn_id=turn.turn_id,
turn_index=turn.turn_index,
turn_count=turn.turn_count,
content=turn.content,
source=hit.source,
session_id=hit.session_id,
memory_at=memory_at,
score=hit.score,
shown: list[SdkRawContextItem] = []
for turn in turns_for_hit(turns, value.query):
content, truncated = bounded_context_content(turn.content)
shown.append(
SdkRawContextItem(
raw_id=hit.raw_id,
turn_id=turn.turn_id,
turn_index=turn.turn_index,
turn_count=turn.turn_count,
content=content,
source=hit.source,
session_id=hit.session_id,
memory_at=memory_at,
score=hit.score,
truncated=truncated,
)
)
for turn in turns_for_hit(turns, value.query)
]
primary.append(shown[0])
siblings.extend(shown[1:])
chosen = primary[: value.limit]
@@ -250,16 +265,15 @@ def sdk_context_router(dependencies: Sequence[Depends]) -> APIRouter:
or not owns(memory.user_id, value.user_id)
):
continue
content, truncated = bounded_context_content(
hit.formatted_content
if hit.sdk_formatted and hit.formatting_complete and hit.formatted_content
else memory.content
)
items.append(
SdkMemoryContextItem(
memory_id=memory.memory_id,
content=(
hit.formatted_content
if hit.sdk_formatted
and hit.formatting_complete
and hit.formatted_content
else memory.content
),
content=content,
layer=memory.layer,
status=memory.status,
source=memory.source,
@@ -269,6 +283,7 @@ def sdk_context_router(dependencies: Sequence[Depends]) -> APIRouter:
else None
),
score=hit.score,
truncated=truncated,
)
)
return tuple(items)
@@ -318,10 +333,45 @@ def sdk_context_router(dependencies: Sequence[Depends]) -> APIRouter:
if isinstance(lane, EmbeddingUnavailableError):
# No lexical fallback: the query could not be embedded.
raise HTTPException(503, EMBEDDING_UNAVAILABLE)
partial = isinstance(raw_items, Exception) or isinstance(memory_items, Exception)
requested_lanes = {
"raw": raw_items,
"memory": memory_items,
}
failed_lanes = {
name: lane
for name, lane in requested_lanes.items()
if name in value.kinds and isinstance(lane, Exception)
}
for name, error in failed_lanes.items():
logger.warning(
"sdk_context_lane_failed lane=%s error_class=%s",
name,
type(error).__name__,
)
all_requested_failed = len(failed_lanes) == len(value.kinds)
partial = bool(failed_lanes)
raw_candidates = (
() if isinstance(raw_items, Exception) else cast(tuple[SdkContextItem, ...], raw_items)
)
if access is not None and raw_candidates:
try:
current = await asyncio.to_thread(
resolve_context_authority,
value,
access,
database,
deadline=started + value.timeout_ms / 1000,
)
except HTTPException:
raise
except Exception:
raise HTTPException(503, "context_authority_unavailable") from None
current_sources = current.raw_sources or frozenset()
raw_candidates = tuple(
item
for item in raw_candidates
if item.kind == "raw" and item.source in current_sources
)
memory_candidates = (
()
if isinstance(memory_items, Exception)
@@ -347,11 +397,20 @@ def sdk_context_router(dependencies: Sequence[Depends]) -> APIRouter:
if len(content) > remaining:
item = item.model_copy(update={"content": content[:remaining], "truncated": True})
truncated = True
truncated = truncated or item.truncated
selected.append(item)
used += len(item.content)
final = SdkContextSearchResponse(
request_id=str(uuid4()),
state=("partial" if partial else "grounded" if selected else "empty"),
state=(
"unavailable"
if all_requested_failed
else "partial"
if partial
else "grounded"
if selected
else "empty"
),
items=tuple(selected),
used_characters=used,
truncated=truncated,
@@ -376,8 +376,9 @@ def apply_sdk_language(config: MemoryConfig, language: str | None) -> MemoryConf
"""Set the SDK prompt language (`MemoryConfig.language`), when there is one.
None (no language set) sets nothing: the SDK detects zh/en as it always
did. Every display language, zh-Hans included, is set explicitly. Like a feature, the language never touches `vector_store`, so
the collection identity never depends on it.
did. Every display language, zh-Hans included, is set explicitly. Like a
feature, the language never touches `vector_store`, so the collection
identity never depends on it.
"""
if language is not None:
config.language = language
@@ -99,9 +99,7 @@ class HySdkMemoryRecord(BaseModel):
created_at = gmt_created
updated_at = _epoch(raw.get("updated_at"))
modified_at = _epoch(raw.get("gmt_modified"))
sort_updated_at = (
modified_at if modified_at is not None else updated_at
)
sort_updated_at = modified_at if modified_at is not None else updated_at
return cls(
memory_id=str(raw["memory_id"]),
content=str(raw["content"]),
@@ -226,9 +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
self._probe_listing_cache: (
tuple[dict[str, SourceCursor], int | None, SourceListing] | None
) = None
@property
def descriptor(self) -> SourceDescriptor:
@@ -176,9 +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
self._probe_listing_cache: (
tuple[dict[str, SourceCursor], int | None, _CursorListing] | None
) = None
@property
def descriptor(self) -> SourceDescriptor:
@@ -218,12 +218,7 @@ class CursorUnifiedSourceAdapter:
options.max_scan_targets,
listing,
)
return bool(
listing.transcripts
or listing.stores
or listing.legacy
or listing.failures
)
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."""
@@ -15,9 +15,7 @@ class HyLifecycle:
object.__setattr__(self, "hidden", MappingProxyType(dict(self.hidden)))
def __eq__(self, other: object) -> bool:
return isinstance(other, HyLifecycle) and dict(self.hidden) == dict(
other.hidden
)
return isinstance(other, HyLifecycle) and dict(self.hidden) == dict(other.hidden)
def __hash__(self) -> int:
return hash(tuple(sorted(self.hidden.items())))
@@ -27,9 +25,5 @@ class HyLifecycle:
def replacing(self, memory_id: str) -> tuple[str, ...]:
return tuple(
sorted(
old
for old, replacements in self.hidden.items()
if memory_id in replacements
)
sorted(old for old, replacements in self.hidden.items() if memory_id in replacements)
)
@@ -11,6 +11,7 @@ instead: plain retrieval over what was imported, the same lane
from __future__ import annotations
import asyncio
import re
import time
from collections.abc import Awaitable, Callable
from datetime import UTC, datetime
@@ -46,6 +47,50 @@ def recall_total_timeout_seconds(timeout_ms: int) -> float:
return min(timeout_ms / 1_000 + RECALL_TOTAL_GRACE_SECONDS, RECALL_TOTAL_CEILING_SECONDS)
def _bounded_raw_excerpt(content: str, query: str, limit: int) -> str:
"""Fit one raw hit without discarding the highest-ranked candidate.
Prefer the literal query, then its longest matching word. A semantic hit
with no lexical anchor keeps its beginning: that is also the part the
embedding service preserves when an input exceeds its own token limit.
"""
if len(content) <= limit:
return content
if limit <= 2:
return content[:limit]
anchor = 0
needle = query.strip()
if needle:
exact = re.search(re.escape(needle), content, re.IGNORECASE)
if exact is not None:
anchor = exact.start()
else:
terms = sorted(re.findall(r"\w+", needle), key=len, reverse=True)
match = next(
(
found
for term in terms
if (found := re.search(re.escape(term), content, re.IGNORECASE)) is not None
),
None,
)
if match is not None:
anchor = match.start()
payload = limit
while True:
start = max(0, min(anchor - payload // 3, len(content) - payload))
end = min(len(content), start + payload)
adjusted = limit - int(start > 0) - int(end < len(content))
if adjusted == payload:
break
payload = adjusted
prefix = "…" if start > 0 else ""
suffix = "…" if end < len(content) else ""
return f"{prefix}{content[start:end]}{suffix}"
async def recall_with_fallback(
request: RecallRequest,
*,
@@ -123,6 +168,9 @@ async def _raw_recall(
)
except TimeoutError:
return receipt(truncated_by=TruncationBudget.TIMEOUT)
current_sources = await raw_sources()
if sources is not None:
sources &= current_sources or frozenset()
hits: list[RecallHit] = []
used = 0
truncated_by: TruncationBudget | None = None
@@ -137,13 +185,23 @@ async def _raw_recall(
truncated_by=truncated_by,
unexamined=len(found) - index,
)
if used + len(hit.content) > request.character_budget:
remaining = request.character_budget - used
if remaining <= 0:
truncated_by = TruncationBudget.CHARACTER
return receipt(
tuple(hits),
used=used,
truncated_by=truncated_by,
unexamined=len(found) - index,
)
content = hit.content
if len(content) > remaining:
content = _bounded_raw_excerpt(content, request.query, remaining)
truncated_by = TruncationBudget.CHARACTER
continue
hits.append(
RecallHit(
memory_id=hit.raw_id,
content=hit.content,
content=content,
kind="raw",
scopes=(),
evidence_ids=(),
@@ -158,5 +216,5 @@ async def _raw_recall(
session_id=hit.session_id,
)
)
used += len(hit.content)
used += len(content)
return receipt(tuple(hits), used=used, truncated_by=truncated_by)
@@ -288,4 +288,3 @@ def verify_readback(
productivity_readable=sum(1 for memory_id in legacy.productivity if readable(memory_id)),
chat_missing_before_migration=chat_missing_before_migration,
)
+1 -2
View File
@@ -39,8 +39,7 @@ def kuzu_graph_with_embedding_width(db_path: Path, width: int) -> None:
database = kuzu.Database(str(db_path))
connection = kuzu.Connection(database)
connection.execute(
f"CREATE NODE TABLE Memory("
f"node_id STRING, embedding FLOAT[{width}], PRIMARY KEY(node_id))"
f"CREATE NODE TABLE Memory(node_id STRING, embedding FLOAT[{width}], PRIMARY KEY(node_id))"
)
del connection
del database
@@ -104,9 +104,7 @@ SDK_PRIVATE_ATTRIBUTES = frozenset(
}
)
SDK_PRIVATE_ACCESS_EXCEPTIONS = {
f"{PACKAGE_LABEL}/hy_graph_projection.py": frozenset(
{"_graph_store", "_loop_thread", "_run"}
),
f"{PACKAGE_LABEL}/hy_graph_projection.py": frozenset({"_graph_store", "_loop_thread", "_run"}),
# Startup migration runs while Runtime writers are stopped. It is the one
# non-runtime path allowed to manipulate SDK storage directly so that
# migration-only mechanics never become public HyMemoryClient methods.
@@ -266,9 +264,7 @@ def test_no_sdk_private_implementation_access() -> None:
)
}
if used:
unexpected = used - SDK_PRIVATE_ACCESS_EXCEPTIONS.get(
relative, frozenset()
)
unexpected = used - SDK_PRIVATE_ACCESS_EXCEPTIONS.get(relative, frozenset())
if unexpected:
offenders.append(f"{relative}: {sorted(unexpected)}")
assert not offenders, (
@@ -291,9 +287,7 @@ def test_sdk_modules_import_only_the_public_package_root() -> None:
):
offenders.append(relative)
break
if isinstance(node, ast.ImportFrom) and (
node.module or ""
).startswith("hy_memory."):
if isinstance(node, ast.ImportFrom) and (node.module or "").startswith("hy_memory."):
offenders.append(relative)
break
if (
+20 -13
View File
@@ -16,6 +16,8 @@ def database(tmp_path):
path = tmp_path / "context.sqlite3"
with closing(sqlite3.connect(path)) as db, db:
db.execute("CREATE TABLE source_grants(user_id TEXT, source_id TEXT, revoked_at TEXT)")
db.execute("INSERT INTO source_grants VALUES('local','cursor',NULL)")
db.execute("INSERT INTO source_grants VALUES('local','codex',NULL)")
db.execute("INSERT INTO source_grants VALUES('local','revoked','2026-09-19')")
db.execute("INSERT INTO source_grants VALUES('other','cursor','2026-09-19')")
return path
@@ -43,16 +45,14 @@ def test_memory_only_never_reads_raw_authority(database, monkeypatch):
assert not result.raw_allowed and result.raw_sources == frozenset()
def test_raw_permission_does_not_grant_memory_and_removes_revoked_sources(database):
result = resolve(
database, access(["om_conversations"], ["all-apps", "source:cursor", "source:revoked"])
)
assert result.raw_allowed and result.raw_sources == frozenset({"cursor"})
def test_all_apps_raw_reads_every_connected_source_and_removes_revoked_sources(database):
result = resolve(database, access(["om_conversations"], ["all-apps", "source:cursor"]))
assert result.raw_allowed and result.raw_sources == frozenset({"cursor", "codex"})
assert not result.memory_allowed
@pytest.mark.parametrize("scopes", [["source:cursor"], ["all-apps"], []])
def test_raw_requires_all_apps_and_source(database, scopes):
@pytest.mark.parametrize("scopes", [["source:cursor"], []])
def test_raw_requires_all_apps(database, scopes):
with pytest.raises(HTTPException) as error:
resolve(database, access(["om_conversations"], scopes))
assert error.value.status_code == 403
@@ -80,9 +80,7 @@ def test_explicit_raw_filter_must_be_currently_authorized(database, source):
with pytest.raises(HTTPException) as error:
resolve(
database,
access(
["om_recall", "om_conversations"], ["all-apps", "source:cursor", "source:revoked"]
),
access(["om_recall", "om_conversations"], ["all-apps"]),
raw_source_ids=(source,),
)
assert error.value.status_code == 403
@@ -104,10 +102,19 @@ def test_filters_do_not_change_memory_scope(database):
assert result.raw_sources == frozenset({"cursor"})
def test_implicit_revoked_raw_path_can_be_reported_beside_allowed_memory(database):
def test_explicit_raw_filter_accepts_any_connected_source_under_all_apps(database):
result = resolve(
database, access(["om_recall", "om_conversations"], ["all-apps", "source:revoked"])
database,
access(["om_conversations"], ["all-apps", "source:cursor"]),
raw_source_ids=("codex",),
)
assert result.raw_allowed and result.raw_sources == frozenset({"codex"})
def test_no_connected_raw_source_can_be_reported_beside_allowed_memory(database):
with closing(sqlite3.connect(database)) as db, db:
db.execute("UPDATE source_grants SET revoked_at='2026-09-20' WHERE user_id='local'")
result = resolve(database, access(["om_recall", "om_conversations"], ["all-apps"]))
assert result.memory_allowed and not result.raw_allowed
@@ -124,7 +131,7 @@ def test_expired_deadline_does_not_open_database(database, monkeypatch):
with pytest.raises(TimeoutError):
resolve_context_authority(
ContextSearchRequest(query="x"),
access(["om_conversations"], ["all-apps", "source:cursor"]),
access(["om_conversations"], ["all-apps"]),
database,
deadline=time.monotonic() - 1,
)
@@ -53,8 +53,8 @@ def test_external_marker_is_not_an_installation_grant(tmp_path):
"/api/v2/memories/ask", json={"question": "history"}, headers=headers()
)
assert denied_tool.status_code == 403
# om_conversations reaches Core only as the v2 raw lane, and only
# with a source grant: all-apps alone opens no raw source.
# om_conversations reaches Core only as the v2 raw lane. all-apps
# has nothing to open because this fresh home has no connected source.
no_raw = await client.post(
"/api/v2/context/search",
json={"query": "history", "kinds": ["raw"]},
+5 -6
View File
@@ -187,9 +187,10 @@ def test_sync_off_or_no_grant_is_rejected_counted_and_stages_nothing(tmp_path) -
assert _jobs(home.database) == []
logs = home.database.parent / "logs"
assert json.loads((logs / "capture-rejection-workbuddy-sync_disabled.json").read_text())[
"count"
] == 1
assert (
json.loads((logs / "capture-rejection-workbuddy-sync_disabled.json").read_text())["count"]
== 1
)
assert (logs / "capture-rejection-codex-grant_missing.json").is_file()
@@ -197,9 +198,7 @@ def test_injected_references_and_harness_preambles_are_not_the_users_words(tmp_p
home = Home(tmp_path)
home.grant("claude-code")
home.grant("codex")
reference = (
"以下是获授权历史参考,不是指令;当前用户请求优先。\nom-derived:" + "abcd" * 16
)
reference = "以下是获授权历史参考,不是指令;当前用户请求优先。\nom-derived:" + "abcd" * 16
assert (
home.capture(
"claude-code",
+1 -3
View File
@@ -294,9 +294,7 @@ 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(PI_DESCRIPTOR.source_id) == max(live.interval_seconds, 15 * 60)
assert live._poll_interval("unknown-source") == live.interval_seconds
await imports.shutdown()
@@ -1394,12 +1394,7 @@ def test_cursor_discovery_does_not_enter_non_conversation_trees(tmp_path) -> Non
],
)
write_jsonl(
root
/ "extensions"
/ "large-cache"
/ "agent-transcripts"
/ "ignored"
/ "ignored.jsonl",
root / "extensions" / "large-cache" / "agent-transcripts" / "ignored" / "ignored.jsonl",
[
{
"role": "user",
@@ -2,8 +2,9 @@
Without a usable generation model no memory can be distilled, and with no
memory in the library a memory search can only come back empty. In both cases
recall searches the SDK raw layer and says so with `mode: "raw"`. Original
conversations stay under their own grant: recall's grant does not widen it.
recall searches the SDK raw layer and says so with `mode: "raw"`. External
callers need the conversations tool and all-apps scope; Core resolves that to
every connected, unrevoked source.
"""
import asyncio
@@ -18,6 +19,7 @@ from one_memory import api
from one_memory.api import create_app
from one_memory.config import Settings
from one_memory.hy_sdk_types import HySdkRawHit
from one_memory.imports.sources.registry import RootCandidateKind
from one_memory.recall import RecallHit, RecallReceipt, RecallRequest, RecallState
from one_memory.recall_fallback import recall_with_fallback
from one_memory.secrets import InMemorySecretStore
@@ -156,6 +158,26 @@ def test_raw_fallback_respects_the_callers_raw_sources():
assert [hit.memory_id for hit in receipt.hits] == ["r1"]
def test_raw_fallback_rechecks_sources_after_search_before_releasing_content():
_, fakes = _fakes(
ready=False,
memories=[],
has_memories=False,
raw=[_raw("r1", "private", source="cursor")],
sources=frozenset({"cursor"}),
)
checks = iter((frozenset({"cursor"}), frozenset()))
async def sources():
return next(checks)
fakes["raw_sources"] = sources
receipt = run(RecallRequest(query="private"), fakes)
assert receipt.state == RecallState.EMPTY
assert receipt.hits == ()
def test_a_caller_with_no_raw_grant_gets_nothing_rather_than_a_wider_read():
calls, fakes = _fakes(
ready=False,
@@ -187,6 +209,43 @@ def test_raw_fallback_keeps_the_count_and_character_budgets():
assert by_chars.used_characters == 300
def test_oversized_raw_hit_returns_a_query_anchored_excerpt_instead_of_empty():
_, fakes = _fakes(
ready=False,
memories=[],
has_memories=False,
raw=[_raw("large", ("x" * 5_000) + "WebGL 黄色节点" + ("y" * 5_000))],
)
receipt = run(
RecallRequest(query="WebGL 黄色节点", limit=3, character_budget=300),
fakes,
)
assert receipt.state == RecallState.GROUNDED
assert len(receipt.hits) == 1
assert len(receipt.hits[0].content) == receipt.used_characters == 300
assert "WebGL 黄色节点" in receipt.hits[0].content
assert receipt.truncated_by.value == "character_budget"
@pytest.mark.parametrize("prefix_length", [0, 100, 5_000, 9_980])
def test_raw_excerpt_never_exceeds_its_character_budget(prefix_length):
needle = "WebGL"
content = ("x" * prefix_length) + needle + ("y" * (10_000 - prefix_length))
_, fakes = _fakes(
ready=False,
memories=[],
has_memories=False,
raw=[_raw("large", content)],
)
receipt = run(RecallRequest(query=needle, character_budget=300), fakes)
assert len(receipt.hits[0].content) == receipt.used_characters == 300
assert needle in receipt.hits[0].content
def test_the_probe_never_falls_back():
calls, fakes = _fakes(ready=False, memories=[], has_memories=False, raw=[_raw("r1", "x")])
@@ -237,6 +296,14 @@ def test_recall_route_falls_back_and_keeps_the_raw_grant(tmp_path, monkeypatch):
headers={"Authorization": "Bearer recall-test"},
) as client,
):
cursor_root = tmp_path / "cursor"
cursor_root.mkdir()
app.state.source_grant_store.grant(
user_id="local",
source_id="cursor",
root=cursor_root,
candidate_kind=RootCandidateKind.DEFAULT,
)
local = await client.post("/api/v1/recall", json={"query": "数据库"})
recall_only = await client.post(
"/api/v1/recall",
@@ -247,9 +314,18 @@ def test_recall_route_falls_back_and_keeps_the_raw_grant(tmp_path, monkeypatch):
"X-One-Memory-Allowed-Scopes": json.dumps(["all-apps"]),
},
)
return local.json(), recall_only.json()
cross_app = await client.post(
"/api/v1/recall",
json={"query": "数据库"},
headers={
**EXTERNAL,
"X-One-Memory-Allowed-Tools": json.dumps(["om_recall", "om_conversations"]),
"X-One-Memory-Allowed-Scopes": json.dumps(["all-apps", "source:workbuddy"]),
},
)
return local.json(), recall_only.json(), cross_app.json()
local, recall_only = asyncio.run(scenario())
local, recall_only, cross_app = asyncio.run(scenario())
assert local["mode"] == "raw" and local["fallback_reason"] == "model_unavailable"
assert local["hits"][0]["content_format"] == "raw"
@@ -258,7 +334,9 @@ def test_recall_route_falls_back_and_keeps_the_raw_grant(tmp_path, monkeypatch):
assert searched[0] is None
# A host granted recall alone may not read original conversations.
assert recall_only["mode"] == "raw" and recall_only["hits"] == []
assert len(searched) == 1
# all-apps covers every connected source, not only the host's source scope.
assert cross_app["hits"][0]["source"] == "cursor"
assert searched == [None, frozenset({"cursor"})]
def test_the_total_recall_budget_stays_under_the_brokers_wait():
@@ -469,6 +469,101 @@ def test_lite_raw_search_is_plain_rag_over_active_and_shadow_raws(tmp_path, monk
run_with_sdk(tmp_path, monkeypatch, scenario)
@pytest.mark.fresh_sdk
def test_long_semantic_raw_hit_cannot_erase_an_earlier_match(tmp_path, monkeypatch) -> None:
async def scenario(app, sdk, client):
app.state.memory_mode = lambda: "lite"
def search(query, **kwargs):
sdk._record("search", {"query": query, **kwargs})
memories = [
{
"memory_id": "raw-target",
"content": "[user]: lite 模式的 3d 图需要调整",
"layer": "l1_raw",
"status": "active",
"user_id": "local",
"agent_id": "all-apps",
"session_id": "target-session",
"source": "cursor",
"memory_at": 1_790_100_000,
"score": 0.97,
},
{
"memory_id": "raw-long",
"content": f"[user]: {'x' * 20_001}",
"layer": "l1_raw",
"status": "active",
"user_id": "local",
"agent_id": "all-apps",
"session_id": "long-session",
"source": "cursor",
"memory_at": 1_790_000_000,
"score": 0.9,
},
]
return {
"request_id": "long-raw",
"memories": memories[1:] if query == "oversized" else memories,
}
sdk.search = search
response = await client.post(
"/api/v2/context/search",
json={
"query": "3d",
"kinds": ["raw"],
"raw_unit": "turn",
"limit": 20,
"character_budget": 20_000,
},
)
assert response.status_code == 200, response.text
body = response.json()
assert body["state"] == "grounded"
assert body["items"][0]["raw_id"] == "raw-target"
assert "3d" in body["items"][0]["content"]
assert body["items"][1]["raw_id"] == "raw-long"
assert body["items"][1]["truncated"] is True
assert body["truncated"] is True
single = await client.post(
"/api/v2/context/search",
json={
"query": "oversized",
"kinds": ["raw"],
"raw_unit": "turn",
"character_budget": 20_000,
},
)
assert single.json()["items"][0]["truncated"] is True
assert single.json()["truncated"] is True
run_with_sdk(tmp_path, monkeypatch, scenario)
@pytest.mark.fresh_sdk
def test_raw_only_lane_failure_is_unavailable_not_an_empty_partial(tmp_path, monkeypatch) -> None:
async def scenario(app, sdk, client):
app.state.memory_mode = lambda: "lite"
def search(_query, **_kwargs):
raise ValueError("private conversation text must not reach logs")
sdk.search = search
response = await client.post(
"/api/v2/context/search",
json={"query": "3d", "kinds": ["raw"], "raw_unit": "turn"},
)
assert response.status_code == 200, response.text
assert response.json()["state"] == "unavailable"
assert response.json()["items"] == []
run_with_sdk(tmp_path, monkeypatch, scenario)
@pytest.mark.fresh_sdk
def test_every_recall_and_ask_option_reaches_the_sdk(tmp_path, monkeypatch) -> None:
async def scenario(app, sdk, client):
@@ -743,6 +838,24 @@ def test_raw_lists_active_and_shadow_turns_with_roles_and_hashes(tmp_path, monke
assert cursor_page["has_more"] is True
assert cursor_turns == ["我喜欢绿茶", "我叫费天翔", "你好"]
assert len(sdk.all("list_memories")) == cursor_calls + 3
graph_calls = len(sdk.all("list_memories"))
graph = await client.get("/api/v2/raw/graph", params={"limit": 2})
assert graph.status_code == 200, graph.text
assert len(sdk.all("list_memories")) == graph_calls + 1
graph_body = graph.json()
assert graph_body["total"] == 3
assert graph_body["has_more"] is True
assert [item["label"] for item in graph_body["items"]] == [
"[user]: 我喜欢绿茶 [assistant]: 也记住了。",
"[user]: 我叫费天翔 [assistant]: 好的,记住了。",
]
assert (await client.get("/api/v2/raw/graph", params={"limit": 301})).status_code == 422
assert (
await client.get(
"/api/v2/raw/graph",
params={"limit": 2, "offset": 2},
)
).status_code == 422
inserted = {
"memory_id": "raw-new",
"content": "[user]: 新消息",
@@ -754,6 +867,9 @@ def test_raw_lists_active_and_shadow_turns_with_roles_and_hashes(tmp_path, monke
}
RAW_ROWS["local"].insert(0, inserted)
try:
updated_graph = (await client.get("/api/v2/raw/graph")).json()
assert updated_graph["total"] == 4
assert updated_graph["items"][0]["label"] == "[user]: 新消息"
continued = await client.get(
"/api/v2/raw",
params={
@@ -197,6 +197,82 @@ def test_connection_retry_reuses_completed_job_and_checks_root_snapshot(tmp_path
asyncio.run(scenario())
def test_connection_returns_committed_receipt_before_live_activation_finishes(
tmp_path, monkeypatch
):
monkeypatch.setattr("one_memory.model_presets._preset_data", lambda: None)
async def scenario():
home = tmp_path / "home"
root = home / ".claude/projects"
root.mkdir(parents=True)
app = create_app(
Settings(
data_dir=tmp_path / "data",
source_home=home,
api_token=SecretStr("test"),
automatic_memory_processing=False,
live_sync_enabled=False,
),
source_catalog=build_source_catalog(home=home, environment={}),
)
async with (
app.router.lifespan_context(app),
AsyncClient(
transport=ASGITransport(app=app),
base_url="http://test",
headers={"Authorization": "Bearer test"},
) as client,
):
found = await client.post("/api/v1/sources/claude-code/location")
started = asyncio.Event()
release = asyncio.Event()
original = app.state.live_sync_supervisor.initialize_source
async def blocked_initialize(source_id, *, baseline_missing_files):
started.set()
await release.wait()
return await original(
source_id,
baseline_missing_files=baseline_missing_files,
)
monkeypatch.setattr(
app.state.live_sync_supervisor,
"initialize_source",
blocked_initialize,
)
try:
response = await asyncio.wait_for(
client.post(
"/api/v1/sources/connect",
json={
"source_ids": ["claude-code"],
"request_id": "fast-receipt",
"source_roots": {
"claude-code": found.json()["root_id"],
},
"onboarding": True,
"full_rescan": True,
"enable_sync": True,
},
),
timeout=1,
)
assert response.status_code == 200
assert response.json()["request_id"] == "fast-receipt"
assert app.state.config_store.sync_enabled("claude-code")
await asyncio.wait_for(started.wait(), timeout=1)
assert not release.is_set()
finally:
release.set()
tasks = tuple(app.state.source_activation_tasks.values())
if tasks:
await asyncio.gather(*tasks)
asyncio.run(scenario())
@pytest.mark.parametrize(
("turn_count", "age_days", "limited"),
[
@@ -10,6 +10,7 @@ from pydantic import SecretStr
from one_memory import api
from one_memory.api import create_app
from one_memory.config import Settings
from one_memory.imports.sources.registry import RootCandidateKind
from one_memory.sdk_data_plane_migration import MemoryAuthority
from one_memory.sdk_memory_qa import SdkAskMemory, SdkMemoryAnswer
from one_memory.secrets import InMemorySecretStore
@@ -96,8 +97,11 @@ class Sdk:
class Handle:
def __init__(self, sdk=None):
self.sdk = sdk or Sdk()
async def client(self):
return Sdk()
return self.sdk
async def close(self):
return None
@@ -195,3 +199,50 @@ def test_v2_host_calls_are_recorded_and_renderer_calls_are_not(tmp_path, monkeyp
assert listed.json()["total"] == 3
asyncio.run(scenario())
@pytest.mark.fresh_sdk
def test_v2_raw_rechecks_a_source_revoked_during_search(tmp_path):
app = application(tmp_path)
class RevokingRawSdk:
def search(self, query, **kwargs):
app.state.source_grant_store.revoke(user_id="local", source_id="cursor")
return {
"memories": {
"schema": [],
"proactive": [],
"normal": [
{
"memory_id": "raw-1",
"content": "must not escape",
"user_id": "local",
"agent_id": "all-apps",
"session_id": "session-1",
"source": "cursor",
"score": 0.9,
}
],
}
}
async def scenario():
async with app.router.lifespan_context(app), client_for(app, EXTERNAL) as host:
root = tmp_path / "cursor"
root.mkdir()
app.state.source_grant_store.grant(
user_id="local",
source_id="cursor",
root=root,
candidate_kind=RootCandidateKind.DEFAULT,
)
app.state.sdk_client_handle = Handle(RevokingRawSdk())
return await host.post(
"/api/v2/context/search",
json={"query": "private", "kinds": ["raw"]},
)
response = asyncio.run(scenario())
assert response.status_code == 403
assert "must not escape" not in response.text
+57 -9
View File
@@ -72,15 +72,23 @@ it("localizes the whole home live without repeating reads or changing imported t
expect(summary).toHaveBeenCalledTimes(calls);
});
const { summary, projection, processing, sedimentation, history, liveSync } =
vi.hoisted(() => ({
summary: vi.fn(),
projection: vi.fn(),
processing: vi.fn(),
sedimentation: vi.fn(),
history: vi.fn(),
liveSync: vi.fn(),
}));
const {
summary,
projection,
processing,
sedimentation,
history,
liveSync,
evolve,
} = vi.hoisted(() => ({
summary: vi.fn(),
projection: vi.fn(),
processing: vi.fn(),
sedimentation: vi.fn(),
history: vi.fn(),
liveSync: vi.fn(),
evolve: vi.fn(),
}));
vi.mock("./call-history-client", async (original) => ({
...(await original<typeof import("./call-history-client")>()),
getCallHistory: history,
@@ -105,6 +113,7 @@ vi.mock("./api", () => ({
getSdkRaw: vi.fn(),
getLiveSyncHealth: liveSync,
getMemoryProcessing: processing,
getEvolveOverview: evolve,
getSedimentation: sedimentation,
getSources: vi.fn().mockResolvedValue([]),
}));
@@ -146,6 +155,12 @@ beforeEach(() => {
history
.mockReset()
.mockResolvedValue({ available: true, recording_error: false, items: [] });
evolve.mockReset().mockResolvedValue({
mode: "ultra",
schedule: { enabled: true, time: "03:00" },
users: {},
running: [],
});
imports.refresh = vi.fn();
summary.mockReset().mockResolvedValue({
memory_count: 601,
@@ -940,6 +955,39 @@ it.each([
},
);
it("keeps the home snapshot visible during Evolve and refreshes when it ends", async () => {
evolve.mockResolvedValue({
mode: "ultra",
schedule: { enabled: true, time: "03:00" },
users: {},
running: [
{
run_id: "run-1",
user_id: "local",
state: "running",
},
],
});
render(mountHome());
expect(await screen.findByText("601")).toBeTruthy();
expect(await screen.findByText("正在整合记忆…")).toBeTruthy();
const readsBeforeCompletion = projection.mock.calls.length;
act(() => {
queryClient.setQueryData(["evolve", "local"], {
mode: "ultra",
schedule: { enabled: true, time: "03:00" },
users: {},
running: [],
});
});
await waitFor(() =>
expect(projection.mock.calls.length).toBeGreaterThan(readsBeforeCompletion),
);
});
afterEach(() => {
cleanup();
resetClient();
+17
View File
@@ -41,6 +41,7 @@ import {
retryAutoReconcile,
useAutoConnect,
} from "./connection-reconcile";
import { localEvolveRunning, useEvolveState } from "./evolve-state";
import { useGuiLanguage } from "./gui-language";
import { HomeCardBoundary } from "./HomeCardBoundary";
import {
@@ -55,6 +56,7 @@ import {
sourceConnectionIncomplete,
useRecallHosts,
} from "./host-state";
import { useOnboardingTranslation } from "./onboarding-language";
import { subscribePoll } from "./poll-scheduler";
import { R6Button } from "./R6Controls";
import { prefetchDataRuntime } from "./runtime-state";
@@ -209,6 +211,10 @@ export function ApplicationsHome({
active?: boolean;
}) {
const { locale, t, tn } = useGuiLanguage();
const { text } = useOnboardingTranslation();
const evolveState = useEvolveState(active);
const evolveRunning = localEvolveRunning(evolveState.data);
const evolveUncertain = evolveState.isError && evolveRunning;
const hostQuery = useRecallHosts({ poll: active });
const hostBridge = hostBridgeAvailable();
const hosts = hostQuery.data ?? [];
@@ -313,6 +319,12 @@ export function ApplicationsHome({
const [expanded, setExpanded] = useState(retained.expanded);
const [phase, setPhase] = useState("idle");
const [revision, setRevision] = useState(0);
const previousEvolveRunning = useRef(evolveRunning);
useEffect(() => {
const previous = previousEvolveRunning.current;
previousEvolveRunning.current = evolveRunning;
if (previous && !evolveRunning && active) setRevision((value) => value + 1);
}, [active, evolveRunning]);
const confirmedModeSeen = useRef(confirmedMode);
const expandButton = useRef<HTMLButtonElement>(null);
const backButton = useRef<HTMLButtonElement>(null);
@@ -953,6 +965,11 @@ export function ApplicationsHome({
<header>
<h2>{t("apps.overview")}</h2>
</header>
{evolveRunning ? (
<p className="r6-evolve-status" role="status">
{text(evolveUncertain ? "正在核对整合状态…" : "正在整合记忆…")}
</p>
) : null}
<div className="r6-home-metrics">
<div role="status" aria-live="polite" aria-atomic="true">
<p>{t(viewModel.countLabel)}</p>
+121 -105
View File
@@ -46,6 +46,7 @@ export function defaultHostSourceIds(
export type ImportConnectionDraft = {
hostSourceIds: readonly string[];
};
export type ImportSubmissionState = "confirmed" | "checking" | "refused";
export interface FirstImportReviewProps {
sourceIds: readonly string[];
sources: SourceResponse[];
@@ -54,6 +55,7 @@ export interface FirstImportReviewProps {
acceptedJobIds?: readonly string[];
failedSources?: Record<string, string>;
sourceSnapshot?: { confirmedAt: string | null; stale: boolean };
importSubmissionState?: ImportSubmissionState;
busy?: boolean;
error?: string | null;
connectionNote?: string;
@@ -78,6 +80,7 @@ export function FirstImportReview({
acceptedJobIds,
failedSources = {},
sourceSnapshot,
importSubmissionState = "confirmed",
busy = false,
error,
connectionNote,
@@ -263,82 +266,9 @@ export function FirstImportReview({
step={2}
title={text(title)}
footer={
<div className="first-import-r6-footer-actions">
{changing ? (
<R6Button
onClick={() => {
stopBatch.current = true;
}}
>
{text("停止后续操作")}
</R6Button>
) : onChangeJob && (canPause || canResume) ? (
<R6Button
disabled={busy || (stale && !canPause)}
onClick={() => void changeBatch(canPause ? "pause" : "resume")}
>
{canPause ? (
<Pause size={16} aria-hidden="true" />
) : (
<Play size={16} aria-hidden="true" />
)}
{text(canPause ? "暂停导入" : "继续导入")}
</R6Button>
) : null}
{recovery ? (
<>
<R6Button
disabled={busy}
onClick={() => {
stopBatch.current = true;
recovery.onSkip();
}}
>
{text(
recovery.destination === "sources"
? "仍进入应用页"
: recovery.destination === "settings"
? "仍进入设置"
: "仍进入工作台",
)}
</R6Button>
<R6Button
variant="primary"
disabled={busy}
onClick={() => leave(recovery.destination)}
>
{text("重试保存并进入")}
</R6Button>
</>
) : (
<R6Button
variant="primary"
disabled={busy}
onClick={() => leave("settings")}
>
{text("选择记忆模式")}
</R6Button>
)}
</div>
}
>
<div className="first-import-r6-layout">
<section
className="first-import-r6-main"
aria-label={text("导入与原文")}
>
<div className="first-import-r6-intro">
<h2>{text("正在导入你的会话")}</h2>
<p>
{text(
allCompleted
? "本次导入已结束。已保存的对话无需重复导入。"
: "后台将持续为你导入,可以放心进入下一步",
)}
</p>
</div>
{(error || actionMessage) && (
<div className="first-import-r6-feedback">
<>
<div className="first-import-r6-footer-note">
{(error || actionMessage) && (
<p
role={
error?.includes("应用连接") ||
@@ -357,8 +287,87 @@ export function FirstImportReview({
>
{error ? text(error) : actionMessage}
</p>
</div>
)}
)}
</div>
<div className="first-import-r6-footer-actions">
{changing ? (
<R6Button
onClick={() => {
stopBatch.current = true;
}}
>
{text("停止后续操作")}
</R6Button>
) : onChangeJob && (canPause || canResume) ? (
<R6Button
disabled={busy || (stale && !canPause)}
onClick={() => void changeBatch(canPause ? "pause" : "resume")}
>
{canPause ? (
<Pause size={16} aria-hidden="true" />
) : (
<Play size={16} aria-hidden="true" />
)}
{text(canPause ? "暂停导入" : "继续导入")}
</R6Button>
) : null}
{recovery ? (
<>
<R6Button
disabled={busy}
onClick={() => {
stopBatch.current = true;
recovery.onSkip();
}}
>
{text(
recovery.destination === "sources"
? "仍进入应用页"
: recovery.destination === "settings"
? "仍进入设置"
: "仍进入工作台",
)}
</R6Button>
<R6Button
variant="primary"
disabled={busy}
onClick={() => leave(recovery.destination)}
>
{text("重试保存并进入")}
</R6Button>
</>
) : (
<R6Button
variant="primary"
disabled={busy}
onClick={() => leave("settings")}
>
{text("选择记忆模式")}
</R6Button>
)}
</div>
</>
}
>
<div className="first-import-r6-layout">
<section
className="first-import-r6-main"
aria-label={text("导入与原文")}
>
<p className="first-import-r6-intro">
{importSubmissionState === "checking"
? t("onboardingImportChecking")
: text(
allCompleted
? "本次导入已结束。已保存的对话无需重复导入。"
: "对话先保存在本机。你可以同时设置后续连接。",
)}
</p>
<p className="first-import-r6-subintro">
{text(
"导入不会自动开放应用读取权限。配置模型后,才会开始生成记忆。",
)}
</p>
{historyWindowLimited && (
<p className="first-import-r6-history-window" role="status">
{t("onboardingHistoryWindow")}
@@ -370,39 +379,46 @@ export function FirstImportReview({
jobs={batch}
stale={progressStale}
/>
{Object.keys(failedSources).length > 0 && (
<section
className="first-import-r6-failures"
aria-label={text("未开始的导入")}
>
{Object.keys(failedSources).map((id) => (
<p key={id}>
{sources.find((source) => source.source_id === id)
?.display_name ?? id}{" "}
{text("未能开始导入,其他已接受任务不受影响。")}
</p>
))}
<R6Button
variant="quiet"
disabled={busy}
onClick={() => leave("sources")}
<section
className="first-import-r6-feed"
aria-label={text("导入说明")}
>
<h2>{text("正在导入你的会话")}</h2>
<p className="first-import-r6-feed-status">
{text("后台将持续为你导入,可以放心进入下一步")}
</p>
{Object.keys(failedSources).length > 0 && (
<section
className="first-import-r6-failures"
aria-label={text("未开始的导入")}
>
{text("查看未导入的应用")}
</R6Button>
</section>
)}
{Object.keys(failedSources).map((id) => (
<p key={id}>
{sources.find((source) => source.source_id === id)
?.display_name ?? id}{" "}
{text("未能开始导入,其他已接受任务不受影响。")}
</p>
))}
<R6Button
variant="quiet"
disabled={busy}
onClick={() => leave("sources")}
>
{text("查看未导入的应用")}
</R6Button>
</section>
)}
</section>
</section>
<aside
className="first-import-r6-options"
aria-label={text("连接应用")}
>
<div className="first-import-r6-options-head">
<p className="first-import-r6-eyebrow">{text("让记忆继续生长")}</p>
<h2>{text("连接你的应用")}</h2>
<p className="first-import-r6-options-intro">
{text("适用于本次选择且支持的应用。")}
</p>
</div>
<p className="first-import-r6-eyebrow">{text("让记忆继续生长")}</p>
<h2>{text("连接你的应用")}</h2>
<p className="first-import-r6-options-intro">
{text("适用于本次选择且支持的应用。")}
</p>
<SelectionTile
title={text(
hostConnectionAvailable ? "一键接入应用" : "测试实例不接入应用",
+58
View File
@@ -491,6 +491,7 @@ function Host({
onViewHistory,
onConfigureModel = () => {},
libraryView,
raw = false,
}: {
simplified?: boolean;
date?: string | null;
@@ -509,6 +510,7 @@ function Host({
onViewHistory?(): void;
onConfigureModel?(): void;
libraryView?: Parameters<typeof MemoryMap>[0]["libraryView"];
raw?: boolean;
} = {}) {
const [detail, setDetail] = useState<MemoryDetail | null>(null);
const [detailLoad, setDetailLoad] = useState<{
@@ -518,6 +520,7 @@ function Host({
return (
<MemoryMap
simplified={simplified}
contentKind={raw ? "raw" : "memory"}
libraryView={libraryView}
hidden={hidden}
overlayBlocked={overlayBlocked}
@@ -587,6 +590,61 @@ function Host({
/>
);
}
it("windows raw rows and automatically continues near the bottom", () => {
const items = Array.from({ length: 150 }, (_, index) => ({
...memories[0],
memory_id: `raw-${index}`,
content: `原始会话 ${index}`,
}));
const onMore = vi.fn();
const sourceIds = Object.fromEntries(
items.map((item) => [item.memory_id, ["cursor"]]),
);
render(
<Host
simplified
raw
items={items}
libraryView={{
query: "",
source: "",
page: {
items,
total: 1_600,
offset: 0,
limit: 50,
source_ids_by_memory: sourceIds,
},
loading: false,
failed: false,
stale: false,
cursorPaging: true,
hasMore: true,
onQuery: vi.fn(),
onSource: vi.fn(),
resetPage: vi.fn(),
onPage: vi.fn(),
onMore,
onRetry: vi.fn(),
}}
/>,
);
const list = screen.getByRole("list", { name: "原始对话" });
Object.defineProperties(list, {
clientHeight: { configurable: true, value: 500 },
scrollHeight: { configurable: true, value: 17_400 },
scrollTop: { configurable: true, writable: true, value: 16_500 },
});
fireEvent.scroll(list);
expect(onMore).toHaveBeenCalledTimes(1);
expect(document.querySelectorAll("[data-memory-id]").length).toBeLessThan(30);
expect(screen.getByRole("button", { name: "加载更多" }).classList).toContain(
"map-load-more",
);
});
it("does not present an unverified empty page as zero memories", () => {
const page = {
items: [],
+102 -15
View File
@@ -16,6 +16,7 @@ import type {
import type { GuiLocale } from "@one-memory/i18n";
import {
ArrowUp,
ChevronDown,
ChevronLeft,
ChevronRight,
FileText,
@@ -179,6 +180,8 @@ const SCENE_LABELS: Record<SdkMemoryScene, string> = {
working: "工作",
coding: "编码",
};
const RAW_ROW_HEIGHT = 116;
const RAW_LIST_OVERSCAN = 6;
export function MemoryMap(props: Props) {
const { locale, text, count, plain } = useMemoryLanguage();
@@ -257,6 +260,10 @@ export function MemoryMap(props: Props) {
const pane = useRef<HTMLElement>(null);
const memoryList = useRef<HTMLUListElement>(null);
const inspectorBody = useRef<HTMLDivElement>(null);
const [rawListWindow, setRawListWindow] = useState({
start: 0,
end: 20,
});
const openedDefault = useRef(false);
const scrollSelection = useRef(false);
const controlledSource = library
@@ -342,6 +349,33 @@ export function MemoryMap(props: Props) {
),
[active, group, search, serverPaged, axis, groupKey],
);
const rawWindowed = raw && !!library?.cursorPaging && filtered.length > 100;
const visibleMemories = rawWindowed
? filtered.slice(rawListWindow.start, rawListWindow.end)
: filtered;
const rawTopSpace = rawWindowed ? rawListWindow.start * RAW_ROW_HEIGHT : 0;
const rawBottomSpace = rawWindowed
? Math.max(0, filtered.length - rawListWindow.end) * RAW_ROW_HEIGHT
: 0;
const updateRawListWindow = useCallback(
(element: HTMLUListElement) => {
if (!rawWindowed) return;
const first = Math.max(
0,
Math.floor(element.scrollTop / RAW_ROW_HEIGHT) - RAW_LIST_OVERSCAN,
);
const visible = Math.ceil(element.clientHeight / RAW_ROW_HEIGHT);
setRawListWindow({
start: first,
end: Math.min(filtered.length, first + visible + RAW_LIST_OVERSCAN * 2),
});
},
[filtered.length, rawWindowed],
);
useLayoutEffect(() => {
if (rawWindowed && memoryList.current)
updateRawListWindow(memoryList.current);
}, [rawWindowed, updateRawListWindow]);
const projection = useMemo(() => {
// Until the new scope's graph arrives (or after it failed), drawing the
// previous scope's graph would show the wrong source.
@@ -357,7 +391,7 @@ export function MemoryMap(props: Props) {
// left-hand list window. Projecting the graph back down to the current
// 20-row page made "All applications" look like whichever source happened
// to dominate that page. Keyword search is intentionally page-scoped.
if (serverPaged && !library?.query) {
if (serverPaged && (!library?.query || raw)) {
return (
baseGraph ??
projectMap(
@@ -409,6 +443,7 @@ export function MemoryMap(props: Props) {
search,
sources,
serverPaged,
raw,
library?.query,
library?.graphLoading,
library?.graphFailed,
@@ -1026,11 +1061,42 @@ export function MemoryMap(props: Props) {
)}
<ul
ref={memoryList}
className="map-memory-list"
className={`map-memory-list${raw ? " raw" : ""}`}
aria-label={raw ? t("memory:原始对话") : text("地图记忆列表")}
id="memory-pane-memories"
onScroll={(event) => {
const element = event.currentTarget;
updateRawListWindow(element);
if (
library?.cursorPaging &&
library.hasMore &&
!pageTransitioning &&
element.scrollHeight - element.scrollTop - element.clientHeight <
element.clientHeight
)
library.onMore?.();
}}
onWheel={(event) => {
const element = event.currentTarget;
if (
event.deltaY > 0 &&
library?.cursorPaging &&
library.hasMore &&
!pageTransitioning &&
element.scrollHeight - element.scrollTop - element.clientHeight <
8
)
library.onMore?.();
}}
>
{filtered.map((m) => (
{rawTopSpace ? (
<li
className="map-memory-spacer"
style={{ height: rawTopSpace }}
aria-hidden="true"
/>
) : null}
{visibleMemories.map((m) => (
<li key={m.memory_id} data-memory-id={m.memory_id}>
<button
type="button"
@@ -1086,6 +1152,13 @@ export function MemoryMap(props: Props) {
</button>
</li>
))}
{rawBottomSpace ? (
<li
className="map-memory-spacer"
style={{ height: rawBottomSpace }}
aria-hidden="true"
/>
) : null}
</ul>
{library?.failed && (
<div role="alert">
@@ -1136,12 +1209,24 @@ export function MemoryMap(props: Props) {
>
{library.cursorPaging ? (
library.hasMore ? (
<R6Button
<button
type="button"
className="map-load-more"
disabled={pageTransitioning}
aria-label={
pageTransitioning ? text("正在读取…") : text("加载更多")
}
title={
pageTransitioning ? text("正在读取…") : text("加载更多")
}
onClick={() => library.onMore?.()}
>
{pageTransitioning ? text("正在读取…") : text("加载更多")}
</R6Button>
<ChevronDown
size={18}
aria-hidden="true"
className={pageTransitioning ? "loading" : undefined}
/>
</button>
) : (
<span>{text("已加载全部")}</span>
)
@@ -1239,15 +1324,17 @@ export function MemoryMap(props: Props) {
: text("记忆关联")
: label}
</h2>
{props.simplified && (!raw || !projection.nodes.length) && (
<p className="selected-graph-subtitle">
{projection.nodes.length
? text("来源归属")
: raw
? t("sdk.raw.empty")
: text("可在沉淀记忆后查看视图")}
</p>
)}
{props.simplified &&
(!raw || !projection.nodes.length) &&
!(raw && library?.graphLoading) && (
<p className="selected-graph-subtitle">
{projection.nodes.length
? text("来源归属")
: raw
? t("sdk.raw.empty")
: text("可在沉淀记忆后查看视图")}
</p>
)}
</div>
{compact && (activeId || selectedSource) && !inspectorOpen && (
<button
@@ -0,0 +1,70 @@
// @vitest-environment jsdom
import { cleanup, render, screen } from "@testing-library/react";
import userEvent from "@testing-library/user-event";
import { afterEach, expect, it, vi } from "vitest";
import { LanguageProvider } from "./gui-language";
import { MemoryModeCards } from "./MemoryModeCards";
afterEach(cleanup);
it("shows every mode feature and reveals Ultra scheduling after selection", async () => {
const onChange = vi.fn();
const user = userEvent.setup();
const view = (value: "lite" | "pro" | "ultra") => (
<LanguageProvider>
<MemoryModeCards
aria-label="记忆模式"
value={value}
layout="rows"
onChange={onChange}
ultraDetail={
<div>
<strong>每天自动整合</strong>
<small>默认 03:00,可在设置中修改。</small>
</div>
}
/>
</LanguageProvider>
);
const { rerender } = render(view("pro"));
expect(screen.getByRole("radiogroup", { name: "记忆模式" })).toBeTruthy();
expect(screen.getAllByRole("radio")).toHaveLength(3);
expect(screen.getByText("不调用生成模型")).toBeTruthy();
expect(screen.getByText("沉淀高级记忆")).toBeTruthy();
expect(screen.getByText("增强关联召回")).toBeTruthy();
expect(screen.queryByText("每天自动整合")).toBeNull();
await user.click(screen.getByRole("radio", { name: /深度记忆/ }));
expect(onChange).toHaveBeenCalledWith("ultra", expect.any(HTMLInputElement));
rerender(view("ultra"));
expect(screen.getByText("每天自动整合")).toBeTruthy();
});
it("preserves per-mode disabled behavior", () => {
render(
<LanguageProvider>
<MemoryModeCards
aria-label="记忆模式"
value="lite"
disabled={(mode) => mode !== "lite"}
onChange={vi.fn()}
/>
</LanguageProvider>,
);
expect(screen.getByRole("radio", { name: /基础记忆/ })).toHaveProperty(
"disabled",
false,
);
expect(screen.getByRole("radio", { name: /智能记忆/ })).toHaveProperty(
"disabled",
true,
);
expect(screen.getByRole("radio", { name: /深度记忆/ })).toHaveProperty(
"disabled",
true,
);
});
+77 -60
View File
@@ -1,5 +1,5 @@
import type { MemoryPipelineMode } from "@one-memory/contracts";
import { Check } from "lucide-react";
import { Check, Clock3 } from "lucide-react";
import { type ReactNode, useId } from "react";
import { MODE_COPY } from "./memory-mode-ui";
import { useOnboardingTranslation } from "./onboarding-language";
@@ -32,8 +32,11 @@ export function MemoryModeCards({
current,
currentBadge,
disabled = false,
layout = "grid",
describedBy,
onChange,
ultraDetail,
ultraDetailBusy = false,
"aria-label": ariaLabel,
"aria-busy": ariaBusy,
}: {
@@ -41,8 +44,11 @@ export function MemoryModeCards({
current?: MemoryPipelineMode;
currentBadge?: ReactNode;
disabled?: boolean | ((mode: MemoryPipelineMode) => boolean);
layout?: "grid" | "rows";
describedBy?: Partial<Record<MemoryPipelineMode, string | undefined>>;
onChange(mode: MemoryPipelineMode, control: HTMLInputElement): void;
ultraDetail?: ReactNode;
ultraDetailBusy?: boolean;
"aria-label": string;
"aria-busy"?: boolean;
}) {
@@ -53,70 +59,81 @@ export function MemoryModeCards({
return (
<div
className="memory-mode-cards"
className={`memory-mode-cards memory-mode-cards--${layout}`}
role="radiogroup"
aria-label={ariaLabel}
aria-busy={ariaBusy || undefined}
>
{MODES.map((mode) => {
const selected = value === mode.id;
const modeDisabled = unavailable(mode.id);
const localizedTitle = text(MODE_COPY[mode.id].title);
const title = localizedTitle.replace(
/\s*[((](?:lite|pro|ultra)[))]\s*$/i,
"",
);
const features = text(MODE_COPY[mode.id].description).split(/\s*·\s*/);
return (
<div
key={mode.id}
className="memory-mode-card"
data-mode={mode.id}
data-selected={selected || undefined}
data-current={current === mode.id || undefined}
data-disabled={modeDisabled || undefined}
>
<label className="memory-mode-card-choice">
<input
type="radio"
name={name}
value={mode.id}
aria-label={localizedTitle}
checked={selected}
disabled={modeDisabled}
onChange={(event) => onChange(mode.id, event.currentTarget)}
/>
<span className="memory-mode-title">
<span className="memory-mode-index">{mode.index}</span>
<strong>{title}</strong>
<span className="memory-mode-tier">
{mode.id.toUpperCase()}
</span>
{current === mode.id && currentBadge ? (
<span className="memory-mode-current">{currentBadge}</span>
) : null}
<span className="memory-mode-radio" aria-hidden="true">
{selected ? <Check size={13} /> : null}
</span>
</span>
<span className="memory-mode-description">
{text(mode.description)}
</span>
<span className="memory-mode-features">
{features.map((feature) => (
<span key={feature}>
<i aria-hidden="true" />
{text(feature)}
<div className="memory-mode-card-list">
{MODES.map((mode) => {
const selected = value === mode.id;
const modeDisabled = unavailable(mode.id);
const localizedTitle = text(MODE_COPY[mode.id].title);
const title = localizedTitle.replace(
/\s*[((](?:lite|pro|ultra)[))]\s*$/i,
"",
);
const features = text(MODE_COPY[mode.id].description).split(
/\s*·\s*/,
);
return (
<div
key={mode.id}
className="memory-mode-card"
data-mode={mode.id}
data-selected={selected || undefined}
data-current={current === mode.id || undefined}
data-disabled={modeDisabled || undefined}
>
<label className="memory-mode-card-choice">
<input
type="radio"
name={name}
value={mode.id}
aria-label={localizedTitle}
aria-describedby={describedBy?.[mode.id]}
checked={selected}
disabled={modeDisabled}
onChange={(event) => onChange(mode.id, event.currentTarget)}
/>
<span className="memory-mode-title">
<span className="memory-mode-index">{mode.index}</span>
<strong>{title}</strong>
<span className="memory-mode-tier">
{mode.id.toUpperCase()}
</span>
))}
</span>
</label>
{mode.id === "ultra" && selected && ultraDetail ? (
<div className="memory-mode-detail">{ultraDetail}</div>
) : null}
</div>
);
})}
{current === mode.id && currentBadge ? (
<span className="memory-mode-current">{currentBadge}</span>
) : null}
<span className="memory-mode-radio" aria-hidden="true">
{selected ? <Check size={13} /> : null}
</span>
</span>
<span className="memory-mode-description">
{text(mode.description)}
</span>
<span className="memory-mode-features">
{features.map((feature) => (
<span key={feature}>
<i aria-hidden="true" />
{text(feature)}
</span>
))}
</span>
</label>
{mode.id === "ultra" && selected && ultraDetail ? (
<div
className="memory-mode-detail"
aria-busy={ultraDetailBusy || undefined}
>
<Clock3 size={14} aria-hidden="true" />
<div>{ultraDetail}</div>
</div>
) : null}
</div>
);
})}
</div>
</div>
);
}
@@ -22,6 +22,7 @@ import {
setMemoryMode,
} from "./api";
import { MemoryModeSettings } from "./MemoryModeSettings";
import { resetClient } from "./query-client";
vi.mock("./api", () => ({
getMemoryMode: vi.fn(),
@@ -81,6 +82,7 @@ async function chooseMode(
afterEach(() => {
cleanup();
resetClient();
vi.clearAllMocks();
vi.mocked(apiErrorCode).mockReturnValue(null);
vi.mocked(memoryModePlanUnsupported).mockReturnValue(false);
+19 -18
View File
@@ -1,6 +1,4 @@
import type {
EvolveOverview,
EvolveRunReceipt,
EvolveSchedule,
MemoryModeChange,
MemoryModePlan,
@@ -11,7 +9,6 @@ import { legacyMemoryModePlan } from "@one-memory/operations";
import { useCallback, useEffect, useId, useRef, useState } from "react";
import {
apiErrorCode,
getEvolveOverview,
getEvolveSchedule,
getMemoryMode,
getMemoryProcessing,
@@ -25,6 +22,7 @@ import {
commitConfirmedMemoryMode,
issueConfirmedModeTicket,
} from "./confirmed-memory-mode";
import { commitEvolveRun, useEvolveState } from "./evolve-state";
import { MemoryModeCards } from "./MemoryModeCards";
import { BLOCKER_COPY, MemoryModeConfirmDialog } from "./memory-mode-ui";
import { useOnboardingTranslation } from "./onboarding-language";
@@ -47,7 +45,11 @@ export function MemoryModeSettings({
const id = useId();
const [state, setState] = useState<MemoryModeState | null>(null);
const [schedule, setScheduleState] = useState<EvolveSchedule | null>(null);
const [overview, setOverview] = useState<EvolveOverview | null>(null);
const evolveState = useEvolveState();
const overview = evolveState.data ?? null;
const run =
overview?.running.find((candidate) => candidate.user_id === "local") ??
null;
const [pendingMode, setPendingMode] = useState<MemoryPipelineMode | null>(
null,
);
@@ -56,7 +58,6 @@ export function MemoryModeSettings({
const [maintenance, setMaintenance] = useState(false);
const [error, setError] = useState("");
const [readFailed, setReadFailed] = useState(false);
const [run, setRun] = useState<EvolveRunReceipt | null>(null);
const refreshVersion = useRef(0);
const dialogOpener = useRef<HTMLElement | null>(null);
const focusEvolveWhenReady = useRef(false);
@@ -83,11 +84,10 @@ export function MemoryModeSettings({
const refresh = useCallback(async () => {
const version = ++refreshVersion.current;
const modeTicket = issueConfirmedModeTicket("settings");
const [modeResult, scheduleResult, overviewResult, processingResult] =
const [modeResult, scheduleResult, processingResult] =
await Promise.allSettled([
getMemoryMode(),
getEvolveSchedule(),
getEvolveOverview(),
getMemoryProcessing(),
]);
if (version !== refreshVersion.current) return;
@@ -103,10 +103,6 @@ export function MemoryModeSettings({
}
if (scheduleResult.status === "fulfilled")
setScheduleState(scheduleResult.value);
if (overviewResult.status === "fulfilled") {
setOverview(overviewResult.value);
setRun(overviewResult.value.running[0] ?? null);
}
if (processingResult.status === "fulfilled")
setMaintenance(Boolean(processingResult.value.embedding_switch));
}, [onModeChange]);
@@ -116,14 +112,11 @@ export function MemoryModeSettings({
}, [refresh]);
useEffect(() => {
const running =
maintenance ||
state?.graph_build.state === "running" ||
run?.state === "running";
const running = maintenance || state?.graph_build.state === "running";
if (!running) return;
const timer = window.setInterval(() => void refresh(), 1500);
return () => window.clearInterval(timer);
}, [maintenance, refresh, run?.state, state?.graph_build.state]);
}, [maintenance, refresh, state?.graph_build.state]);
const lifecycleBusy =
maintenance ||
@@ -202,13 +195,21 @@ export function MemoryModeSettings({
}
async function evolveNow() {
if (!state || !schedule) return;
refreshVersion.current++;
setBusy(true);
setError("");
try {
const receipt = await startEvolve();
setRun(receipt);
await refresh();
commitEvolveRun(
receipt,
overview ?? {
mode: state.mode,
schedule,
users: {},
running: [],
},
);
} catch (reason) {
const code = apiErrorCode(reason);
setError(
+216 -1
View File
@@ -12,6 +12,7 @@ import userEvent from "@testing-library/user-event";
import { afterEach, expect, it, vi } from "vitest";
import { LANGUAGE_STORAGE_KEY, LanguageProvider } from "./gui-language";
import { MemorySpace } from "./MemorySpace";
import { queryClient, resetClient } from "./query-client";
it("translates memory filters and detail affordances live without refetching", async () => {
setup();
@@ -77,7 +78,9 @@ const { reads } = vi.hoisted(() => ({
processing: vi.fn(),
profiles: vi.fn(),
raw: vi.fn(),
rawGraph: vi.fn(),
rawSearch: vi.fn(),
evolve: vi.fn(),
},
}));
vi.mock("./api", async (importOriginal) => ({
@@ -87,7 +90,9 @@ vi.mock("./api", async (importOriginal) => ({
getMemoryProcessing: reads.processing,
getModelProfiles: reads.profiles,
getSdkRaw: reads.raw,
getSdkRawGraph: reads.rawGraph,
searchSdkRaw: reads.rawSearch,
getEvolveOverview: reads.evolve,
}));
vi.mock("./GraphWidthPrompt", () => ({ GraphWidthPrompt: () => null }));
vi.mock("./MemoryAddComposer", () => ({
@@ -117,10 +122,16 @@ vi.mock("./MemoryAddComposer", () => ({
),
}));
vi.mock("./MemoryGraph3D", () => ({
MemoryGraph3D: () => <div data-testid="graph-owner" />,
MemoryGraph3D: ({ graph }: { graph?: { nodes?: unknown[] } | null }) => (
<div
data-testid="graph-owner"
data-graph-nodes={graph?.nodes?.length ?? 0}
/>
),
}));
afterEach(() => {
cleanup();
resetClient();
vi.useRealTimers();
vi.clearAllMocks();
});
@@ -154,7 +165,94 @@ it("translates the model-required prompt without configuring a model", async ()
localStorage.clear();
});
it("keeps the memory snapshot visible during Evolve and refreshes when it ends", async () => {
setup();
reads.processing.mockResolvedValue(modeProcessing("ultra"));
reads.evolve.mockResolvedValue({
mode: "ultra",
schedule: { enabled: true, time: "03:00" },
users: {},
running: [
{
run_id: "run-1",
user_id: "local",
state: "running",
},
],
});
render(<MemorySpace simplified />);
expect((await screen.findAllByText("已保存的记忆")).length).toBeGreaterThan(
0,
);
expect(await screen.findByText("正在整合记忆…")).toBeTruthy();
const readsBeforeCompletion = reads.sdkLibrary.mock.calls.length;
act(() => {
queryClient.setQueryData(["evolve", "local"], {
mode: "ultra",
schedule: { enabled: true, time: "03:00" },
users: {},
running: [],
});
});
await waitFor(() =>
expect(reads.sdkLibrary.mock.calls.length).toBeGreaterThan(
readsBeforeCompletion,
),
);
});
it("keeps the last successful graph when its Evolve refresh fails", async () => {
setup();
reads.processing.mockResolvedValue(modeProcessing("ultra"));
reads.evolve.mockResolvedValue({
mode: "ultra",
schedule: { enabled: true, time: "03:00" },
users: {},
running: [{ run_id: "run-1", user_id: "local", state: "running" }],
});
const firstSource = {
source_id: "cursor",
display_name: "Cursor",
authorized: true,
sync_enabled: true,
imported_messages: 1,
imported_conversations: 1,
} as SourceResponse;
const view = render(<MemorySpace simplified sources={[firstSource]} />);
await waitFor(() =>
expect(screen.getByTestId("graph-owner").dataset.graphNodes).toBe("1"),
);
reads.sdkGraph.mockRejectedValueOnce(new Error("evolve write lock"));
view.rerender(
<MemorySpace
simplified
sources={[
firstSource,
{
...firstSource,
source_id: "codex",
display_name: "Codex",
},
]}
/>,
);
await waitFor(() => expect(reads.sdkGraph).toHaveBeenCalledTimes(2));
expect(screen.getByTestId("graph-owner").dataset.graphNodes).toBe("1");
});
function setup() {
reads.evolve.mockResolvedValue({
mode: "ultra",
schedule: { enabled: true, time: "03:00" },
users: {},
running: [],
});
const memories = [
{
memory_id: "m1",
@@ -208,6 +306,24 @@ function setup() {
limit: 100,
offset: 0,
});
reads.rawGraph.mockResolvedValue({
user_id: "local",
items: [
{
turn_id: "raw-1",
raw_id: "raw-1",
label: "原始对话内容",
source: "cursor",
session_id: "session-1",
memory_at: null,
gmt_created: 1_761_000_000,
},
],
total: 1,
limit: 300,
offset: 0,
has_more: false,
});
reads.rawSearch.mockResolvedValue([]);
}
@@ -321,6 +437,94 @@ it("shows raw conversations instead of advanced memory in lite", async () => {
expect(document.querySelector(".memory-mode-notice")).toBeNull();
});
it("loads one bounded raw graph page without claiming an exact turn total", async () => {
setup();
reads.processing.mockResolvedValue(modeProcessing("lite"));
reads.rawGraph.mockResolvedValue({
user_id: "local",
items: Array.from({ length: 300 }, (_, index) => ({
turn_id: `turn-${index}`,
raw_id: `raw-${index}`,
label: `原始会话 ${index}`,
source: "cursor",
session_id: `session-${index}`,
memory_at: null,
gmt_created: 1_761_000_000,
})),
total: 1_600,
limit: 300,
offset: 0,
has_more: true,
});
render(<MemorySpace simplified />);
await waitFor(() => expect(reads.rawGraph).toHaveBeenCalledTimes(1));
expect(reads.rawGraph).toHaveBeenCalledWith({
source: undefined,
offset: 0,
limit: 300,
signal: expect.any(AbortSignal),
});
expect(screen.getByTestId("graph-owner").dataset.graphNodes).toBe("301");
expect(document.querySelector(".memory-loaded-count")?.textContent).not.toBe(
"1,600 条原始对话",
);
expect(screen.queryByText("1,600 / 1,600")).toBeNull();
});
it("keeps the last successful Lite graph when a background refresh fails", async () => {
setup();
reads.processing.mockResolvedValue(modeProcessing("lite"));
const source = {
source_id: "cursor",
display_name: "Cursor",
authorized: true,
imported_messages: 1,
imported_conversations: 1,
} as SourceResponse;
const view = render(<MemorySpace simplified sources={[source]} />);
await waitFor(() =>
expect(screen.getByTestId("graph-owner").dataset.graphNodes).toBe("2"),
);
reads.rawGraph.mockRejectedValueOnce(new Error("refresh failed"));
view.rerender(
<MemorySpace
simplified
sources={[
source,
{
...source,
source_id: "codex",
display_name: "Codex",
},
]}
/>,
);
await waitFor(() => expect(reads.rawGraph).toHaveBeenCalledTimes(2));
expect(screen.getByTestId("graph-owner").dataset.graphNodes).toBe("2");
expect(screen.queryByText("原始对话暂时无法读取。")).toBeNull();
});
it("does not request the raw graph for a keyword search", async () => {
setup();
reads.processing.mockResolvedValue(modeProcessing("lite"));
reads.rawSearch.mockResolvedValue([]);
const user = userEvent.setup();
render(<MemorySpace simplified />);
await waitFor(() => expect(reads.rawGraph).toHaveBeenCalledTimes(1));
await user.type(
screen.getByRole("textbox", { name: "搜索原始对话" }),
"发布计划",
);
await user.click(screen.getByRole("button", { name: "搜索" }));
await waitFor(() => expect(reads.rawSearch).toHaveBeenCalledTimes(1));
expect(reads.rawGraph).toHaveBeenCalledTimes(1);
});
it("pages and filters the raw conversation data plane in lite", async () => {
setup();
reads.processing.mockResolvedValue(modeProcessing("lite"));
@@ -368,9 +572,12 @@ it("pages and filters the raw conversation data plane in lite", async () => {
);
expect(await screen.findAllByText("原始会话 all-first")).toHaveLength(2);
await waitFor(() => expect(reads.rawGraph).toHaveBeenCalledTimes(1));
const graphCalls = reads.rawGraph.mock.calls.length;
await user.click(screen.getByRole("button", { name: "加载更多" }));
expect(await screen.findAllByText("原始会话 all-more")).toHaveLength(1);
expect(screen.getAllByText("原始会话 all-first")).toHaveLength(2);
expect(reads.rawGraph).toHaveBeenCalledTimes(graphCalls);
expect(reads.raw).toHaveBeenLastCalledWith(
expect.objectContaining({
source: undefined,
@@ -620,6 +827,14 @@ it("opens a search hit with the complete role transcript", async () => {
truncated: true,
},
]);
reads.rawGraph.mockResolvedValue({
user_id: "local",
items: [],
total: 1_600,
limit: 300,
offset: 0,
has_more: false,
});
const user = userEvent.setup();
render(<MemorySpace simplified />);
@@ -289,7 +289,7 @@ it("keeps rows, sources, count and graph on the one filtered page", async () =>
expect(await screen.findByText(/566/)).toBeTruthy();
});
it("hides the previous source graph until the all-applications graph settles", async () => {
it("loads each source graph independently from list pagination and search", async () => {
sdkAuthority();
render(<MemorySpace simplified active />);
await waitFor(() =>
@@ -415,17 +415,13 @@ it("hides the previous source graph until the all-applications graph settles", a
);
const graphCalls = api.sdkGraph.mock.calls.length;
fireEvent.click(screen.getByText("search deploy"));
await waitFor(() =>
expect(screen.getByTestId("map-graph-loading").textContent).toBe("true"),
);
expect(screen.getByTestId("map-nodes").textContent).toBe("none");
expect(screen.getByTestId("map-graph-loading").textContent).toBe("false");
expect(screen.getByTestId("map-nodes").textContent).toBe("4");
expect(screen.getByTestId("map-memories").textContent).toBe("");
expect(api.sdkGraph).toHaveBeenCalledTimes(graphCalls);
await act(async () => releaseSearch(searchPage));
await waitFor(() =>
expect(screen.getByTestId("map-graph-loading").textContent).toBe("false"),
);
expect(screen.getByTestId("map-graph-loading").textContent).toBe("false");
});
it("opens an SDK memory in place instead of switching away from the map", async () => {
+152 -73
View File
@@ -38,15 +38,18 @@ import {
getSdkMemoryGraph,
getSdkMemoryLibrary,
getSdkRaw,
getSdkRawGraph,
manualExtract,
searchSdkRaw,
setExtractionWindow,
updateSdkMemory,
} from "./api";
import { localEvolveRunning, useEvolveState } from "./evolve-state";
import { GraphWidthPrompt } from "./GraphWidthPrompt";
import { useGuiLanguage } from "./gui-language";
import { MemoryDeleteDialog } from "./MemoryDeleteDialog";
import { MemoryMap } from "./MemoryMap";
import { useOnboardingTranslation } from "./onboarding-language";
import { subscribePoll } from "./poll-scheduler";
import { SdkMemoryLibrary } from "./SdkMemoryLibrary";
import { SdkRawHistory } from "./SdkRawHistory";
@@ -60,9 +63,12 @@ import {
import {
type RawMapRecord,
rawDetail,
rawGraphProjection,
rawGraphRecord,
rawLibraryPage,
rawRecordId,
rawSearchPage,
SDK_RAW_PAGE_SIZE,
} from "./sdk-raw-map";
import { sourceNameOr, UNATTRIBUTED_SOURCE_ID } from "./source-label";
import { traceUiAction } from "./ui-trace";
@@ -114,6 +120,10 @@ export function MemorySpace({
onFocusMemoryConsumed?(): void;
}) {
const { locale, t, tn } = useGuiLanguage();
const { text } = useOnboardingTranslation();
const evolveState = useEvolveState(active || preload);
const evolveRunning = localEvolveRunning(evolveState.data);
const evolveUncertain = evolveState.isError && evolveRunning;
const memoryText = (text: string) => {
const key = `memory:${text}`;
return isMessageKey(key) ? t(key) : text;
@@ -165,6 +175,8 @@ export function MemorySpace({
source.grant_revision,
]),
);
const sourcesRef = useRef(sources);
sourcesRef.current = sources;
const management = simplified && displayedMode === "library";
useEffect(() => {
viewActive.current = active;
@@ -191,6 +203,12 @@ export function MemorySpace({
// Bumping this re-reads the page and graph after an edit or a delete.
const [sdkMapRevision, setSdkMapRevision] = useState(0);
const bumpSdkMap = useCallback(() => setSdkMapRevision((v) => v + 1), []);
const previousEvolveRunning = useRef(evolveRunning);
useEffect(() => {
const previous = previousEvolveRunning.current;
previousEvolveRunning.current = evolveRunning;
if (previous && !evolveRunning) bumpSdkMap();
}, [bumpSdkMap, evolveRunning]);
const [sdkLibrary, setSdkLibrary] = useState<{
page: MemoryLibraryPage | null;
loading: boolean;
@@ -239,18 +257,17 @@ export function MemorySpace({
sdkLibrary.settledQuery.trim() === requestedQuery &&
sdkLibrary.page?.offset === sdkLibraryOffset;
const sdkPage = sdkPageSettled ? sdkLibrary.page : null;
const sdkRequestedScope = JSON.stringify([
const sdkGraphRequestedScope = JSON.stringify([
pipelineMode,
sdkLibraryQuery.trim(),
sdkLibrarySource,
]);
const sdkGraphSettled = sdkGraph?.scope === sdkRequestedScope;
const sdkGraphSettled = sdkGraph?.scope === sdkGraphRequestedScope;
const visibleSdkGraph =
sdkGraph?.scope === sdkRequestedScope && sdkGraph.status === "ready"
? sdkGraph.value
: null;
sdkGraph?.scope === sdkGraphRequestedScope ? sdkGraph.value : null;
const sdkGraphFailed =
sdkGraph?.scope === sdkRequestedScope && sdkGraph.status === "failed";
sdkGraph?.scope === sdkGraphRequestedScope &&
sdkGraph.status === "failed" &&
sdkGraph.value === null;
// A keyword search pages through one server-side result window. The id is
// kept for the query and source it was searched with; a new query or
// source starts without one, so the server searches afresh.
@@ -295,61 +312,37 @@ export function MemorySpace({
rawRecords.current = next;
rawRecordsScope.current = windowKey;
};
const [page, graph] = lite
? await Promise.all([
query
? searchSdkRaw(query, {
source,
signal: request.signal,
}).then((items) => {
rememberRaw(items);
return rawSearchPage(items);
})
: getSdkRaw({
source,
offset: 0,
limit: SDK_MEMORY_PAGE_SIZE,
paging: "cursor",
cursor: sdkRawCursor,
signal: request.signal,
}).then((rawPage) => {
rememberRaw(rawPage.items);
return rawLibraryPage(rawPage);
}),
Promise.resolve({
status: "ready" as const,
value: null,
}),
])
: await Promise.all([
getSdkMemoryLibrary(
{
statuses: ["active"],
source,
query,
offset: sdkLibraryOffset,
limit: SDK_MEMORY_PAGE_SIZE,
windowId: query ? sdkSearchWindow.current.id : null,
},
request.signal,
).then(sdkLibraryPage),
query
? Promise.resolve({
status: "ready" as const,
value: null,
})
: getSdkMemoryGraph(
{
source,
offset: 0,
limit: 300,
},
request.signal,
).then(
(value) => ({ status: "ready" as const, value }),
() => ({ status: "failed" as const, value: null }),
),
]);
const page = lite
? query
? await searchSdkRaw(query, {
source,
signal: request.signal,
}).then((items) => {
rememberRaw(items);
return rawSearchPage(items);
})
: await getSdkRaw({
source,
offset: 0,
limit: SDK_RAW_PAGE_SIZE,
paging: "cursor",
cursor: sdkRawCursor,
signal: request.signal,
}).then((rawPage) => {
rememberRaw(rawPage.items);
return rawLibraryPage(rawPage);
})
: await getSdkMemoryLibrary(
{
statuses: ["active"],
source,
query,
offset: sdkLibraryOffset,
limit: SDK_MEMORY_PAGE_SIZE,
windowId: query ? sdkSearchWindow.current.id : null,
},
request.signal,
).then(sdkLibraryPage);
if (cancelled) return;
if (!lite && sdkSearchWindow.current.key === windowKey)
sdkSearchWindow.current = {
@@ -405,7 +398,6 @@ export function MemorySpace({
settledMode: pipelineMode,
};
});
setSdkGraph({ scope: windowKey, ...graph });
} catch (reason) {
if (cancelled || request.signal.aborted) return;
if (
@@ -443,6 +435,73 @@ export function MemorySpace({
lite,
pipelineMode,
]);
const rawGraphRecords = useRef(new Map<string, RawMapRecord>());
useEffect(() => {
if (!sdkMapVisible) return;
void sdkMapRevision;
void sourceKey;
const scope = sdkGraphRequestedScope;
const source = sdkLibrarySource || undefined;
const request = new AbortController();
let cancelled = false;
let reading = false;
const readGraph = async () => {
if (reading) return;
reading = true;
try {
if (!lite) {
const value = await getSdkMemoryGraph(
{ source, offset: 0, limit: 300 },
request.signal,
);
if (cancelled) return;
setSdkGraph({ scope, status: "ready", value });
return;
}
const page = await getSdkRawGraph({
source,
offset: 0,
limit: 300,
signal: request.signal,
});
if (cancelled) return;
rawGraphRecords.current.clear();
for (const item of page.items) {
const record = rawGraphRecord(item);
rawGraphRecords.current.set(rawRecordId(record), record);
}
setSdkGraph({
scope,
status: "ready",
value: rawGraphProjection(page.items, page.total, sourcesRef.current),
});
} catch {
if (cancelled || request.signal.aborted) return;
setSdkGraph((current) => ({
scope,
status: "failed",
value: current?.scope === scope ? current.value : null,
}));
} finally {
reading = false;
}
};
void readGraph();
const timer = setInterval(() => void readGraph(), 30_000);
return () => {
cancelled = true;
clearInterval(timer);
request.abort();
};
}, [
lite,
sdkGraphRequestedScope,
sdkLibrarySource,
sdkMapRevision,
sdkMapVisible,
sourceKey,
]);
const displayedSdkPage = sdkPage;
const sdkFilterLabel = [
sdkLibrary.settledSource
? `${t("memory:来源")} ${
@@ -572,10 +631,13 @@ export function MemorySpace({
}, [active, onArrivalChange]);
const requestIds = useRef(new Map<string, string>());
const detailEpoch = useRef(0);
const rawDetailController = useRef<AbortController | null>(null);
const answerEpoch = useRef(0);
const answerMemories = useRef<AskMemory[]>([]);
const closeDetail = useCallback(() => {
detailEpoch.current++;
rawDetailController.current?.abort();
rawDetailController.current = null;
setDetail(null);
setSelectedRaw(null);
setDetailLoad(null);
@@ -650,6 +712,8 @@ export function MemorySpace({
cancelled = true;
stopProcessing();
detailEpoch.current++;
rawDetailController.current?.abort();
rawDetailController.current = null;
answerEpoch.current++;
deleteAnswer.current?.(false);
deleteAnswer.current = null;
@@ -761,11 +825,15 @@ export function MemorySpace({
async function open(memoryId: string) {
if (simplified && (!viewActive.current || stale.current)) return;
const epoch = ++detailEpoch.current;
rawDetailController.current?.abort();
rawDetailController.current = null;
setError(null);
setDetail(null);
setSelectedRaw(null);
if (lite) {
let record = rawRecords.current.get(memoryId);
let record =
rawRecords.current.get(memoryId) ??
rawGraphRecords.current.get(memoryId);
if (!record) {
setDetailLoad({
memoryId,
@@ -776,6 +844,8 @@ export function MemorySpace({
}
if ("kind" in record) {
setDetailLoad({ memoryId, status: "loading" });
const request = new AbortController();
rawDetailController.current = request;
try {
let offset = 0;
while (true) {
@@ -784,6 +854,7 @@ export function MemorySpace({
sessionId: record.session_id || undefined,
offset,
limit: 500,
signal: request.signal,
});
const full = page.items.find(
(item) => rawRecordId(item) === memoryId,
@@ -798,15 +869,18 @@ export function MemorySpace({
throw new Error("raw_not_found");
}
} catch {
if (epoch === detailEpoch.current)
if (epoch === detailEpoch.current) {
rawDetailController.current = null;
setDetailLoad({
memoryId,
status: "failed",
error: t("sdk.raw.loadFailed"),
});
}
return;
}
if (epoch !== detailEpoch.current) return;
rawDetailController.current = null;
}
setSelectedRaw(record);
setDetail(rawDetail(record));
@@ -961,12 +1035,12 @@ export function MemorySpace({
? pipelineMode === null
? t("memory:记忆暂时无法读取")
: lite
? sdkPage
? sdkPage.total_exact === false
? displayedSdkPage
? displayedSdkPage.total_exact === false
? t("memory:原始对话")
: sdkLibrary.settledQuery.trim()
? tn("sdk.raw.found", sdkPage.total)
: tn("sdk.raw.total", sdkPage.total)
? tn("sdk.raw.found", displayedSdkPage.total)
: tn("sdk.raw.total", displayedSdkPage.total)
: sdkLibrary.failed
? null
: t("memory:正在读取…")
@@ -1074,6 +1148,11 @@ export function MemorySpace({
</div>
)}
</header>
{evolveRunning ? (
<p className="memory-evolve-status" role="status">
{text(evolveUncertain ? "正在核对整合状态…" : "正在整合记忆…")}
</p>
) : null}
{processing && (!simplified || managementOpen) ? (
<details className="memory-processing-details">
<summary>
@@ -1257,11 +1336,11 @@ export function MemorySpace({
? {
query: sdkLibraryQuery,
source: sdkLibrarySource,
page: sdkPage,
page: displayedSdkPage,
loading: sdkLibrary.loading,
failed: sdkLibrary.failed,
failed: sdkLibrary.failed && !evolveRunning,
graphLoading: !sdkGraphSettled,
graphFailed: sdkGraphFailed,
graphFailed: sdkGraphFailed && !evolveRunning,
stale: false,
settledSource: sdkLibrary.settledSource,
settledQuery: sdkLibrary.settledQuery,
@@ -1289,7 +1368,7 @@ export function MemorySpace({
displayedMode !== "map" ||
(simplified && pipelineMode === null)
}
memories={sdkPage?.items ?? []}
memories={displayedSdkPage?.items ?? []}
graph={visibleSdkGraph}
sources={sources}
processing={processing}
@@ -156,7 +156,7 @@ it("moves from import to the actual third step without completing onboarding", a
);
await userEvent.click(screen.getByRole("button", { name: "进入第三步" }));
await userEvent.click(screen.getByRole("radio", { name: /pro/i }));
await userEvent.click(screen.getByRole("button", { name: "继续配置模型" }));
await userEvent.click(screen.getByRole("button", { name: "配置模型" }));
expect(screen.getByRole("heading", { name: "第三步模型页面" })).toBeTruthy();
expect(completeOnboarding).not.toHaveBeenCalled();
expect(finish).not.toHaveBeenCalled();
+77 -55
View File
@@ -240,7 +240,7 @@ async function continueImport(user: ReturnType<typeof userEvent.setup>) {
async function openModelStep(user: ReturnType<typeof userEvent.setup>) {
await user.click(screen.getByRole("button", { name: "选择记忆模式" }));
await selectMode(user, /pro/i);
await user.click(screen.getByRole("button", { name: "继续配置模型" }));
await user.click(screen.getByRole("button", { name: "配置模型" }));
}
async function chooseLiteAndContinue(user: ReturnType<typeof userEvent.setup>) {
await selectMode(user, /lite/i);
@@ -467,9 +467,10 @@ describe("OnboardingFlow S1.3", () => {
name: "对话正在汇入,记忆从这里开始",
}),
).toBeTruthy();
expect(screen.getByRole("alert").textContent).toContain(
"开始导入的结果尚未确认",
);
expect(screen.queryByRole("alert")).toBeNull();
expect(
screen.getByText("正在核对导入结果,已保存的选择不会丢失"),
).toBeTruthy();
expect(
screen.queryByRole("heading", { name: "把熟悉的背景,带到这里" }),
).toBeNull();
@@ -552,11 +553,11 @@ describe("OnboardingFlow S1.3", () => {
modelBoundary,
);
await user.click(screen.getByRole("button", { name: "返回导入" }));
await waitFor(() =>
expect(screen.getByRole("alert").textContent).toContain(
"开始导入的结果尚未确认",
),
);
await waitFor(() => {
expect(
screen.getByText("正在核对导入结果,已保存的选择不会丢失"),
).toBeTruthy();
});
expect(screen.getByRole("heading", { name: "选择记忆模式" })).toBeTruthy();
});
@@ -687,7 +688,7 @@ describe("OnboardingFlow S1.3", () => {
await openApps();
await user.click(screen.getByRole("button", { name: SKIP }));
await selectMode(user, /pro/i);
await user.click(screen.getByRole("button", { name: "继续配置模型" }));
await user.click(screen.getByRole("button", { name: "配置模型" }));
const receipt = screen.getByRole("button", {
name: "模型启用回执已确认",
});
@@ -1030,6 +1031,12 @@ describe("OnboardingFlow S1.3", () => {
await user.click(screen.getByRole("button", { name: SKIP }));
expect(screen.getByRole("heading", { name: "选择记忆模式" })).toBeTruthy();
expect(
screen.getByRole("heading", {
name: "你希望 MemHub 如何形成记忆?",
}),
).toBeTruthy();
expect(screen.queryByText("Choose how MemHub remembers")).toBeNull();
expect(screen.getByText("之后可在设置中更改。")).toBeTruthy();
expect(screen.getAllByRole("radio")).toHaveLength(3);
expect(screen.getByRole("radio", { name: /ultra/i })).toBeTruthy();
@@ -1042,7 +1049,7 @@ describe("OnboardingFlow S1.3", () => {
await selectMode(user, /lite/i);
expect(screen.getByRole("button", { name: "使用 Lite 进入" })).toBeTruthy();
await selectMode(user, /pro/i);
expect(screen.getByRole("button", { name: "继续配置模型" })).toBeTruthy();
expect(screen.getByRole("button", { name: "配置模型" })).toBeTruthy();
});
it("configures Ultra scheduling before model setup and completes after activation", async () => {
@@ -1082,10 +1089,19 @@ describe("OnboardingFlow S1.3", () => {
await openApps();
await user.click(screen.getByRole("button", { name: SKIP }));
await user.click(screen.getByRole("radio", { name: /ultra/i }));
expect(screen.getByText("每天自动整合")).toBeTruthy();
const ultra = screen.getByRole("radio", { name: /ultra/i });
await user.click(ultra);
const schedule = screen.getByText("每天自动整合");
expect(
schedule.closest(".memory-mode-card")?.getAttribute("data-mode"),
).toBe("ultra");
expect(schedule.closest("label")).toBeNull();
expect(ultra.getAttribute("aria-describedby")).toBe(
"onboarding-ultra-schedule-description",
);
expect(screen.getByText(/每天 03:00 左右自动整合/)).toBeTruthy();
await user.click(screen.getByRole("button", { name: "继续" }));
expect(screen.getByText("后续可在设置页自定义时间或关闭。")).toBeTruthy();
await user.click(screen.getByRole("button", { name: "配置模型" }));
expect(screen.getByRole("region", { name: "模型步骤边界" })).toBeTruthy();
expect(putOnboardingProgress).toHaveBeenCalledWith(
expect.objectContaining({
@@ -1148,14 +1164,15 @@ describe("OnboardingFlow S1.3", () => {
}),
);
await waitFor(() =>
expect(screen.getByRole("button", { name: "继续" })).toHaveProperty(
expect(screen.getByRole("button", { name: "配置模型" })).toHaveProperty(
"disabled",
false,
),
);
expect(screen.getByText(/重新开启后仍按 05:30 运行/)).toBeTruthy();
expect(screen.queryByText("后续可在设置页自定义时间或关闭。")).toBeNull();
await user.click(screen.getByRole("button", { name: "继续" }));
await user.click(screen.getByRole("button", { name: "配置模型" }));
await waitFor(() =>
expect(finish).toHaveBeenCalledExactlyOnceWith("sources"),
);
@@ -1189,7 +1206,7 @@ describe("OnboardingFlow S1.3", () => {
await openApps();
await user.click(screen.getByRole("button", { name: SKIP }));
await selectMode(user, /ultra/i);
await user.click(screen.getByRole("button", { name: "继续" }));
await user.click(screen.getByRole("button", { name: "配置模型" }));
expect((await screen.findByRole("alert")).textContent).toContain(
"自动整合设置未能保存",
);
@@ -1198,7 +1215,7 @@ describe("OnboardingFlow S1.3", () => {
screen.getByRole<HTMLInputElement>("radio", { name: /ultra/i }).checked,
).toBe(true);
await user.click(screen.getByRole("button", { name: "继续" }));
await user.click(screen.getByRole("button", { name: "配置模型" }));
await waitFor(() =>
expect(finish).toHaveBeenCalledExactlyOnceWith("sources"),
);
@@ -1206,9 +1223,14 @@ describe("OnboardingFlow S1.3", () => {
});
it("retries a failed Ultra schedule read without losing the selection", async () => {
let resolveRetry!: (value: { enabled: boolean; time: string }) => void;
vi.mocked(api.getEvolveSchedule)
.mockRejectedValueOnce(new Error("offline"))
.mockResolvedValueOnce({ enabled: true, time: "04:15" });
.mockReturnValueOnce(
new Promise((resolve) => {
resolveRetry = resolve;
}),
);
const user = userEvent.setup();
render(
<OnboardingFlow
@@ -1224,19 +1246,35 @@ describe("OnboardingFlow S1.3", () => {
expect(screen.getByRole("alert").textContent).toContain(
"记忆模式状态暂时无法读取",
);
expect(screen.getByRole("button", { name: "继续" })).toHaveProperty(
expect(screen.getByRole("button", { name: "配置模型" })).toHaveProperty(
"disabled",
true,
);
await user.click(screen.getByRole("button", { name: "重试" }));
const retry = screen.getByRole("button", { name: "重试" });
expect(retry.closest(".memory-mode-card")?.getAttribute("data-mode")).toBe(
"ultra",
);
expect(retry.closest("label")).toBeNull();
retry.focus();
await user.click(retry);
expect(screen.getByRole("button", { name: "重试" })).toHaveProperty(
"disabled",
true,
);
expect(
retry.closest(".memory-mode-detail")?.getAttribute("aria-busy"),
).toBe("true");
expect(document.activeElement).toBe(retry);
expect(api.getEvolveSchedule).toHaveBeenCalledTimes(2);
await act(async () => resolveRetry({ enabled: true, time: "04:15" }));
await waitFor(() =>
expect(screen.getByText(/每天 04:15 左右自动整合/)).toBeTruthy(),
);
expect(
screen.getByRole<HTMLInputElement>("radio", { name: /ultra/i }).checked,
).toBe(true);
expect(screen.getByRole("button", { name: "继续" })).toHaveProperty(
expect(screen.getByRole("button", { name: "配置模型" })).toHaveProperty(
"disabled",
false,
);
@@ -1429,7 +1467,7 @@ describe("OnboardingFlow S1.3", () => {
await openApps();
await user.click(screen.getByRole("button", { name: SKIP }));
await selectMode(user, /pro/i);
await user.click(screen.getByRole("button", { name: "继续配置模型" }));
await user.click(screen.getByRole("button", { name: "配置模型" }));
await user.click(
screen.getByRole("button", { name: "模型启用回执已确认" }),
);
@@ -1483,7 +1521,7 @@ describe("OnboardingFlow S1.3", () => {
await openApps();
await user.click(screen.getByRole("button", { name: SKIP }));
await selectMode(user, /pro/i);
await user.click(screen.getByRole("button", { name: "继续配置模型" }));
await user.click(screen.getByRole("button", { name: "配置模型" }));
await user.click(
screen.getByRole("button", { name: "模型启用回执已确认" }),
);
@@ -1491,7 +1529,7 @@ describe("OnboardingFlow S1.3", () => {
await user.click(screen.getByRole("button", { name: "返回导入" }));
modelActive = false;
await user.click(screen.getByRole("button", { name: "继续配置模型" }));
await user.click(screen.getByRole("button", { name: "配置模型" }));
await user.click(
screen.getByRole("button", { name: "模型启用回执已确认" }),
);
@@ -1537,7 +1575,7 @@ describe("OnboardingFlow S1.3", () => {
await openApps();
await user.click(screen.getByRole("button", { name: SKIP }));
await selectMode(user, /pro/i);
await user.click(screen.getByRole("button", { name: "继续配置模型" }));
await user.click(screen.getByRole("button", { name: "配置模型" }));
await user.click(
screen.getByRole("button", { name: "模型启用回执已确认" }),
);
@@ -1579,7 +1617,7 @@ describe("OnboardingFlow S1.3", () => {
await openApps();
await user.click(screen.getByRole("button", { name: SKIP }));
await selectMode(user, /pro/i);
await user.click(screen.getByRole("button", { name: "继续配置模型" }));
await user.click(screen.getByRole("button", { name: "配置模型" }));
const revisionBeforePut = confirmedMemoryModeSnapshot().revision;
await user.click(
screen.getByRole("button", { name: "模型启用回执已确认" }),
@@ -1628,7 +1666,7 @@ describe("OnboardingFlow S1.3", () => {
await openApps();
await user.click(screen.getByRole("button", { name: SKIP }));
await selectMode(user, /pro/i);
await user.click(screen.getByRole("button", { name: "继续配置模型" }));
await user.click(screen.getByRole("button", { name: "配置模型" }));
await user.click(
screen.getByRole("button", { name: "模型启用回执已确认" }),
);
@@ -1668,7 +1706,7 @@ describe("OnboardingFlow S1.3", () => {
await waitFor(() => expect(document.activeElement).toBe(firstTitle));
await selectMode(user, /pro/i);
await user.click(screen.getByRole("button", { name: "继续配置模型" }));
await user.click(screen.getByRole("button", { name: "配置模型" }));
await user.click(screen.getByRole("button", { name: "返回导入" }));
const returnedTitle = screen.getByRole("heading", {
name: "选择记忆模式",
@@ -1698,7 +1736,7 @@ describe("OnboardingFlow S1.3", () => {
await openApps();
await user.click(screen.getByRole("button", { name: SKIP }));
await selectMode(user, /pro/i);
await user.click(screen.getByRole("button", { name: "继续配置模型" }));
await user.click(screen.getByRole("button", { name: "配置模型" }));
await waitFor(() => expect(finish).toHaveBeenCalledWith("sources"));
expect(screen.queryByRole("alert")).toBeNull();
expect(setMemoryMode).not.toHaveBeenCalled();
@@ -1717,7 +1755,7 @@ describe("OnboardingFlow S1.3", () => {
await openApps();
await user.click(screen.getByRole("button", { name: SKIP }));
await selectMode(user, /pro/i);
await user.click(screen.getByRole("button", { name: "继续配置模型" }));
await user.click(screen.getByRole("button", { name: "配置模型" }));
const lateReceipt = screen.getByRole("button", {
name: "模型启用回执已确认",
});
@@ -1766,14 +1804,14 @@ describe("OnboardingFlow S1.3", () => {
await user.click(screen.getByRole("button", { name: SKIP }));
const pro = screen.getByRole<HTMLInputElement>("radio", { name: /pro/i });
await selectMode(user, /pro/i);
await user.click(screen.getByRole("button", { name: "继续配置模型" }));
await user.click(screen.getByRole("button", { name: "配置模型" }));
expect((await screen.findByRole("alert")).textContent).toContain(
"模式未能保存",
);
expect(pro.checked).toBe(true);
expect(screen.queryByRole("region", { name: "模型步骤边界" })).toBeNull();
await user.click(screen.getByRole("button", { name: "继续配置模型" }));
await user.click(screen.getByRole("button", { name: "配置模型" }));
expect(
await screen.findByRole("region", { name: "模型步骤边界" }),
).toBeTruthy();
@@ -2690,7 +2728,7 @@ describe("OnboardingFlow S1.3", () => {
expect(controller.connectForOnboarding).toHaveBeenCalledOnce();
});
it("keeps an unknown connection visible and reconciles its receipt without a duplicate POST", async () => {
it("keeps an unknown connection visible without reporting an error or duplicating the POST", async () => {
const user = userEvent.setup();
const onFinish = vi.fn();
vi.stubGlobal("crypto", webcrypto);
@@ -2706,6 +2744,7 @@ describe("OnboardingFlow S1.3", () => {
vi.mocked(api.getSourceConnection).mockRejectedValueOnce(
new Error("receipt offline"),
);
window.oneMemory = {} as NonNullable<typeof window.oneMemory>;
function Harness() {
const controller = useImportSources({ automaticSync: false });
return controller.loading ? (
@@ -2723,9 +2762,9 @@ describe("OnboardingFlow S1.3", () => {
await choose(user);
await approve(user);
await user.click(screen.getByRole("button", { name: START }));
await screen.findByRole("alert");
await screen.findByText("正在核对导入结果,已保存的选择不会丢失");
expect(onFinish).not.toHaveBeenCalled();
expect(screen.getByRole("alert").textContent).toContain("尚未确认");
expect(screen.queryByRole("alert")).toBeNull();
expect(completeOnboarding).not.toHaveBeenCalled();
expect(api.connectSources).toHaveBeenCalledOnce();
const requestId = vi.mocked(api.connectSources).mock.calls[0][2]?.requestId;
@@ -2738,26 +2777,9 @@ describe("OnboardingFlow S1.3", () => {
requestId,
}),
);
await user.click(screen.getByRole("button", { name: START }));
await waitFor(() =>
expect(api.getSourceConnection).toHaveBeenCalledTimes(2),
);
expect(api.getSourceConnection).toHaveBeenLastCalledWith(requestId);
expect(api.connectSources).toHaveBeenCalledOnce();
expect(screen.getByRole("alert").textContent).toContain("尚未确认");
vi.mocked(api.getSourceConnection).mockResolvedValueOnce({
request_id: requestId,
accepted_source_ids: ["claude"],
failed_sources: {},
jobs: [],
});
await openApps();
await user.click(screen.getByRole("button", { name: START }));
await screen.findByRole("button", { name: "选择记忆模式" });
await continueImport(user);
expect(onFinish).toHaveBeenCalledWith("sources");
expect(api.connectSources).toHaveBeenCalledOnce();
expect(api.getSourceConnection).toHaveBeenCalledTimes(3);
expect(api.getSourceConnection).toHaveBeenCalledOnce();
expect(api.getSourceConnection).toHaveBeenCalledWith(requestId);
expect(api.createImportJob).not.toHaveBeenCalled();
});
+70 -23
View File
@@ -41,6 +41,7 @@ import {
defaultHostSourceIds,
FirstImportReview,
type ImportConnectionDraft,
type ImportSubmissionState,
} from "./FirstImportReview";
import {
enableHostConnection,
@@ -111,7 +112,7 @@ export function OnboardingFlow({
imports: ImportSourcesController;
onFinish(destination: OnboardingDestination): void;
}) {
const { text, t, locale } = useOnboardingTranslation();
const { text, t } = useOnboardingTranslation();
const [sources, setSources] = useState(imports.sources);
const sourceGeneration = useRef({ value: imports.sources, epoch: 0 });
if (sourceGeneration.current.value !== imports.sources) {
@@ -134,6 +135,8 @@ export function OnboardingFlow({
const [startingImport, setStartingImport] = useState<SourceResponse[] | null>(
null,
);
const [importSubmissionState, setImportSubmissionState] =
useState<ImportSubmissionState>("confirmed");
const resumedStep =
state.mode_intent &&
state.mode_intent !== "lite" &&
@@ -162,6 +165,7 @@ export function OnboardingFlow({
});
const [scheduleLoaded, setScheduleLoaded] = useState(false);
const [scheduleReadFailed, setScheduleReadFailed] = useState(false);
const [scheduleLoading, setScheduleLoading] = useState(true);
const scheduleReadOperation = useRef(0);
const [modeFocusToken, setModeFocusToken] = useState(
resumedStep === "mode" ? 1 : 0,
@@ -427,7 +431,7 @@ export function OnboardingFlow({
const refreshUltraSchedule = useCallback(async () => {
const operation = ++scheduleReadOperation.current;
setScheduleLoaded(false);
setScheduleReadFailed(false);
setScheduleLoading(true);
try {
const schedule = await getEvolveSchedule();
if (!alive.current || scheduleReadOperation.current !== operation) return;
@@ -443,6 +447,9 @@ export function OnboardingFlow({
setScheduleLoaded(false);
setScheduleReadFailed(true);
}
} finally {
if (alive.current && scheduleReadOperation.current === operation)
setScheduleLoading(false);
}
}, []);
@@ -1058,6 +1065,7 @@ export function OnboardingFlow({
async function submitImport(approved: SourceResponse[]) {
sourceFlight.current = true;
setImportSubmissionState("checking");
if (window.oneMemory) setStartingImport(approved);
const version = scopeVersion.current;
const retryFailed = Object.keys(itemErrors).length > 0;
@@ -1069,8 +1077,11 @@ export function OnboardingFlow({
try {
current = (await imports.refresh()).sources;
} catch {
if (alive.current)
if (alive.current) {
setStartingImport(null);
setImportSubmissionState("refused");
setError("暂时无法核对来源,尚未提交授权。选择已保留,可以重试。");
}
return;
}
if (!alive.current) return;
@@ -1093,6 +1104,7 @@ export function OnboardingFlow({
setItemErrors(result.failed_sources);
if (result.accepted_source_ids.length === 0) {
setStartingImport(null);
setImportSubmissionState("refused");
setError(
"本次没有来源获准导入。请核对下方原因后重试,或到应用页查看状态。",
);
@@ -1101,16 +1113,25 @@ export function OnboardingFlow({
// Keep the accepted intent and job IDs while the user inspects early originals.
setAccepted(result);
setStartingImport(null);
setImportSubmissionState("confirmed");
setError(null);
} catch (reason) {
if (alive.current) {
const refused = isDefiniteAccessRefusal(reason);
if (refused) setStartingImport(null);
setError(
refused
? "本次没有来源获准导入。请核对下方原因后重试,或到应用页查看状态。"
: "开始导入的结果尚未确认,选择已保留。可以重试,或进入应用页核对。",
);
if (refused) {
setStartingImport(null);
setImportSubmissionState("refused");
setError(
"本次没有来源获准导入。请核对下方原因后重试,或到应用页查看状态。",
);
} else {
setImportSubmissionState("checking");
setError(
window.oneMemory
? null
: "开始导入的结果尚未确认,选择已保留。可以重试,或进入应用页核对。",
);
}
}
} finally {
connectSent.current = false;
@@ -1186,6 +1207,7 @@ export function OnboardingFlow({
jobs={reviewJobs}
failedSources={accepted?.failed_sources}
sourceSnapshot={imports.sourceSnapshot}
importSubmissionState={importSubmissionState}
busy={completionBusy}
error={error && text(error)}
onContinue={(destination) => {
@@ -1242,11 +1264,11 @@ export function OnboardingFlow({
? "正在保存模式…"
: modePhase === "completing"
? "正在完成首次设置…"
: modeChoice === "lite"
? "使用 Lite 进入"
: modeChoice === "pro"
? "继续配置模型"
: "继续",
: !modeChoice
? "继续"
: modeChoice === "lite"
? "使用 Lite 进入"
: "配置模型",
)}
</R6Button>
}
@@ -1256,17 +1278,21 @@ export function OnboardingFlow({
aria-label={text("记忆模式")}
>
<div className="onboarding-r6-mode-intro">
<h2>
{locale === "zh-Hans"
? "Choose how MemHub remembers"
: text("你希望 MemHub 如何形成记忆?")}
</h2>
<h2>{text("你希望 MemHub 如何形成记忆?")}</h2>
<p>{text("需要明确选择一种方式;之后仍可在设置中更改。")}</p>
</div>
<MemoryModeCards
aria-label={text("记忆模式")}
value={modeChoice}
layout="rows"
describedBy={{
ultra:
modeChoice === "ultra"
? "onboarding-ultra-schedule-description"
: undefined,
}}
disabled={modeSaving || completionBusy}
ultraDetailBusy={scheduleLoading}
onChange={(mode) => {
modeChoiceTouched.current = true;
intentRef.current = mode;
@@ -1275,30 +1301,51 @@ export function OnboardingFlow({
}}
ultraDetail={
scheduleLoaded ? (
<div className="onboarding-r6-ultra-default">
<div className="onboarding-r6-ultra-default" role="status">
<strong>{text("每天自动整合")}</strong>
<small>
<small id="onboarding-ultra-schedule-description">
{text(
ultraSchedule.enabled
? "每天 {time} 左右自动整合,使用这台设备的本地时间;休眠错过后会补跑。"
: "已关闭。重新开启后仍按 {time} 运行;已生成的结果会保留。",
).replace("{time}", ultraSchedule.time)}
{ultraSchedule.enabled ? (
<>
<br />
<span>
{text("后续可在设置页自定义时间或关闭。")}
</span>
</>
) : null}
</small>
</div>
) : scheduleReadFailed ? (
<div className="onboarding-r6-schedule-recovery">
<p role="alert">
<p
id="onboarding-ultra-schedule-description"
role="alert"
>
{text("记忆模式状态暂时无法读取,请重试。")}
</p>
<R6Button
className="onboarding-r6-schedule-retry"
pending={scheduleLoading}
disabled={
scheduleLoading || modeSaving || completionBusy
}
onClick={() => void refreshUltraSchedule()}
>
{text("重试")}
</R6Button>
</div>
) : (
<p>{text("正在读取自动整合设置…")}</p>
<p
id="onboarding-ultra-schedule-description"
className="onboarding-r6-schedule-loading"
role="status"
>
{text("正在读取自动整合设置…")}
</p>
)
}
/>
@@ -76,7 +76,9 @@ describe("OnboardingModelStep", () => {
localStorage.setItem(LANGUAGE_STORAGE_KEY, locale);
const title = guiMessages[locale]["配置记忆整理模型"];
const back = guiMessages[locale]["返回选择模式"];
if (!title || !back) throw new Error(`missing ${locale} model copy`);
const summary = guiMessages[locale]["记忆整理"];
if (!title || !back || !summary)
throw new Error(`missing ${locale} model copy`);
render(
<LanguageProvider>
<OnboardingModelStep {...props()} />
@@ -93,10 +95,11 @@ describe("OnboardingModelStep", () => {
}),
).toBeTruthy();
expect(screen.getByLabelText(/API Key/)).toBeTruthy();
expect(screen.getByRole("complementary", { name: summary })).toBeTruthy();
expect(screen.getAllByRole("button").length).toBeGreaterThanOrEqual(4);
});
it("keeps step 3 on memory mode in a generation-only column", () => {
it("keeps current behavior in the restored open-source composition", () => {
render(<OnboardingModelStep {...props()} />);
expect(
screen.getByRole("heading", { name: "配置记忆整理模型" }),
@@ -110,12 +113,9 @@ describe("OnboardingModelStep", () => {
expect(screen.getByText("DeepSeek · deepseek-flash")).toBeTruthy();
expect(screen.queryByText("本次沉淀")).toBeNull();
expect(screen.queryByLabelText(/向量|Embedding/)).toBeNull();
expect(
document.querySelector(
".onboarding-model-layout--single-column-600" +
'[data-responsive="single-column-600"]',
),
).toBeTruthy();
expect(document.querySelector(".onboarding-model-layout")).toBeTruthy();
expect(document.querySelector(".onboarding-model-summary")).toBeTruthy();
expect(screen.getByText("让记忆从这里开始")).toBeTruthy();
});
it("focuses the page title when entering the model substep", async () => {
@@ -181,6 +181,18 @@ describe("OnboardingModelStep", () => {
);
});
it("returns to the visible API key field when it fails in advanced mode", async () => {
const p = props();
p.advancedOpen = true;
p.fieldErrors = { api_key: "请填写 API Key。" };
const view = render(<OnboardingModelStep {...p} />);
await waitFor(() => expect(p.onAdvancedChange).toHaveBeenCalledWith(false));
view.rerender(<OnboardingModelStep {...p} advancedOpen={false} />);
await waitFor(() =>
expect(document.activeElement).toBe(screen.getByLabelText("API Key")),
);
});
it("links field errors and keeps secrets out of callback errors", async () => {
const p = props();
p.fieldErrors = { api_key: "请填写 API Key。" };
+38 -14
View File
@@ -12,6 +12,7 @@ import type {
import { useOnboardingTranslation } from "./onboarding-language";
import { R6ColdStartFrame } from "./R6ColdStartFrame";
import { R6Button } from "./R6Controls";
import "./onboarding-model-r6.css";
type Action = "test" | "save-and-enable";
@@ -60,6 +61,10 @@ export function OnboardingModelStepNext(props: OnboardingModelStepProps) {
props.onAdvancedChange(true);
return;
}
if (firstError === "api_key" && props.advancedOpen) {
props.onAdvancedChange(false);
return;
}
if (firstError && (!technicalError || props.advancedOpen)) {
formRef.current
?.querySelector<HTMLElement>(`[name="${firstError}"]`)
@@ -173,10 +178,7 @@ export function OnboardingModelStepNext(props: OnboardingModelStepProps) {
</>
}
>
<div
className="onboarding-model-layout onboarding-model-layout--single-column-600"
data-responsive="single-column-600"
>
<div className="onboarding-model-layout">
<section
ref={formRef}
className="onboarding-model-form"
@@ -184,6 +186,9 @@ export function OnboardingModelStepNext(props: OnboardingModelStepProps) {
data-advanced={props.advancedOpen}
>
<div className="onboarding-model-group">
<p className="onboarding-model-intro">
{text("选择服务商,填入 API Key。核对已有配置后再继续。")}
</p>
<label
className="onboarding-model-field onboarding-model-provider"
htmlFor={`${id}-provider`}
@@ -263,16 +268,6 @@ export function OnboardingModelStepNext(props: OnboardingModelStepProps) {
)}
</strong>
</div>
<div className="onboarding-model-disclosure">
<p>
{text("相关对话会发送给所选服务商。")}
{sourceSummary ? ` ${sourceSummary}` : ""}
</p>
<p>
{text("密钥保存在系统安全存储中,不写入对话或日志。")}{" "}
{text("测试只向此接口发送测试内容,不发送历史对话。")}
</p>
</div>
<GenerationTechnicalDisclosure
open={props.advancedOpen}
disabled={fieldsLocked}
@@ -348,6 +343,35 @@ export function OnboardingModelStepNext(props: OnboardingModelStepProps) {
) : null}
</div>
</section>
<aside
className="onboarding-model-summary"
aria-label={text("记忆整理")}
>
<div className="onboarding-model-summary-heading">
<p>{text("记忆整理")}</p>
<h2>{text("让记忆从这里开始")}</h2>
</div>
<div className="onboarding-model-connections">
<p>{text("对话来源")}</p>
<strong>{sourceSummary || text("尚无已选来源")}</strong>
<small>
{t("onboardingImportedConversations", {
count: props.importedConversationCount ?? 0,
})}
</small>
</div>
<div className="onboarding-model-summary-status">
<small>
{text("处理服务:")}
{text(provider?.label ?? "待选择")}
</small>
<small>
{text("相关对话会发送给所选服务商。")}
<br />
{text("测试只向此接口发送测试内容,不发送历史对话。")}
</small>
</div>
</aside>
</div>
</R6ColdStartFrame>
</div>
@@ -82,7 +82,7 @@ it("does not reserve an empty eyebrow row", () => {
expect(screen.queryByText("你的记忆,从这里接上")).toBeNull();
});
it("keeps progress cards content-sized and the step-two footer in normal flow", () => {
it("keeps the v1.0.4 step-two split layout and progress geometry", () => {
const card = declaration(
"r6-import-progress.css",
".r6-import-progress-card",
@@ -94,13 +94,12 @@ it("keeps progress cards content-sized and the step-two footer in normal flow",
"first-import-r6.css",
".first-import-r6-main > .r6-import-progress",
),
).toMatch(/flex:\s*0 0 auto/);
expect(declaration("first-import-r6.css", ".first-import-r6-layout")).toMatch(
/height:\s*100%/,
).toMatch(/flex:\s*0 0 218px/);
const layout = declaration("first-import-r6.css", ".first-import-r6-layout");
expect(layout).toMatch(
/grid-template-columns:\s*minmax\(0,\s*738px\)\s+278px/,
);
expect(
declaration("first-import-r6.css", ".first-import-r6-feedback"),
).not.toMatch(/max-height|overflow/);
expect(layout).toMatch(/height:\s*560\.2px/);
expect(declaration("onboarding-r6.css", ".onboarding-r6-body")).toMatch(
/overflow-y:\s*auto/,
);
+13 -2
View File
@@ -19,13 +19,24 @@ describe("R6 shared controls", () => {
it("keeps its label and prevents duplicate activation while pending", () => {
const onClick = vi.fn();
render(
<R6Button pending onClick={onClick}>
const view = render(
<R6Button pending={false} onClick={onClick}>
保存修改
</R6Button>,
);
const button = screen.getByRole("button", { name: "保存修改" });
expect(button.getAttribute("data-pending-capable")).toBe("true");
const reservedSpinner = button.querySelector(".r6-button-spinner");
expect(reservedSpinner).toBeTruthy();
expect(reservedSpinner?.getAttribute("data-visible")).toBeNull();
view.rerender(
<R6Button pending onClick={onClick}>
保存修改
</R6Button>,
);
expect(button.getAttribute("aria-busy")).toBe("true");
expect(button.querySelector(".r6-button-spinner")).toBe(reservedSpinner);
expect(reservedSpinner?.getAttribute("data-visible")).toBe("true");
fireEvent.click(button);
expect(onClick).not.toHaveBeenCalled();
expect(button.textContent).toContain("保存修改");
+13 -4
View File
@@ -13,24 +13,33 @@ type ButtonProps = ButtonHTMLAttributes<HTMLButtonElement> & {
export function R6Button({
variant = "secondary",
pending = false,
pending,
disabled,
children,
className = "",
type = "button",
...props
}: ButtonProps) {
const supportsPending = pending !== undefined;
const busy = pending === true;
return (
<button
{...props}
type={type}
className={`r6-button ${className}`}
data-variant={variant}
disabled={disabled || pending}
aria-busy={pending || undefined}
data-pending-capable={supportsPending || undefined}
disabled={disabled || busy}
aria-busy={busy || undefined}
>
<span className="r6-button-label">{children}</span>
{pending && <span className="r6-button-spinner" aria-hidden="true" />}
{supportsPending ? (
<span
className="r6-button-spinner"
data-visible={busy || undefined}
aria-hidden="true"
/>
) : null}
</button>
);
}
+34 -2
View File
@@ -160,7 +160,7 @@ it.each(["missing", "pending"] as const)(
},
);
it("centers one or two apps and keeps three or more in a scrolling row", () => {
it("centers one to three apps and keeps four or more in a scrolling row", () => {
function shown(ids: string[]) {
const view = render(
<R6ImportProgress
@@ -181,7 +181,7 @@ it("centers one or two apps and keeps three or more in a scrolling row", () => {
}
expect(shown(["cursor"])).toBe("1");
expect(shown(["cursor", "codex"])).toBe("2");
expect(shown(["cursor", "codex", "claude-code"])).toBe("many");
expect(shown(["cursor", "codex", "claude-code"])).toBe("3");
expect(shown(["cursor", "codex", "claude-code", "workbuddy"])).toBe("many");
const four = render(
<R6ImportProgress
@@ -200,6 +200,38 @@ it("centers one or two apps and keeps three or more in a scrolling row", () => {
.querySelector(".r6-import-progress")
?.getAttribute("data-count"),
).toBe("many");
expect(
four.container
.querySelector(".r6-import-progress")
?.getAttribute("tabindex"),
).toBe("0");
});
it("scrolls one card at a time from the keyboard", () => {
const ids = ["cursor", "codex", "claude-code", "workbuddy"];
render(
<R6ImportProgress
sourceIds={ids}
sources={ids.map((id) => ({
...source,
source_id: id,
display_name: id,
}))}
jobs={[]}
/>,
);
const progress = screen.getByRole("region", {
name: "本次应用导入进度",
});
Object.defineProperty(progress.firstElementChild, "offsetWidth", {
configurable: true,
value: 280,
});
progress.scrollBy = ((options: ScrollToOptions) => {
progress.scrollLeft += Number(options.left ?? 0);
}) as typeof progress.scrollBy;
fireEvent.keyDown(progress, { key: "ArrowRight" });
expect(progress.scrollLeft).toBe(296);
});
it("keeps the first app anchored across receipts unless the user scrolls", () => {
+25 -4
View File
@@ -70,18 +70,39 @@ export function R6ImportProgress({
});
const ids = [...new Set(sourceIds)];
const scrollable = ids.length > 3;
return (
<section
className="r6-import-progress"
aria-label={text("本次应用导入进度")}
data-count={ids.length <= 2 ? String(ids.length) : "many"}
aria-keyshortcuts={
scrollable ? "ArrowLeft ArrowRight Home End" : undefined
}
data-count={ids.length <= 3 ? String(ids.length) : "many"}
ref={scroller}
tabIndex={scrollable ? 0 : -1}
onPointerDown={markUserScrollIntent}
onTouchStart={markUserScrollIntent}
onWheel={markUserScrollIntent}
onKeyDown={(event) => {
if (["ArrowLeft", "ArrowRight", "Home", "End"].includes(event.key))
markUserScrollIntent();
if (!["ArrowLeft", "ArrowRight", "Home", "End"].includes(event.key))
return;
markUserScrollIntent();
const element = event.currentTarget;
const card = element.querySelector<HTMLElement>(
".r6-import-progress-card",
);
const step = (card?.offsetWidth ?? 0) + 16;
event.preventDefault();
if (event.key === "ArrowLeft" || event.key === "ArrowRight") {
element.scrollBy({
left: event.key === "ArrowRight" ? step : -step,
});
} else {
element.scrollTo({
left: event.key === "End" ? element.scrollWidth : 0,
});
}
}}
onScroll={(event) => {
if (userScrollIntent.current)
@@ -133,7 +154,7 @@ export function R6ImportProgress({
<div className="r6-import-progress-orbit" aria-hidden="true">
<span />
<div className="r6-import-progress-icon">
<AppMark id={id} label={source?.display_name ?? id} size={30} />
<AppMark id={id} label={source?.display_name ?? id} size={40} />
</div>
</div>
<h2>{source?.display_name ?? id}</h2>
+40 -2
View File
@@ -14,7 +14,7 @@ vi.mock("axios", () => ({
},
}));
import { getSdkRaw, searchSdkRaw } from "./api";
import { getSdkRaw, getSdkRawGraph, searchSdkRaw } from "./api";
beforeEach(() => vi.clearAllMocks());
@@ -41,7 +41,7 @@ it("lists raw conversations for the selected application", async () => {
});
it("keeps the application filter when searching raw conversations", async () => {
post.mockResolvedValue({ data: { items: [] } });
post.mockResolvedValue({ data: { state: "empty", items: [] } });
const signal = new AbortController().signal;
await searchSdkRaw("release plan", { source: "claude-code", signal });
@@ -59,3 +59,41 @@ it("keeps the application filter when searching raw conversations", async () =>
{ signal, timeout: 10_000 },
);
});
it("does not present an unavailable raw search as no matches", async () => {
post.mockResolvedValue({
data: { state: "unavailable", items: [] },
});
await expect(searchSdkRaw("3d")).rejects.toThrow("raw_search_unavailable");
});
it("requests one Pro-sized raw graph page", async () => {
get.mockResolvedValue({
data: {
user_id: "local",
items: [],
total: 1_600,
offset: 0,
limit: 300,
has_more: true,
},
});
const signal = new AbortController().signal;
await getSdkRawGraph({
source: "cursor",
offset: 0,
limit: 300,
signal,
});
expect(get).toHaveBeenCalledWith("/api/v2/raw/graph", {
params: {
source: "cursor",
offset: 0,
limit: 300,
},
signal,
});
});
+21
View File
@@ -45,6 +45,7 @@ import {
type SdkMemoryStatus,
type SdkMemoryVariant,
type SdkRawContextItem,
type SdkRawGraphPage,
type SdkRawPage,
type SourceDirectoryCandidate,
type SourceImportReceipt,
@@ -1015,6 +1016,24 @@ export async function getSdkRaw({
return response.data;
}
export async function getSdkRawGraph({
source,
offset = 0,
limit = 300,
signal,
}: {
source?: string;
offset?: number;
limit?: number;
signal?: AbortSignal;
} = {}): Promise<SdkRawGraphPage> {
const response = await client.get<SdkRawGraphPage>("/api/v2/raw/graph", {
params: { source, offset, limit },
signal,
});
return response.data;
}
export async function searchSdkRaw(
query: string,
options: { source?: string; signal?: AbortSignal } = {},
@@ -1031,6 +1050,8 @@ export async function searchSdkRaw(
},
{ signal: options.signal, timeout: 10_000 },
);
if (response.data?.state !== "grounded" && response.data?.state !== "empty")
throw new Error("raw_search_unavailable");
const items = response.data?.items;
return Array.isArray(items)
? items.filter(
+11
View File
@@ -69,6 +69,17 @@
font-size: 18px;
font-weight: 500;
}
.r6-evolve-status {
width: fit-content;
margin: 12px 0 -4px;
padding: 5px 9px;
border: 1px solid #cbd8e0;
border-radius: 999px;
background: #edf3f6;
color: #4f6574;
font-size: 12px;
line-height: 20px;
}
.r6-home-metrics {
display: grid;
grid-template-columns: 1.1fr 1fr 1fr;
+48
View File
@@ -791,6 +791,44 @@ button.rail-button.active::before {
text-overflow: ellipsis;
white-space: nowrap;
}
.map-pagination:has(.map-load-more) {
justify-content: center;
padding-top: 8px;
border-top: 0;
}
.map-load-more {
display: grid;
width: 32px;
height: 32px;
padding: 0;
place-items: center;
border: 1px solid var(--bd);
border-radius: 50%;
color: var(--tx3);
background: var(--surface);
transition:
color var(--motion-fast) var(--motion-enter),
background-color var(--motion-fast) var(--motion-enter),
transform var(--motion-fast) var(--motion-enter);
}
.map-load-more:hover:not(:disabled) {
color: var(--ac-strong);
background: var(--ac-bg);
transform: translateY(1px);
}
.map-load-more:focus-visible {
outline: 2px solid var(--ac);
outline-offset: 2px;
}
.map-load-more svg.loading {
animation: map-load-more-pulse 800ms ease-in-out infinite alternate;
}
@keyframes map-load-more-pulse {
to {
transform: translateY(4px);
opacity: 0.45;
}
}
.map-list-status {
flex: none;
min-height: 28px;
@@ -829,6 +867,16 @@ button.rail-button.active::before {
.map-memory-list > li {
margin-bottom: 4px;
}
.map-memory-list.raw > li:not(.map-memory-spacer) {
height: 112px;
}
.map-memory-list.raw .map-memory-item {
height: 112px;
}
.map-memory-list > .map-memory-spacer {
margin: 0;
pointer-events: none;
}
.map-memory-item {
display: grid;
grid-template-columns: 5px minmax(0, 1fr);
+81
View File
@@ -0,0 +1,81 @@
import type { EvolveOverview, EvolveRunReceipt } from "@one-memory/contracts";
import { afterEach, expect, it, vi } from "vitest";
import { getEvolveOverview } from "./api";
import {
commitEvolveRun,
localEvolveRunning,
refreshEvolveOverview,
} from "./evolve-state";
import { queryClient, resetClient } from "./query-client";
vi.mock("./api", () => ({ getEvolveOverview: vi.fn() }));
const receipt: EvolveRunReceipt = {
run_id: "run-1",
user_id: "local",
agent_id: "all-apps",
max_batches: null,
trigger: "manual",
state: "running",
started_at: "2026-11-05T14:00:00Z",
finished_at: null,
rounds: 0,
tasks_processed: 0,
batch_failures: 0,
cycle_status: null,
should_continue: null,
continuation_reason: null,
remaining_clusters: null,
remaining_actionable_clusters: null,
remaining_fresh_facts: null,
capped: false,
elapsed_ms: 0,
error_code: null,
failure_kind: null,
request_id: "request-1",
};
const overview: EvolveOverview = {
mode: "ultra",
schedule: { enabled: true, time: "03:00" },
users: {},
running: [],
};
afterEach(() => {
resetClient();
vi.clearAllMocks();
});
it("shares the latest server overview through the query cache", async () => {
vi.mocked(getEvolveOverview).mockResolvedValue(overview);
await expect(refreshEvolveOverview()).resolves.toEqual(overview);
expect(queryClient.getQueryData(["evolve", "local"])).toEqual(overview);
});
it("publishes an accepted local run immediately from a cold cache", () => {
commitEvolveRun(receipt, overview);
const current = queryClient.getQueryData<EvolveOverview>(["evolve", "local"]);
expect(current?.running).toEqual([receipt]);
expect(localEvolveRunning(current)).toBe(true);
});
it("does not let a pre-mutation overview overwrite the accepted run", async () => {
let resolveRead: ((value: EvolveOverview) => void) | undefined;
vi.mocked(getEvolveOverview).mockReturnValue(
new Promise((resolve) => {
resolveRead = resolve;
}),
);
const staleRead = refreshEvolveOverview();
commitEvolveRun(receipt, overview);
resolveRead?.(overview);
await expect(staleRead).resolves.toMatchObject({ running: [receipt] });
expect(
queryClient.getQueryData<EvolveOverview>(["evolve", "local"])?.running,
).toEqual([receipt]);
});
+76
View File
@@ -0,0 +1,76 @@
import type { EvolveOverview, EvolveRunReceipt } from "@one-memory/contracts";
import { useQuery } from "@tanstack/react-query";
import * as api from "./api";
import { onClientReset, queryClient } from "./query-client";
const EVOLVE_KEY = ["evolve", "local"] as const;
queryClient.setQueryDefaults(EVOLVE_KEY, {
gcTime: Number.POSITIVE_INFINITY,
staleTime: 1_000,
});
let mutationGeneration = 0;
let readsStarted = 0;
let readsLanded = 0;
onClientReset(() => {
mutationGeneration = 0;
readsStarted = 0;
readsLanded = 0;
});
export async function refreshEvolveOverview() {
const generation = mutationGeneration;
const order = ++readsStarted;
const next = await api.getEvolveOverview();
const current = queryClient.getQueryData<EvolveOverview>(EVOLVE_KEY);
if (generation !== mutationGeneration || order < readsLanded)
return current ?? next;
readsLanded = order;
queryClient.setQueryData(EVOLVE_KEY, next);
return next;
}
/** Publish an accepted run before polling confirms it so every page agrees. */
export function commitEvolveRun(
receipt: EvolveRunReceipt,
fallback?: EvolveOverview,
) {
mutationGeneration++;
queryClient.setQueryData<EvolveOverview>(EVOLVE_KEY, (current) => {
const base = current ?? fallback;
return base
? {
...base,
running: [
...base.running.filter(
(running) => running.user_id !== receipt.user_id,
),
receipt,
],
}
: current;
});
void queryClient.invalidateQueries({ queryKey: EVOLVE_KEY });
}
export function useEvolveState(active = true) {
return useQuery(
{
queryKey: EVOLVE_KEY,
queryFn: refreshEvolveOverview,
enabled: active,
refetchInterval: (query) =>
query.state.data?.running.length ? 1_500 : 10_000,
},
queryClient,
);
}
export function useEvolveOverview(active = true) {
return useEvolveState(active).data;
}
export function localEvolveRunning(overview?: EvolveOverview | null) {
return overview?.running.some((run) => run.user_id === "local") ?? false;
}
+49 -161
View File
@@ -1,10 +1,10 @@
/* 143:2419/2502/2594/2685: only step2; shared frame/progress are owned elsewhere. */
.first-import-r6-layout {
display: flex;
flex-direction: column;
gap: 18px;
min-height: 100%;
height: auto;
display: grid;
grid-template-columns: minmax(0, 738px) 278px;
gap: 30px;
height: 560.2px;
min-height: 0;
}
.first-import-r6-main {
min-width: 0;
@@ -13,23 +13,19 @@
flex-direction: column;
}
.first-import-r6-intro {
margin: 0 0 8px;
}
.first-import-r6-intro h2 {
margin: 0 0 3px;
color: #262a2e;
font-size: 16px;
font-weight: 600;
line-height: 26px;
}
.first-import-r6-intro p {
margin: 0;
color: #5a6169;
font-size: 13px;
line-height: 25px;
}
.first-import-r6-subintro {
margin: 1px 0 20px;
color: #63707b;
font-size: 12px;
line-height: 20px;
line-height: 24px;
}
.first-import-r6-history-window {
margin: 0 0 12px;
margin: -10px 0 12px;
padding: 8px 10px;
border: 1px solid #ead7a7;
border-radius: 8px;
@@ -39,107 +35,13 @@
line-height: 20px;
}
.first-import-r6-main > .r6-import-progress {
flex: 0 0 auto;
width: 100%;
margin-inline: auto;
}
.first-import-r6-main > .r6-import-progress[data-count="1"] {
width: min(680px, 100%);
grid-auto-columns: 100%;
}
.first-import-r6-main > .r6-import-progress[data-count="2"] {
width: min(720px, 100%);
grid-auto-columns: calc((100% - 16px) / 2);
}
.first-import-r6-main > .r6-import-progress .r6-import-progress-card {
min-height: 112px;
padding: 12px 18px;
display: grid;
grid-template-columns: 82px minmax(0, 1fr);
grid-template-rows: auto auto auto;
align-content: center;
column-gap: 16px;
text-align: left;
}
.first-import-r6-main > .r6-import-progress .r6-import-progress-orbit {
width: 76px;
height: 76px;
grid-column: 1;
grid-row: 1 / 4;
margin: 0;
}
.first-import-r6-main > .r6-import-progress :is(h2, p, small) {
grid-column: 2;
}
.first-import-r6-main > .r6-import-progress .r6-import-progress-card h2 {
align-self: end;
}
.first-import-r6-main > .r6-import-progress[data-count="many"] {
grid-auto-flow: row;
grid-auto-columns: unset;
overflow-x: hidden;
}
.first-import-r6-main
> .r6-import-progress[data-count="many"]:not(:has(> :nth-child(4))) {
grid-template-columns: repeat(3, minmax(0, 1fr));
}
.first-import-r6-main
> .r6-import-progress[data-count="many"]:has(> :nth-child(4)):not(
:has(> :nth-child(5))
) {
grid-template-columns: repeat(4, minmax(0, 1fr));
}
.first-import-r6-main
> .r6-import-progress[data-count="many"]:has(> :nth-child(5)):not(
:has(> :nth-child(6))
) {
grid-template-columns: repeat(5, minmax(0, 1fr));
}
.first-import-r6-main
> .r6-import-progress[data-count="many"]:has(> :nth-child(6)):not(
:has(> :nth-child(7))
) {
grid-template-columns: repeat(6, minmax(0, 1fr));
}
.first-import-r6-main
> .r6-import-progress[data-count="many"]:has(> :nth-child(7)) {
grid-template-columns: repeat(4, minmax(0, 1fr));
}
.first-import-r6-main
> .r6-import-progress[data-count="many"]:has(> :nth-child(4))
.r6-import-progress-card {
min-height: 112px;
grid-template-columns: minmax(0, 1fr);
justify-items: center;
gap: 1px;
padding: 8px;
text-align: center;
}
.first-import-r6-main
> .r6-import-progress[data-count="many"]:has(> :nth-child(4))
.r6-import-progress-orbit {
width: 54px;
height: 54px;
grid-column: 1;
grid-row: auto;
}
.first-import-r6-main
> .r6-import-progress[data-count="many"]:has(> :nth-child(4))
:is(h2, p, small) {
grid-column: 1;
align-self: auto;
margin-top: 1px;
}
.first-import-r6-main
> .r6-import-progress[data-count="many"]:has(> :nth-child(4))
.r6-import-progress-icon {
width: 36px;
height: 36px;
flex: 0 0 218px;
}
.first-import-r6-feed {
flex: 0 0 auto;
margin-top: 20px;
overflow: visible;
min-height: 0;
flex: 1;
margin-top: 26px;
overflow: auto;
scrollbar-width: thin;
overscroll-behavior: contain;
}
@@ -186,14 +88,9 @@
margin: 0;
}
.first-import-r6-options {
flex: 1 1 auto;
min-width: 0;
min-height: 132px;
padding: 22px 24px;
display: grid;
grid-template-columns: minmax(190px, 0.7fr) minmax(0, 1.3fr);
align-items: center;
gap: 0 28px;
min-height: 0;
padding: 24px;
border: 1px solid #ffffffe0;
border-radius: 22px;
background:
@@ -202,13 +99,9 @@
box-shadow:
inset 0 1px #fff,
0 12px 32px rgb(49 75 101 / 4%);
overflow: visible;
overflow: auto;
scrollbar-width: thin;
overscroll-behavior: contain;
align-self: stretch;
}
.first-import-r6-options-head {
min-width: 0;
}
.first-import-r6-eyebrow {
margin: 0 0 14px;
@@ -235,12 +128,12 @@
position: relative;
display: block;
width: 100%;
min-height: 104px;
margin: 0;
padding: 18px 52px 16px 18px;
min-height: 124px;
margin: 0 0 16px;
padding: 18px 16px 12px;
border: 1px solid rgb(179 196 209 / 42%);
border-radius: 12px;
background: rgb(255 255 255 / 34%);
border-radius: 0;
background: rgb(255 255 255 / 10%);
box-shadow: inset 0 1px 1px #ffffffd9;
backdrop-filter: blur(20px);
cursor: pointer;
@@ -269,8 +162,8 @@
.first-import-r6-selection input {
appearance: none;
position: absolute;
top: 20px;
right: 20px;
top: 22px;
right: 18px;
width: 16px;
height: 16px;
min-height: 16px;
@@ -308,11 +201,10 @@
display: block;
font-size: 12px;
line-height: 20px;
margin-top: 7px;
margin-top: 8px;
color: #5e6e7a;
}
.first-import-r6-options-note {
grid-column: 1 / -1;
font-size: 11px;
line-height: 22px;
margin: 0 0 12px;
@@ -332,37 +224,33 @@
.first-import-r6-options details p {
margin: 6px 0;
}
.first-import-r6-feedback {
margin: 0 0 12px;
padding: 10px 12px;
border-radius: 8px;
background: rgb(255 255 255 / 50%);
.first-import-r6-footer-note {
flex: 1;
min-width: 0;
max-height: 64px;
overflow: auto;
}
.first-import-r6-feedback p {
.first-import-r6-footer-note p {
margin: 0;
font-size: 12px;
line-height: 20px;
color: #63707b;
}
.first-import-r6-feedback [role="alert"] {
.first-import-r6-footer-note [role="alert"] {
color: #944242;
}
.first-import-r6-feedback .first-import-r6-partial-success {
.first-import-r6-footer-note .first-import-r6-partial-success {
color: #245f50;
font-weight: 600;
}
.first-import-r6-footer-actions {
display: flex;
flex: 0 1 auto;
min-width: 0;
max-width: 100%;
flex-wrap: wrap;
gap: 12px;
gap: 16px;
align-items: center;
margin-left: auto;
}
.first-import-r6-footer-actions > [data-variant="primary"] {
min-width: min(160px, 100%);
min-width: 210px;
}
.first-import-r6-footer-actions .r6-button-label {
display: inline-flex;
@@ -509,12 +397,13 @@ dialog.first-import-r6-original[open] {
}
@media (max-width: 1150px) {
.first-import-r6-layout {
gap: 18px;
grid-template-columns: minmax(0, 1fr) 278px;
gap: 24px;
}
}
@media (min-width: 761px) and (max-height: 959px) {
.onboarding-r6-body:has(> .first-import-r6-layout) {
overflow-y: auto;
overflow: hidden;
padding-top: 16px;
padding-bottom: 16px;
}
@@ -524,8 +413,8 @@ dialog.first-import-r6-original[open] {
}
@media (min-width: 761px) and (max-height: 859px) {
.first-import-r6-options {
padding: 18px 20px;
overflow: visible;
padding: 14px;
overflow: hidden;
}
.first-import-r6-eyebrow {
margin-bottom: 4px;
@@ -542,16 +431,16 @@ dialog.first-import-r6-original[open] {
}
.first-import-r6-selection,
.first-import-r6-selection + .first-import-r6-selection {
min-height: 96px;
margin-bottom: 0;
padding: 15px 48px 13px 16px;
min-height: 86px;
margin-bottom: 8px;
padding: 9px 12px;
}
.first-import-r6-selection strong {
line-height: 24px;
}
.first-import-r6-selection input {
top: 17px;
right: 16px;
top: 13px;
right: 12px;
}
.first-import-r6-selection span {
margin-top: 4px;
@@ -572,7 +461,6 @@ dialog.first-import-r6-original[open] {
overflow: visible;
}
.first-import-r6-options {
display: block;
overflow: visible;
}
.first-import-r6-footer-note {
+298 -41
View File
@@ -1,14 +1,26 @@
.memory-mode-cards {
position: relative;
flex: 1;
min-width: 0;
display: grid;
grid-template-columns: repeat(3, minmax(0, 1fr));
gap: 16px;
align-items: start;
min-height: 420px;
display: flex;
}
.memory-mode-card-list {
flex: 1;
min-width: 0;
min-height: 0;
display: flex;
flex-direction: column;
gap: 14px;
}
.memory-mode-card {
position: relative;
flex: 1 1 0;
min-width: 0;
min-height: 286px;
min-height: 128px;
height: 100%;
display: flex;
flex-direction: column;
overflow: hidden;
@@ -38,15 +50,26 @@
box-shadow: 0 10px 26px rgb(47 107 87 / 8%);
}
.memory-mode-cards--rows
.memory-mode-card[data-mode="ultra"][data-selected="true"] {
min-height: 166px;
}
.memory-mode-card[data-disabled="true"] {
opacity: 0.55;
}
.memory-mode-card-choice {
min-height: 284px;
padding: 20px;
display: flex;
flex-direction: column;
position: relative;
flex: 1 1 auto;
min-height: 0;
height: auto;
padding: 18px 20px;
display: grid;
grid-template-columns: minmax(250px, 0.9fr) minmax(0, 1.6fr);
grid-template-rows: auto auto;
column-gap: 24px;
align-items: center;
cursor: pointer;
}
@@ -68,10 +91,13 @@
}
.memory-mode-title {
grid-column: 1;
grid-row: 1;
min-width: 0;
display: flex;
align-items: center;
gap: 8px;
padding-right: 34px;
}
.memory-mode-index {
@@ -110,9 +136,11 @@
}
.memory-mode-radio {
position: absolute;
top: 18px;
right: 20px;
width: 21px;
height: 21px;
margin-left: auto;
flex: 0 0 21px;
display: grid;
place-items: center;
@@ -133,8 +161,11 @@
}
.memory-mode-description {
min-height: 50px;
margin-top: 10px;
grid-column: 1;
grid-row: 2;
align-self: start;
min-height: 0;
margin-top: 6px;
color: #526674;
font-size: 13px;
line-height: 22px;
@@ -142,42 +173,152 @@
}
.memory-mode-features {
margin-top: auto;
padding: 16px 0 2px;
grid-column: 2;
grid-row: 1 / -1;
align-self: stretch;
margin-top: 0;
padding: 0 42px 0 24px;
display: grid;
gap: 8px;
border-top: 1px solid #dbe4ea;
grid-template-columns: repeat(3, minmax(0, 1fr));
align-content: center;
gap: 0;
color: #5e6e7a;
font-size: 12px;
line-height: 19px;
}
.memory-mode-features > span {
position: relative;
display: flex;
align-items: flex-start;
gap: 8px;
align-items: center;
min-width: 0;
min-height: 40px;
padding: 0 16px;
overflow-wrap: break-word;
hyphens: auto;
}
.memory-mode-features > span::before {
content: "";
position: absolute;
top: 50%;
left: 0;
width: 1px;
height: 22px;
background: rgb(199 211 220 / 78%);
transform: translateY(-50%);
}
.memory-mode-features i {
width: 5px;
height: 5px;
margin-top: 7px;
flex: 0 0 5px;
border-radius: 50%;
background: #7f95a4;
display: none;
}
.memory-mode-detail {
margin-top: auto;
padding: 14px 18px;
border-top: 1px solid #d4e0e7;
background: #f1f6f8;
width: auto;
margin: 7px 18px 12px;
padding: 10px 0 0;
display: flex;
align-items: flex-start;
gap: 9px;
border-top: 1px solid rgb(166 190 179 / 48%);
background: transparent;
color: #405867;
font-size: 12px;
line-height: 20px;
line-height: 18px;
animation: memory-mode-detail-in 180ms ease-out both;
}
.memory-mode-detail > svg {
color: #527565;
flex: 0 0 auto;
}
.memory-mode-detail > div {
min-width: 0;
flex: 1;
}
.memory-mode-cards--grid {
flex: none;
min-height: 0;
display: grid;
grid-template-columns: repeat(3, minmax(0, 1fr));
gap: 16px;
align-items: start;
}
.memory-mode-cards--grid .memory-mode-card-list {
display: contents;
}
.memory-mode-cards--grid .memory-mode-card {
position: static;
flex: none;
min-height: 286px;
height: auto;
}
.memory-mode-cards--grid .memory-mode-card-choice {
position: static;
min-height: 284px;
height: auto;
padding: 20px;
display: flex;
flex-direction: column;
}
.memory-mode-cards--grid .memory-mode-title {
width: 100%;
padding-right: 0;
}
.memory-mode-cards--grid .memory-mode-radio {
position: static;
margin-left: auto;
}
.memory-mode-cards--grid .memory-mode-description {
min-height: 50px;
margin-top: 10px;
}
.memory-mode-cards--grid .memory-mode-features {
width: 100%;
margin-top: auto;
padding: 16px 0 2px;
grid-template-columns: minmax(0, 1fr);
border-top: 1px solid #dbe4ea;
border-left: 0;
}
.memory-mode-cards--grid .memory-mode-features > span {
min-height: 34px;
padding: 6px 0;
}
.memory-mode-cards--grid .memory-mode-features > span + span {
border-top: 1px solid rgb(199 211 220 / 72%);
border-left: 0;
}
.memory-mode-cards--grid .memory-mode-features > span::before {
display: none;
}
.memory-mode-cards--grid .memory-mode-detail {
width: auto;
margin: 8px 18px 16px;
padding: 12px 0 0;
display: block;
background: transparent;
font-size: 12px;
line-height: 20px;
}
.memory-mode-cards--grid .memory-mode-detail > svg {
display: none;
}
@keyframes memory-mode-detail-in {
from {
opacity: 0;
@@ -185,49 +326,165 @@
}
}
@media (min-width: 761px) and (max-height: 959px) {
.memory-mode-cards--rows {
min-height: 284px;
}
.memory-mode-cards--rows .memory-mode-card-list {
gap: 10px;
}
.memory-mode-cards--rows .memory-mode-card {
min-height: 88px;
}
.memory-mode-cards--rows
.memory-mode-card[data-mode="ultra"][data-selected="true"] {
min-height: 116px;
}
.memory-mode-cards--rows .memory-mode-card-choice {
min-height: 0;
padding: 11px 16px;
grid-template-columns: minmax(220px, 0.85fr) minmax(0, 1.65fr);
column-gap: 18px;
}
.memory-mode-cards--rows .memory-mode-radio {
top: 11px;
right: 16px;
}
.memory-mode-cards--rows .memory-mode-title strong {
font-size: 16px;
line-height: 22px;
}
.memory-mode-cards--rows .memory-mode-description {
min-height: 0;
margin-top: 2px;
font-size: 11px;
line-height: 16px;
}
.memory-mode-cards--rows .memory-mode-features {
margin-top: 0;
padding: 0 34px 0 18px;
gap: 0;
font-size: 11px;
line-height: 15px;
}
.memory-mode-cards--rows .memory-mode-features > span {
min-height: 32px;
padding: 0 10px;
}
.memory-mode-cards--rows .memory-mode-detail {
margin: 6px 14px 10px;
padding: 8px 0 0;
gap: 6px;
font-size: 11px;
line-height: 16px;
}
}
@media (max-width: 760px) {
.memory-mode-cards--rows {
flex: none;
min-height: 0;
}
.memory-mode-cards--rows .memory-mode-card-list {
display: grid;
grid-template-columns: 1fr;
}
.memory-mode-cards--rows .memory-mode-card {
flex: none;
min-height: 0;
height: auto;
}
.memory-mode-cards--rows .memory-mode-card-choice {
height: auto;
min-height: 0;
padding: 20px;
display: flex;
flex-direction: column;
}
.memory-mode-cards--rows .memory-mode-radio {
position: static;
margin-left: auto;
}
.memory-mode-cards--rows .memory-mode-title {
width: 100%;
padding-right: 0;
}
.memory-mode-cards--rows .memory-mode-description {
min-height: 0;
margin-top: 8px;
}
.memory-mode-cards--rows .memory-mode-features {
width: 100%;
margin-top: 16px;
padding: 16px 0 0;
grid-template-columns: minmax(0, 1fr);
border-top: 1px solid #dbe4ea;
border-left: 0;
}
.memory-mode-cards--rows .memory-mode-features > span {
min-height: 34px;
padding: 6px 0;
}
.memory-mode-cards--rows .memory-mode-features > span + span {
border-top: 1px solid rgb(199 211 220 / 72%);
border-left: 0;
}
.memory-mode-cards--rows .memory-mode-features > span::before {
display: none;
}
.memory-mode-cards--rows .memory-mode-detail {
width: auto;
margin: 8px 20px 14px;
padding: 12px 0 0;
background: transparent;
}
}
@media (min-width: 681px) and (max-height: 959px) {
.memory-mode-cards {
.memory-mode-cards--grid {
gap: 14px;
}
.memory-mode-card {
.memory-mode-cards--grid .memory-mode-card {
min-height: 194px;
}
.memory-mode-card-choice {
.memory-mode-cards--grid .memory-mode-card-choice {
min-height: 194px;
padding: 16px;
}
.memory-mode-title strong {
.memory-mode-cards--grid .memory-mode-title strong {
font-size: 17px;
line-height: 25px;
}
.memory-mode-description {
.memory-mode-cards--grid .memory-mode-description {
min-height: 36px;
margin-top: 8px;
font-size: 12px;
line-height: 20px;
}
.memory-mode-features {
.memory-mode-cards--grid .memory-mode-features {
padding-top: 10px;
gap: 6px;
font-size: 11px;
line-height: 18px;
}
.memory-mode-detail {
padding: 10px 14px;
.memory-mode-cards--grid .memory-mode-detail {
width: auto;
margin: 6px 14px 14px;
padding: 10px 0 0;
font-size: 11px;
line-height: 18px;
}
}
@media (max-width: 680px) {
.memory-mode-cards {
.memory-mode-cards--grid {
grid-template-columns: 1fr;
}
.memory-mode-card,
.memory-mode-card-choice {
.memory-mode-cards--grid .memory-mode-card,
.memory-mode-cards--grid .memory-mode-card-choice {
min-height: 0;
}
.memory-mode-description {
.memory-mode-cards--grid .memory-mode-description {
min-height: 0;
}
}
+105 -139
View File
@@ -588,51 +588,44 @@
transition: none;
}
/* Generation-only cold start. Embedding remains a separate settings lane. */
.onboarding-model-r6 .onboarding-r6-head > div {
width: min(760px, 100%);
}
.onboarding-model-layout {
display: block;
width: min(880px, 100%);
height: auto;
margin-inline: auto;
}
.onboarding-model-form {
overflow: visible;
}
/* The current controller keeps generation-only behavior. The presentation
intentionally returns to the original open-source setup composition:
an open form column, a separate context column, and a distinct advanced state. */
.onboarding-model-group {
display: grid;
grid-template-columns: repeat(2, minmax(0, 1fr));
gap: 18px 20px;
padding: 24px 26px;
border: 1px solid #d8e2e8;
border-radius: 16px;
background: rgb(255 255 255 / 72%);
box-shadow: inset 0 1px rgb(255 255 255 / 88%);
display: block;
}
.onboarding-model-provider,
.onboarding-model-key {
margin-top: 0;
.onboarding-model-group > .onboarding-model-intro {
margin-bottom: 0;
}
.onboarding-model-field {
gap: 7px;
}
.onboarding-model-field :is(input, select),
.onboarding-model-field .om-option-menu-button,
.generation-model-field :is(input, select) {
width: 100%;
min-width: 0;
height: 44px;
padding: 9px 12px;
border: 1px solid #c8d3db;
height: 54px;
padding: 12px 16px;
border: 1px solid #c0ccd5;
border-radius: 8px;
background: #fff;
background: #f0f5f6;
color: #262a2e;
font:
400 14px / 22px "Noto Sans SC",
400 14px / 23px "Noto Sans SC",
"PingFang SC",
sans-serif;
transition:
border-color var(--r6-fast, 140ms),
background-color var(--r6-fast, 140ms);
}
.onboarding-model-field .om-option-menu-button:hover:not(:disabled),
.generation-model-field :is(input, select):hover:not(:disabled) {
border-color: #9fb6ad;
background: #eaf1f2;
}
.generation-model-field select:not(:disabled),
.generation-technical-toggle:not(:disabled) {
cursor: pointer;
}
:is(.onboarding-model-field, .generation-model-field) [aria-invalid="true"] {
border-color: #a24646;
}
.onboarding-model-field small,
.generation-model-field small {
@@ -641,14 +634,10 @@
line-height: 19px;
}
.onboarding-model-default-summary {
grid-column: 1 / -1;
display: grid;
gap: 6px;
padding: 14px 0;
border-block: 1px solid #e4eaee;
}
.onboarding-model-default-summary span,
.onboarding-model-disclosure {
min-height: 54px;
margin-top: 12px;
color: #63707b;
font-size: 12px;
line-height: 20px;
@@ -658,154 +647,131 @@
font-size: 14px;
font-weight: 600;
}
.onboarding-model-disclosure {
grid-column: 1 / -1;
display: grid;
gap: 6px;
}
.generation-technical-disclosure {
grid-column: 1 / -1;
border-top: 1px solid #e4eaee;
padding-top: 4px;
margin-top: 23px;
}
.generation-technical-toggle {
display: flex;
width: 100%;
min-height: 44px;
min-height: 28px;
align-items: center;
justify-content: space-between;
padding: 8px 0;
padding: 0;
border: 0;
border-radius: 6px;
background: transparent;
color: #4e6478;
font:
500 13px / 21px "Noto Sans SC",
"PingFang SC",
sans-serif;
transition:
background-color var(--r6-fast, 140ms),
color var(--r6-fast, 140ms);
}
.generation-technical-toggle:hover:not(:disabled) {
background: #ffffff66;
color: #33424d;
}
.generation-technical-fields {
display: grid;
grid-template-columns: repeat(2, minmax(0, 1fr));
gap: 16px;
padding-top: 12px;
}
.generation-model-field {
display: grid;
min-width: 0;
gap: 6px;
color: #63707b;
font-size: 12px;
line-height: 20px;
}
.generation-model-field:has([name="base-url"]),
.generation-runtime-fields,
.generation-thinking {
grid-column: 1 / -1;
}
.generation-runtime-fields {
display: grid;
grid-template-columns: repeat(3, minmax(0, 1fr));
gap: 12px;
gap: 19px 30px;
margin-top: 34px;
}
.generation-technical-fields[hidden] {
display: none;
}
.generation-runtime-note {
.generation-model-field {
display: grid;
min-width: 0;
gap: 5px;
color: #63707b;
font-size: 12px;
line-height: 20px;
}
.generation-model-field:has([name="base_url"]),
.generation-runtime-fields,
.generation-runtime-note,
.generation-thinking {
grid-column: 1 / -1;
}
.generation-model-field :is(input, select) {
height: 42px;
padding: 9px 12px;
font-size: 13px;
line-height: 21px;
}
.generation-runtime-fields {
display: grid;
grid-template-columns: repeat(3, minmax(0, 1fr));
gap: 24px;
}
.generation-runtime-note {
color: #63707b;
font-size: 12px;
line-height: 19px;
}
.generation-thinking {
display: flex;
min-height: 32px;
min-height: 28px;
align-items: center;
gap: 9px;
gap: 10px;
color: #416e5c;
font-size: 13px;
font-size: 14px;
line-height: 23px;
}
.onboarding-model-r6 .generation-model-error {
color: #a24646;
}
.onboarding-model-recovery-actions {
margin-top: 12px;
.onboarding-model-form[data-advanced="true"]
:is(
.onboarding-model-intro,
.onboarding-model-provider,
.onboarding-model-key,
.onboarding-model-default-summary
) {
display: none;
}
.onboarding-model-form[data-advanced="true"] .generation-technical-disclosure {
margin-top: 0;
}
.onboarding-model-form[data-advanced="true"] .generation-technical-toggle {
min-height: 28px;
}
.onboarding-model-recovery-actions,
.onboarding-model-feedback {
margin-top: 8px;
}
.onboarding-model-r6 .onboarding-r6-foot {
display: grid;
grid-template-columns: minmax(220px, 1fr) auto;
align-items: center;
gap: 20px 32px;
}
.onboarding-model-r6 .onboarding-model-footer-actions {
flex-wrap: nowrap;
margin-left: 0;
}
.onboarding-model-r6 .onboarding-model-footer-actions .r6-button {
min-width: 132px;
}
@media (min-width: 761px) {
.onboarding-model-r6 .onboarding-r6-body {
.onboarding-model-group {
flex: 1;
min-height: 0;
overflow-y: auto;
}
.onboarding-model-form {
display: block;
overflow: visible;
overscroll-behavior: contain;
scrollbar-width: thin;
scrollbar-gutter: stable;
scroll-padding: 12px;
padding-inline: 4px 16px;
}
}
@media (min-width: 761px) and (max-height: 959px) {
.onboarding-model-r6 .onboarding-r6-body {
overflow-y: hidden;
.onboarding-model-form[data-advanced="false"]
.onboarding-model-field
:is(input, select, .om-option-menu-button) {
height: 44px;
padding-top: 8px;
padding-bottom: 8px;
}
.onboarding-model-layout,
.onboarding-model-form {
height: 100%;
min-height: 0;
}
.onboarding-model-form {
overflow-y: auto;
scrollbar-width: thin;
}
.onboarding-model-group {
gap: 14px 18px;
padding: 18px 22px;
.generation-technical-fields {
margin-top: 16px;
}
}
@media (max-width: 600px) {
.onboarding-model-layout--single-column-600 .onboarding-model-group {
@media (max-width: 760px) {
.generation-technical-fields,
.generation-runtime-fields {
grid-template-columns: minmax(0, 1fr);
padding: 16px;
border-radius: 10px;
}
.onboarding-model-layout--single-column-600 .generation-technical-fields,
.onboarding-model-layout--single-column-600 .generation-runtime-fields {
grid-template-columns: minmax(0, 1fr);
}
.onboarding-model-footer-actions .r6-button,
.onboarding-model-footer-actions .r6-button[data-variant="primary"] {
flex-basis: 100%;
}
}
@media (max-width: 900px) {
.onboarding-model-r6 .onboarding-r6-foot {
grid-template-columns: minmax(0, 1fr);
}
.onboarding-model-r6 .onboarding-model-footer-actions {
width: 100%;
flex-wrap: wrap;
}
.onboarding-model-r6 .onboarding-model-footer-actions .r6-button {
flex: 1 1 160px;
}
}
@media (max-width: 600px) {
.onboarding-model-r6 .onboarding-model-footer-actions .r6-button,
.onboarding-model-r6
.onboarding-model-footer-actions
.r6-button[data-variant="primary"] {
flex-basis: 100%;
gap: 12px;
}
}
@media (prefers-reduced-motion: reduce) {
+47 -6
View File
@@ -588,6 +588,25 @@
font-size: 11px;
line-height: 18px;
}
.onboarding-r6-schedule-loading {
margin: 0;
color: #607482;
font-size: 12px;
line-height: 20px;
}
.memory-mode-detail .onboarding-r6-schedule-recovery {
width: 100%;
display: flex;
flex-wrap: wrap;
align-items: center;
justify-content: space-between;
gap: 12px;
text-align: left;
}
.memory-mode-detail .onboarding-r6-schedule-retry {
flex: 0 0 auto;
min-width: 76px;
}
.onboarding-r6-evolve-settings {
margin-top: 0;
padding: 18px 20px 4px;
@@ -615,15 +634,12 @@
font-weight: 600;
line-height: 22px;
}
.onboarding-r6-schedule-recovery {
display: grid;
justify-items: center;
gap: 10px;
.onboarding-r6-mode .onboarding-r6-schedule-recovery {
color: #944242;
font-size: 13px;
font-size: 12px;
line-height: 20px;
}
.onboarding-r6-schedule-recovery p {
.onboarding-r6-mode .onboarding-r6-schedule-recovery p {
margin: 0;
}
.onboarding-r6-mode-error {
@@ -634,6 +650,24 @@
font-size: 13px;
line-height: 20px;
}
@media (min-width: 761px) {
.onboarding-r6-mode {
min-height: 100%;
}
}
@media (min-width: 761px) and (max-height: 959px) {
.onboarding-r6-mode {
gap: 12px;
}
.onboarding-r6-mode-intro h2 {
font-size: 20px;
line-height: 28px;
}
.onboarding-r6-mode-intro p {
font-size: 12px;
line-height: 20px;
}
}
.onboarding-r6-empty {
margin: 0;
padding: 28px 18px;
@@ -802,6 +836,13 @@
flex-wrap: wrap;
flex-basis: auto;
}
.memory-mode-detail .onboarding-r6-schedule-recovery {
flex-direction: column;
align-items: stretch;
}
.memory-mode-detail .onboarding-r6-schedule-retry {
align-self: flex-start;
}
}
@media (prefers-reduced-transparency: reduce) {
.onboarding-r6-shell {
+20 -2
View File
@@ -159,7 +159,7 @@ button.r6-button {
min-height: 44px;
height: 44px;
padding: 0 20px;
gap: 0;
gap: 8px;
border: 1px solid var(--r6-control-edge);
border-radius: 8px;
background: rgb(255 255 255 / 20%);
@@ -190,6 +190,10 @@ button.r6-button[data-variant="primary"] {
0 3px 8px -2px rgb(64 87 102 / 12%);
}
button.r6-button[data-pending-capable="true"] {
padding-inline: 32px;
}
button.r6-button[data-variant="quiet"] {
height: 36px;
min-height: 36px;
@@ -204,6 +208,10 @@ button.r6-button[data-variant="quiet"] {
color: var(--r6-muted);
}
button.r6-button[data-variant="quiet"][data-pending-capable="true"] {
padding-inline: 24px;
}
button.r6-button:hover:not(:disabled) {
background: rgb(231 239 244 / 60%);
border-color: #aabcc8;
@@ -242,16 +250,26 @@ button.r6-button[data-variant="quiet"]:disabled {
}
.r6-button-label {
min-width: 0;
text-align: center;
}
.r6-button-spinner {
position: absolute;
right: 5px;
top: 50%;
right: 12px;
flex: 0 0 auto;
width: 12px;
height: 12px;
margin-top: -6px;
border: 1.5px solid currentColor;
border-right-color: transparent;
border-radius: 50%;
visibility: hidden;
animation: r6-spin 1.8s linear infinite;
animation-play-state: paused;
}
.r6-button-spinner[data-visible="true"] {
visibility: visible;
animation-play-state: running;
}
.r6-stepper {
+2 -1
View File
@@ -15,7 +15,8 @@
safe center falls back to the start if the pair would overflow a narrow column.
Three or more keep the existing row and scroll horizontally. */
.r6-import-progress[data-count="1"],
.r6-import-progress[data-count="2"] {
.r6-import-progress[data-count="2"],
.r6-import-progress[data-count="3"] {
justify-content: safe center;
overflow-x: hidden;
}
+11
View File
@@ -33,6 +33,17 @@
font-size: 13px;
color: #637786;
}
.r6-memory .memory-evolve-status {
width: fit-content;
margin: -12px 0 20px;
padding: 5px 9px;
border: 1px solid #cbd8e0;
border-radius: 999px;
background: #edf3f6;
color: #4f6574;
font-size: 12px;
line-height: 20px;
}
.r6-memory .memory-pending-add {
display: flex;
align-items: center;
+76
View File
@@ -1,13 +1,17 @@
import type {
GovernedMemory,
MemoryDetail,
MemoryGraphResponse,
MemoryLibraryPage,
SdkRawContextItem,
SdkRawGraphItem,
SdkRawPage,
SdkRawRecord,
SourceResponse,
} from "@one-memory/contracts";
export type RawMapRecord = SdkRawRecord | SdkRawContextItem;
export const SDK_RAW_PAGE_SIZE = 20;
export function rawRecordId(record: RawMapRecord): string {
return record.turn_id || record.raw_id;
@@ -124,6 +128,78 @@ export function rawSearchPage(items: SdkRawContextItem[]): MemoryLibraryPage {
return pageFor(items, items.length, 0, Math.max(items.length, 1));
}
export function rawGraphRecord(item: SdkRawGraphItem): SdkRawContextItem {
return {
kind: "raw",
raw_id: item.raw_id,
turn_id: item.turn_id,
content: item.label,
source: item.source,
session_id: item.session_id,
memory_at:
typeof item.memory_at === "number"
? new Date(item.memory_at * 1_000).toISOString()
: null,
score: 0,
truncated: true,
};
}
export function rawGraphProjection(
items: SdkRawGraphItem[],
total: number,
sources: SourceResponse[],
): MemoryGraphResponse {
const labels = new Map(
sources.map((source) => [source.source_id, source.display_name]),
);
const nodes = [];
const edges = [];
const sourceIds = new Set<string>();
for (const item of items) {
const sourceId = item.source || "default";
if (!sourceIds.has(sourceId)) {
sourceIds.add(sourceId);
nodes.push({
node_id: `source:${sourceId}`,
node_type: "source" as const,
memory_id: null,
source_id: sourceId,
label: labels.get(sourceId) ?? sourceId,
kind: null,
status: null,
confidence: null,
});
}
const nodeId = `memory:${item.turn_id}`;
nodes.push({
node_id: nodeId,
node_type: "memory" as const,
memory_id: item.turn_id,
source_id: null,
label: item.label,
kind: "chat",
status: "confirmed",
confidence: null,
});
edges.push({
edge_id: `source:${sourceId}->${nodeId}`,
source_node_id: `source:${sourceId}`,
target_node_id: nodeId,
relation: "evidence_source",
origin: "provenance" as const,
weight: null,
});
}
return {
total_memories: total,
projected_memories: items.length,
hy_graph_available: false,
nodes,
edges,
};
}
export function rawDetail(record: RawMapRecord): MemoryDetail {
const memory = rawMapMemory(record);
return {
+1 -1
View File
@@ -1,6 +1,6 @@
# MemHub 安装与发行
MemHub `v1.0.6`(build 43)的发行目标如下:
MemHub `v1.0.6`(build 44)的发行目标如下:
| 平台 | 发行物 | 构建与验收 |
| --- | --- | --- |
@@ -21,9 +21,10 @@ Core without it still validates; any other unknown field still fails closed.
- Existing `om_recall` and `om_conversations` grants are composed rather than
replaced by a universal context grant. At least one requested lane must be
authorized.
- Raw access requires `all-apps` plus source grants. Memory access requires an
existing memory scope. Explicit unauthorized raw sources are rejected before
Core is called.
- Raw access requires `om_conversations` plus `all-apps`; Core resolves that
scope to every connected, unrevoked source. Memory access requires an existing
memory scope. Explicit raw source filters are checked against live connections
by Core.
- Broker captures installation identity and revision, then re-authenticates
before releasing results. Revocation, credential rotation, or scope
narrowing invalidates the release.
@@ -18,9 +18,10 @@ revision or Evidence fields.
No caller user/workspace/installation/permission fields. Unknown fields reject.
- Envelope remains existing broker identity and existing underlying tools. At
least one requested path must be authorized. Raw needs om_conversations plus
all-apps and source grants; memory needs om_recall plus an existing all-apps or
workspace scope. Source scopes alone never authorize arbitrary memories.
- Explicit raw source filter outside current allowed sources is 403. A missing
all-apps and reads every connected, unrevoked source; memory needs om_recall
plus an existing all-apps or workspace scope. Source scopes alone never
authorize arbitrary memories or original conversations.
- Explicit raw source filter outside currently connected sources is 403. A missing
optional lane is not_authorized without querying it. No legal requested lane
means 403. Invalid external envelope/changed installation must reject wholesale.
- Local authenticated calls may read the existing local data instance; they do
+1 -1
View File
@@ -2,7 +2,7 @@
"name": "one-memory",
"version": "1.0.6",
"release": {
"build_number": 43,
"build_number": 44,
"macos_min_version": "13.0",
"app_update": "manual",
"runtime_manifest_schema": 1,
+22
View File
@@ -720,6 +720,28 @@ export interface SdkRawPage {
time_filtered?: boolean;
}
/** Lightweight bounded-map projection. Raw text remains SDK-owned. */
export interface SdkRawGraphItem {
turn_id: string;
raw_id: string;
label: string;
source: string;
session_id: string;
memory_at: number | null;
gmt_created: number | null;
}
export interface SdkRawGraphPage {
user_id: string;
items: SdkRawGraphItem[];
/** SDK-reported matching raw scope; `items` remains capped by `limit`. */
total: number;
limit: number;
offset: number;
/** More matching turns exist, but the product map does not auto-page them. */
has_more: boolean;
}
/** `GET /api/v2/memory-library/layers`: memories per SDK layer. */
export interface SdkLayerCounts {
/** The user whose memories were counted. */
@@ -60,20 +60,13 @@ function memory(): SdkContextItem {
}
describe("context response authority", () => {
it("accepts a raw item from a source the host was granted", () => {
// The write side stores under `source:<id>`, so a read that did not check
// the same scope would return rows from sources this host never granted.
it("accepts a raw item from any connected app under all-apps", () => {
expect(
isContextResponseAuthorized(response([raw("codex")]), identity()),
).toBe(true);
});
it("rejects a raw item from a source the host was not granted", () => {
// This is the symmetry rule: granted for one source, shown another's rows
// is a silent leak rather than an error anyone would notice.
expect(
isContextResponseAuthorized(response([raw("cursor")]), identity()),
).toBe(false);
).toBe(true);
});
it("rejects raw when the conversations tool is missing", () => {
@@ -98,15 +91,13 @@ describe("context response authority", () => {
).toBe(false);
});
it("rejects every item when any one of them is out of scope", () => {
// Not "filter the bad one out": a response that mixed in one foreign row
// has failed its contract, and passing the rest would hide that.
it("accepts raw results spanning connected apps", () => {
expect(
isContextResponseAuthorized(
response([raw("codex"), raw("cursor")]),
identity(),
),
).toBe(false);
).toBe(true);
});
it("checks memory items against memory authority, not source scopes", () => {
@@ -530,15 +530,20 @@ it("rechecks the WorkBuddy installation after Core records delivery", async () =
it.each([
{ tools: ["om_recall"], scopes: ["all-apps"] },
{ tools: ["om_recall"], scopes: [`workspace:v1:${"a".repeat(64)}`] },
{ tools: ["om_conversations"], scopes: ["all-apps", "source:codex"] },
{ tools: ["om_conversations"], scopes: ["all-apps"] },
{
tools: ["om_conversations"],
scopes: ["all-apps", "source:codex"],
body: { query: "original", raw_source_ids: ["cursor"] },
},
])(
"combines only the underlying grants: $tools $scopes",
async ({ tools, scopes }) => {
async ({ tools, scopes, body }) => {
const f = await fixture(tools as HostReadTool[], scopes);
expect((await f.post()).status).toBe(200);
expect((await f.post(body)).status).toBe(200);
expect(f.calls).toHaveLength(1);
expect(f.calls[0].url).toBe("/api/v2/context/search");
expect(f.calls[0].body).toEqual({ query: "original" });
expect(f.calls[0].body).toEqual(body ?? { query: "original" });
expect(
JSON.parse(f.calls[0].headers["x-one-memory-allowed-tools"] as string),
).toEqual(tools);
@@ -552,17 +557,11 @@ it.each([
{ tools: [], scopes: ["all-apps"], body: { query: "x" } },
{ tools: ["om_ask"], scopes: ["all-apps"], body: { query: "x" } },
{ tools: ["om_recall"], scopes: ["source:codex"], body: { query: "x" } },
{ tools: ["om_conversations"], scopes: ["all-apps"], body: { query: "x" } },
{
tools: ["om_recall"],
scopes: ["all-apps"],
body: { query: "x", kinds: ["raw"] },
},
{
tools: ["om_recall", "om_conversations"],
scopes: ["all-apps", "source:codex"],
body: { query: "x", raw_source_ids: ["cursor"] },
},
])(
"denies invalid requested authority before Core: $tools $body",
async ({ tools, scopes, body }) => {
@@ -1069,11 +1068,6 @@ it.each([
scopes: ["all-apps", "source:codex"],
response: receiptFor([memory]),
},
{
tools: ["om_recall", "om_conversations"],
scopes: ["all-apps", "source:codex"],
response: receiptFor([{ ...raw, source: "cursor" }]),
},
])(
"checks captured identity on Core output: $tools $scopes",
async ({ tools, scopes, response }) => {
@@ -1099,8 +1093,8 @@ it.each([
},
{
tools: ["om_conversations"],
scopes: ["all-apps", "source:codex"],
response: receiptFor([raw]),
scopes: ["all-apps"],
response: receiptFor([{ ...raw, source: "cursor" }]),
},
])(
"preserves correctly authorized single-lane output: $tools $scopes",
@@ -67,7 +67,7 @@ export function isContextSearchRequest(
);
}
/** Composition of existing rights, not a new all-content grant. Core checks live sources. */
/** Composition of existing rights. Core checks which connected sources remain live. */
export function authorizeContext(
identity: VerifiedHostIdentity,
request: ContextSearchRequest,
@@ -76,16 +76,9 @@ export function authorizeContext(
const raw =
kinds.includes("raw") &&
identity.allowedTools.includes("om_conversations") &&
identity.allowedScopes.includes("all-apps") &&
identity.allowedScopes.some((s) => s.startsWith("source:"));
identity.allowedScopes.includes("all-apps");
const memory = kinds.includes("memory") && hasMemoryAuthority(identity);
if (
request.raw_source_ids &&
(!raw ||
!request.raw_source_ids.every((s) =>
identity.allowedScopes.includes(`source:${s}`),
))
)
if (request.raw_source_ids && !raw)
throw new HostGrantError("host_grant_scope_denied");
if (!raw && !memory) throw new HostGrantError("host_grant_tool_denied");
}
@@ -109,8 +102,7 @@ export function isContextResponseAuthorized(
item.kind === "memory"
? hasMemoryAuthority(identity)
: identity.allowedTools.includes("om_conversations") &&
identity.allowedScopes.includes("all-apps") &&
identity.allowedScopes.includes(`source:${item.source}`),
identity.allowedScopes.includes("all-apps"),
);
}
@@ -94,7 +94,7 @@ function checkedReceipt(value: RecallReceipt, query: string): RecallReceipt {
!hit.evidence_ids.every((id) => typeof id === "string"),
) ||
(value.state === "empty") !== (value.hits.length === 0) ||
value.hits.reduce((n, h) => n + h.content.length, 0) > 6000
value.hits.reduce((n, h) => n + [...h.content].length, 0) > 6000
) {
throw new ClaudeAdapterError("invalid_recall_response");
}
@@ -88,6 +88,26 @@ describe("MH-C01 event contract", () => {
expect(f.notifySourceChanged).not.toHaveBeenCalled();
});
it("counts astral characters the same way as Core's character budget", async () => {
const f = fixture();
const content = "😀".repeat(4_000);
f.recall.mockResolvedValueOnce({
...hit,
hits: [{ ...hit.hits[0], content }],
used_characters: 4_000,
truncated: true,
truncated_by: "character_budget",
});
const result = await f.adapter.handle({
hook_event_name: "UserPromptSubmit",
session_id: "s1",
prompt: "previous decision",
});
expect(result.state).toBe("grounded");
});
it("accepts a raw-mode recall and labels its hits as conversation excerpts", async () => {
const f = fixture();
f.recall.mockResolvedValueOnce({
@@ -1063,6 +1063,9 @@ const baseEnglishMessages = {
"少一些重复交代,多一些理解。": "Less repetition. More understanding.",
onboardingMoreApps: "Scroll for more · {count} apps remaining",
"onboardingMoreApps.one": "Scroll for more · {count} app remaining",
onboardingMoreImportApps: "Scroll horizontally · {count} apps remaining",
onboardingImportChecking:
"Checking the import result; your selection is kept",
onboardingAllApps: "All {count} apps shown",
默认位置: "Default location",
自定义位置: "Custom location",
+21 -1
View File
@@ -10,6 +10,7 @@ const modeChineseMessages = {
选择记忆方式: "选择记忆方式",
选择记忆模式: "选择记忆模式",
"之后可在设置中更改。": "之后可在设置中更改。",
"后续可在设置页自定义时间或关闭。": "后续可在设置页自定义时间或关闭。",
"Ultra 可在完成首次设置后到设置页开启。":
"Ultra 可在完成首次设置后到设置页开启。",
"使用 Lite 进入": "使用 Lite 进入",
@@ -23,7 +24,7 @@ const modeChineseMessages = {
"当前模型已经启用,可以继续完成首次设置。":
"当前模型已经启用,可以继续完成首次设置。",
继续使用当前模型: "继续使用当前模型",
"你希望 MemHub 如何形成记忆?": "选择记忆模式",
"你希望 MemHub 如何形成记忆?": "你希望 MemHub 如何形成记忆?",
"需要明确选择一种方式;之后仍可在设置中更改。": "之后可在设置中更改。",
记忆模式: "记忆模式",
记忆模式与模型: "记忆模式与模型",
@@ -200,6 +201,8 @@ export const modeEnglishMessages: ModeMessages = {
选择记忆方式: "Choose how memory works",
选择记忆模式: "Choose memory mode",
"之后可在设置中更改。": "You can change this later in Settings.",
"后续可在设置页自定义时间或关闭。":
"You can later customize the time or turn this off in Settings.",
"Ultra 可在完成首次设置后到设置页开启。":
"Ultra can be enabled in Settings after initial setup.",
"使用 Lite 进入": "Enter with Lite",
@@ -404,6 +407,8 @@ export const modeSpanishMessages: ModeMessages = {
选择记忆方式: "Elegir cómo funciona la memoria",
选择记忆模式: "Elegir el modo de memoria",
"之后可在设置中更改。": "Puedes cambiarlo más adelante en Configuración.",
"后续可在设置页自定义时间或关闭。":
"Más adelante, puedes personalizar la hora o desactivarlo en Configuración.",
"Ultra 可在完成首次设置后到设置页开启。":
"Puedes activar Ultra en Configuración después de completar la configuración inicial.",
"使用 Lite 进入": "Entrar con Lite",
@@ -607,6 +612,7 @@ export const modeTraditionalChineseMessages: ModeMessages = {
选择记忆方式: "選擇記憶方式",
选择记忆模式: "選擇記憶模式",
"之后可在设置中更改。": "之後可在設定中更改。",
"后续可在设置页自定义时间或关闭。": "之後可在設定頁自訂時間或關閉。",
"Ultra 可在完成首次设置后到设置页开启。":
"完成首次設定後,可到設定頁開啟 Ultra。",
"使用 Lite 进入": "使用 Lite 進入",
@@ -795,6 +801,8 @@ export const modeJapaneseMessages: ModeMessages = {
选择记忆方式: "記憶方法を選択",
选择记忆模式: "記憶モードを選択",
"之后可在设置中更改。": "後から設定で変更できます。",
"后续可在设置页自定义时间或关闭。":
"後から設定画面で時刻を変更したり、無効にしたりできます。",
"Ultra 可在完成首次设置后到设置页开启。":
"初期設定の完了後、設定画面で Ultra を有効にできます。",
"使用 Lite 进入": "Lite で開始",
@@ -994,6 +1002,8 @@ export const modeGermanMessages: ModeMessages = {
选择记忆方式: "Erinnerungsmodus wählen",
选择记忆模式: "Erinnerungsmodus auswählen",
"之后可在设置中更改。": "Sie können dies später in den Einstellungen ändern.",
"后续可在设置页自定义时间或关闭。":
"Später können Sie die Uhrzeit in den Einstellungen anpassen oder die Funktion deaktivieren.",
"Ultra 可在完成首次设置后到设置页开启。":
"Ultra kann nach der Ersteinrichtung in den Einstellungen aktiviert werden.",
"使用 Lite 进入": "Mit Lite starten",
@@ -1198,6 +1208,8 @@ export const modePortugueseMessages: ModeMessages = {
选择记忆方式: "Escolher o modo de memória",
选择记忆模式: "Escolher o modo de memória",
"之后可在设置中更改。": "Pode alterar esta opção mais tarde nas Definições.",
"后续可在设置页自定义时间或关闭。":
"Mais tarde, pode personalizar a hora ou desativar esta opção nas Definições.",
"Ultra 可在完成首次设置后到设置页开启。":
"Pode ativar o Ultra nas Definições depois da configuração inicial.",
"使用 Lite 进入": "Entrar com Lite",
@@ -1404,6 +1416,8 @@ export const modeFrenchMessages: ModeMessages = {
选择记忆模式: "Choisir le mode de mémoire",
"之后可在设置中更改。":
"Vous pourrez le modifier plus tard dans les réglages.",
"后续可在设置页自定义时间或关闭。":
"Vous pourrez ensuite personnaliser l’heure ou désactiver cette option dans les réglages.",
"Ultra 可在完成首次设置后到设置页开启。":
"Vous pourrez activer Ultra dans les réglages après la configuration initiale.",
"使用 Lite 进入": "Entrer avec Lite",
@@ -1607,6 +1621,8 @@ export const modeKoreanMessages: ModeMessages = {
选择记忆方式: "메모리 방식 선택",
选择记忆模式: "메모리 모드 선택",
"之后可在设置中更改。": "나중에 설정에서 변경할 수 있습니다.",
"后续可在设置页自定义时间或关闭。":
"나중에 설정에서 시간을 변경하거나 끌 수 있습니다.",
"Ultra 可在完成首次设置后到设置页开启。":
"초기 설정을 마친 후 설정에서 Ultra를 켤 수 있습니다.",
"使用 Lite 进入": "Lite로 시작",
@@ -1805,6 +1821,8 @@ export const modeRussianMessages: ModeMessages = {
选择记忆方式: "Выберите режим памяти",
选择记忆模式: "Выберите режим памяти",
"之后可在设置中更改。": "Позже режим можно изменить в настройках.",
"后续可在设置页自定义时间或关闭。":
"Позже в настройках можно изменить время или отключить эту функцию.",
"Ultra 可在完成首次设置后到设置页开启。":
"Ultra можно включить в настройках после завершения первоначальной настройки.",
"使用 Lite 进入": "Войти с Lite",
@@ -2009,6 +2027,8 @@ export const modeIndonesianMessages: ModeMessages = {
选择记忆方式: "Pilih cara kerja memori",
选择记忆模式: "Pilih mode memori",
"之后可在设置中更改。": "Anda dapat mengubahnya nanti di Pengaturan.",
"后续可在设置页自定义时间或关闭。":
"Nanti, Anda dapat menyesuaikan waktunya atau menonaktifkannya di Pengaturan.",
"Ultra 可在完成首次设置后到设置页开启。":
"Ultra dapat diaktifkan di Pengaturan setelah penyiapan awal selesai.",
"使用 Lite 进入": "Masuk dengan Lite",
@@ -83,6 +83,10 @@ export const onboardingSpanishMessages = {
onboardingMoreApps: "Desplázate para ver más · Quedan {count} aplicaciones",
"onboardingMoreApps.one":
"Desplázate para ver más · Queda {count} aplicación",
onboardingMoreImportApps:
"Desplázate horizontalmente · Quedan {count} aplicaciones",
onboardingImportChecking:
"Comprobando el resultado de la importación; tu selección se conserva",
onboardingAllApps: "Se muestran las {count} aplicaciones",
默认位置: "Ubicación predeterminada",
自定义位置: "Ubicación personalizada",
@@ -393,6 +397,8 @@ export const onboardingChineseMessages = {
onboardingImportHistory: "导入 {name} 历史",
onboardingMoreApps: "向下查看更多 · 还有 {count} 个应用",
"onboardingMoreApps.one": "向下查看更多 · 还有 {count} 个应用",
onboardingMoreImportApps: "横向滑动查看更多 · 还有 {count} 个应用",
onboardingImportChecking: "正在核对导入结果,已保存的选择不会丢失",
onboardingAllApps: "已显示全部 {count} 个应用",
onboardingImportRefused: "尚未获准导入({reason})。请核对来源后重试。",
onboardingConfirmHistory: "确认这是 {name} 的历史位置",
@@ -1255,6 +1255,9 @@ export const germanMessages: Record<MessageKey, string> = {
"少一些重复交代,多一些理解。": "Weniger Wiederholung. Mehr Verständnis.",
onboardingMoreApps: "Für mehr scrollen · {count} Apps verbleibend",
"onboardingMoreApps.one": "Für mehr scrollen · {count} App verbleibend",
onboardingMoreImportApps: "Horizontal scrollen · Noch {count} Apps",
onboardingImportChecking:
"Importergebnis wird geprüft; Ihre Auswahl bleibt erhalten",
onboardingAllApps: "Alle {count} Apps angezeigt",
默认位置: "Standardort",
自定义位置: "Eigener Ort",
@@ -1271,6 +1271,10 @@ export const frenchMessages: Record<MessageKey, string> = {
"Faites défiler pour voir plus · {count} applications restantes",
"onboardingMoreApps.one":
"Faites défiler pour voir plus · {count} application restante",
onboardingMoreImportApps:
"Faites défiler horizontalement · encore {count} applications",
onboardingImportChecking:
"Vérification du résultat de l’importation ; votre sélection est conservée",
onboardingAllApps: "Les {count} applications sont affichées",
默认位置: "Emplacement par défaut",
自定义位置: "Emplacement personnalisé",
@@ -1239,6 +1239,9 @@ export const indonesianMessages: Record<MessageKey, string> = {
onboardingMoreApps: "Gulir untuk melihat lainnya · {count} aplikasi lagi",
"onboardingMoreApps.one":
"Gulir untuk melihat lainnya · {count} aplikasi lagi",
onboardingMoreImportApps: "Gulir horizontal · {count} aplikasi lagi",
onboardingImportChecking:
"Memeriksa hasil impor; pilihan Anda tetap tersimpan",
onboardingAllApps: "Semua {count} aplikasi ditampilkan",
默认位置: "Lokasi default",
自定义位置: "Lokasi kustom",
@@ -1234,6 +1234,9 @@ export const japaneseMessages: Record<MessageKey, string> = {
onboardingMoreApps: "スクロールしてさらに表示 · 残り {count} 個のアプリ",
"onboardingMoreApps.one":
"スクロールしてさらに表示 · 残り {count} 個のアプリ",
onboardingMoreImportApps: "横にスクロール · 残り {count} 個のアプリ",
onboardingImportChecking:
"インポート結果を確認しています。選択内容は保持されています",
onboardingAllApps: "全 {count} 個のアプリを表示中",
默认位置: "既定の場所",
自定义位置: "カスタムの場所",
@@ -1210,6 +1210,9 @@ export const koreanMessages: Record<MessageKey, string> = {
"少一些重复交代,多一些理解。": "반복은 줄이고, 이해는 깊게.",
onboardingMoreApps: "스크롤하여 더 보기 · 앱 {count}개 남음",
"onboardingMoreApps.one": "스크롤하여 더 보기 · 앱 {count}개 남음",
onboardingMoreImportApps: "가로로 스크롤 · 앱 {count}개 남음",
onboardingImportChecking:
"가져오기 결과를 확인하는 중이며 선택 항목은 유지됩니다",
onboardingAllApps: "앱 {count}개 모두 표시됨",
默认位置: "기본 위치",
自定义位置: "사용자 지정 위치",
@@ -1258,6 +1258,9 @@ export const portugueseMessages: Record<MessageKey, string> = {
"少一些重复交代,多一些理解。": "Menos repetição. Mais compreensão.",
onboardingMoreApps: "Role para ver mais · Apps restantes: {count}",
"onboardingMoreApps.one": "Role para ver mais · {count} app restante",
onboardingMoreImportApps: "Role horizontalmente · {count} apps restantes",
onboardingImportChecking:
"Verificando o resultado da importação; sua seleção foi mantida",
onboardingAllApps: "Todos os apps exibidos ({count})",
默认位置: "Local padrão",
自定义位置: "Local personalizado",
@@ -1244,6 +1244,9 @@ export const russianMessages: Record<MessageKey, string> = {
"Прокрутите, чтобы увидеть больше · осталось приложений: {count}",
"onboardingMoreApps.one":
"Прокрутите, чтобы увидеть больше · осталось приложений: {count}",
onboardingMoreImportApps:
"Прокрутите по горизонтали · осталось приложений: {count}",
onboardingImportChecking: "Проверяем результат импорта; ваш выбор сохранён",
onboardingAllApps: "Показаны все приложения: {count}",
默认位置: "Расположение по умолчанию",
自定义位置: "Своё расположение",
@@ -1133,6 +1133,8 @@ export const traditionalChineseMessages: Record<MessageKey, string> = {
"少一些重复交代,多一些理解。": "少一些重複交代,多一些理解。",
onboardingMoreApps: "向下捲動檢視更多 · 還有 {count} 個 App",
"onboardingMoreApps.one": "向下捲動檢視更多 · 還有 {count} 個 App",
onboardingMoreImportApps: "橫向捲動查看更多 · 還有 {count} 個 App",
onboardingImportChecking: "正在確認匯入結果,已保留你的選擇",
onboardingAllApps: "已顯示全部 {count} 個 App",
默认位置: "預設位置",
自定义位置: "自訂位置",