Compare commits

..
Author SHA1 Message Date
kristinheandCursor 3e8a006f65 fix(memhub): satisfy full lint gate
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-10-27 23:51:51 +08:00
kristinheandCursor a70830970d fix(memhub): polish settings interactions
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-10-27 23:45:08 +08:00
kristinheandCursor 556d95df84 fix(memhub): stabilize memory detail and pagination
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-10-27 23:45:00 +08:00
feitianxiang e09fbd16f9 Merge pull request #197 from Tencent-Hunyuan/feat/cli-simplify
feat(memhub): simplify the CLI and drop the notes concept
2026-10-27 20:21:20 +08:00
45 changed files with 1221 additions and 1688 deletions

No files matched your search

+30 -100
View File
@@ -47,8 +47,8 @@ from one_memory.background_work import shutdown_bulk_pool, warm_bulk_pool
from one_memory.config import Settings, load_settings
from one_memory.context_routes import sdk_context_router
from one_memory.conversation_processing import (
_conversation_chunks,
memory_task_timeout_seconds,
turn_batches,
)
from one_memory.database_access import (
MAIN_DATABASE_REPORT_SECONDS,
@@ -95,8 +95,6 @@ from one_memory.hy_sdk_runtime import (
SdkClientHandle,
builtin_retrieval_snapshot,
close_all_shared_runtimes,
hold_stores_offline,
release_stores,
sdk_relevance_floor,
)
from one_memory.hy_sdk_types import (
@@ -225,7 +223,6 @@ from one_memory.sdk_memory_qa import (
SdkMemoryAnswerer,
)
from one_memory.sdk_memory_runtime import SdkMemoryRuntime
from one_memory.sdk_migration_log import error_chain_text, error_frames
from one_memory.sdk_write_processor import SdkWriteCoordinator
from one_memory.secrets import (
KeyringSecretStore,
@@ -713,8 +710,7 @@ def create_app(
raise SdkMigrationRequired()
if app.state.memory_authority is MemoryAuthority.SDK:
# The metadata repair after a cutover reads every bundle: it is
# recovery work, so it runs after readiness (`recover_after_ready`)
# while the SDK's stores are held offline (see there).
# recovery work, so it runs after readiness (`recover_after_ready`).
app.state.memory_governance_store = RetiredGovernanceStore(database_path)
app.state.evidence_store = RetiredEvidenceStore(database_path)
else:
@@ -1030,25 +1026,28 @@ def create_app(
app.state.memory_write_intake = UnifiedWriteIntake(
memory_write_queue,
source_writes_permitted=lambda source_id: _source_writes_on(app, source_id),
source_writes_permitted=lambda source_id: (
source_id == "one-memory-manual" or _source_writes_on(app, source_id)
),
)
app.state.start_memory_backfill = lambda: _start_backfill(app)
async def enqueue_stable_evidence(record: EvidenceRecord) -> None:
live = live_sync_arrival.get()
priority = "live" if live_sync_arrival.get() else "normal"
conversation = await asyncio.to_thread(
app.state.evidence_store.list_conversation, record.evidence_id
)
# Legacy authority. The turn rule groups the whole conversation
# from its start, so a batch's records and key never change:
# history (end of file) writes it all, live writes complete
# batches and leaves the rest to catch-up (`_run_backfill`).
batches = turn_batches(conversation, flush=not live).batches
for batch in _batches_settled_by(batches, record):
for chunk in _conversation_chunks(conversation, max_turns=12, completed_only=True):
if (
record not in chunk
or not any(item.role == "user" for item in chunk)
or not any(item.role == "assistant" for item in chunk)
):
continue
await asyncio.to_thread(
app.state.memory_write_intake.submit_records,
batch,
priority="live" if live else "normal",
chunk,
priority=priority,
)
app.state.memory_addition_store = MemoryAdditionStore(
@@ -1190,14 +1189,10 @@ def create_app(
except Exception as error:
failed.append(name)
app.state.recovery_failed_steps = tuple(failed)
# Where it failed, as `file:line:function` frames of
# the exception chain, never its message: a bare
# `error_type=KeyError` named no location.
logger.warning(
"core_recovery_step_failed step=%s error_type=%s chain=%s",
"core_recovery_step_failed step=%s error_type=%s",
name,
type(error).__name__,
error_chain_text(error),
)
diagnostic_event(
level="warn",
@@ -1205,42 +1200,20 @@ def create_app(
event="core.recovery_step_failed",
outcome="failed",
error_type=type(error).__name__,
safe_stack=error_frames(error) or None,
duration_ms=elapsed_ms(step_started),
details={"phase": name},
)
async def repair_sdk_metadata() -> None:
# `sdk-migrate` repairs right after its cutover and writes
# the receipt, so this normally finds it and returns. It
# stays for homes whose repair failed before that (no
# receipt). It opens its own Chroma client on the SDK's
# vector store, and Chroma's shared System is not safe
# beside another client: a first library request that
# opened the SDK client meanwhile lost it (KeyError).
# The stores were held offline before readiness, so any
# SDK client waits for this step.
from one_memory.hy_sdk_migration import (
repair_sdk_metadata_from_bundle,
)
if not app.state.sdk_stores_held:
logger.warning("sdk_metadata_repair_deferred reason=sdk_client_open")
return
# The hold is this step's from here; shutdown leaves it.
app.state.sdk_stores_held = False
work = asyncio.ensure_future(
asyncio.to_thread(
repair_sdk_metadata_from_bundle,
data_dir=runtime_settings.data_dir,
database_path=database_path,
)
repair = await asyncio.to_thread(
repair_sdk_metadata_from_bundle,
data_dir=runtime_settings.data_dir,
database_path=database_path,
)
# Released when the thread ends, not when this coroutine
# is cancelled: a cancelled await leaves the thread
# writing Chroma.
work.add_done_callback(lambda _work: release_stores(runtime_settings.data_dir))
repair = await asyncio.shield(work)
if repair.vector_rows or repair.coding_rows:
logger.info(
"sdk_metadata_repair_completed mapped=%d vector=%d coding=%d",
@@ -1364,12 +1337,7 @@ def create_app(
)
app.state.recovery_task = None
app.state.sdk_stores_held = False
try:
if app.state.memory_authority is MemoryAuthority.SDK:
# Before the first request can open an SDK client; the
# metadata repair step releases it.
app.state.sdk_stores_held = hold_stores_offline(runtime_settings.data_dir)
diagnostic_event(
level="info",
component="core.lifecycle",
@@ -1426,10 +1394,6 @@ def create_app(
if app.state.live_sync_watch is not None:
await app.state.live_sync_watch.shutdown()
await app.state.live_sync_supervisor.shutdown()
if app.state.sdk_stores_held:
# Recovery never reached the metadata repair step.
release_stores(runtime_settings.data_dir)
app.state.sdk_stores_held = False
await app.state.import_supervisor.shutdown()
app.state.memory_write_drain.stop()
if app.state.memory_write_drain_task is not None:
@@ -2860,24 +2824,11 @@ def create_app(
dependencies=protected,
)
async def sdk_home_summary() -> SdkHomeSummary:
"""The home card: the memory page's count and its newest memory.
The count is `memory_stats` total (chat + working + coding
experiences, facts not counted), the same set the memory page lists
with scene="all". Without a scene `list_memories` counts chat only,
which showed 55 on the home card against 94 on the memory page.
"""
try:
client = await _sdk_client_handle(app).client()
stats = await asyncio.to_thread(
client.memory_stats,
user_id="local",
statuses=["active"],
)
raw = await asyncio.to_thread(
client.list_memories,
user_id="local",
scene="all",
statuses=["active"],
limit=1,
offset=0,
@@ -2887,9 +2838,8 @@ def create_app(
_log_skipped_sdk_memories(skipped)
except Exception as error:
raise _sdk_http_error(error, "hy_memory_list_failed") from None
total = stats.get("total") if isinstance(stats, dict) else None
return SdkHomeSummary(
memory_count=total if isinstance(total, int) and total >= 0 else page.total,
memory_count=page.total,
latest=page.memories[0] if page.memories else None,
observed_at=datetime.now(UTC),
)
@@ -4380,11 +4330,14 @@ async def _run_backfill(app: FastAPI) -> None:
_evidence_store(app).list_conversation,
evidence_id,
)
# Catch-up is history: the last turn and a short batch are written.
for batch in turn_batches(conversation, flush=True).batches:
if not any(item.evidence_id in evidence_ids for item in batch):
for chunk in _conversation_chunks(
conversation,
max_turns=12,
completed_only=True,
):
if not any(item.evidence_id in evidence_ids for item in chunk):
continue
await asyncio.to_thread(intake.submit_records, batch, priority="normal")
await asyncio.to_thread(intake.submit_records, chunk, priority="normal")
cursor = evidence_ids[-1]
except asyncio.CancelledError:
raise
@@ -4403,22 +4356,6 @@ async def _run_backfill(app: FastAPI) -> None:
logger.exception("memory catch-up failed, no further memories will be made")
def _batches_settled_by(
batches: tuple[tuple[EvidenceRecord, ...], ...],
record: EvidenceRecord,
) -> list[tuple[EvidenceRecord, ...]]:
"""The batches one arriving record can have made writable.
Its own batch, and the last two: a user message completes the turn
before it, which can close that turn's batch and, at the 8000-character
cut, the batch before it. Submitting is idempotent, so an older batch
named again is only a hit.
"""
chosen = [batch for batch in batches if record in batch]
chosen.extend(batch for batch in batches[-2:] if batch not in chosen)
return chosen
def _sdk_memory_runtime(app: FastAPI) -> SdkMemoryRuntime:
return cast(
SdkMemoryRuntime,
@@ -4714,17 +4651,10 @@ def _evidence_store(app: FastAPI) -> EvidenceStore:
def _source_writes_on(app: FastAPI, source_id: str) -> bool:
"""Whether this source's conversations may become memories.
A hand-added memory is allowed unless its grant was explicitly revoked;
a missing grant row is allowed. The SDK add path writes no grant row, so
requiring one left every SDK-authority manual add unclaimable: Pending
forever with attempts=0 while later jobs ran. Intake, claim and execute
all ask this one question.
"""
"""Whether this source's conversations may become memories."""
if source_id == "one-memory-manual":
receipt = _grant_store(app).receipt(user_id="local", source_id=source_id)
return receipt is None or receipt.revoked_at is None
return receipt is not None and receipt.revoked_at is None
return _source_processing_enabled(app, source_id)
@@ -4,10 +4,9 @@ from __future__ import annotations
import os
import re
from collections.abc import Sequence
from dataclasses import dataclass
from one_memory.imports.models import EvidenceContentKind, EvidenceRecord
from one_memory.imports.models import EvidenceContentKind, EvidenceOrigin, EvidenceRecord
from one_memory.memory_tasks import segment_evidence_record
MEMORY_TASK_TIMEOUT_MARGIN_SECONDS = 60.0
MEMORY_TASK_TIMEOUT_MIN_SECONDS = 180.0
@@ -30,13 +29,10 @@ _HOST_ENVELOPE = re.compile(
r")",
re.IGNORECASE | re.DOTALL,
)
#: The write rule (`turn_batches`): every 5 complete turns, or fewer when
#: the next turn would take the batch past 8000 HY-read characters.
TURN_BATCH_TURNS = 5
TURN_BATCH_CHARACTERS = 8_000
#: A live conversation's last turn counts as complete, and a short batch is
#: written, after this long without a new message in that conversation.
TURN_IDLE_FLUSH_SECONDS = 600
_WHOLE_TURN_CHARACTERS = 60_000
_WINDOW_BYTE_CEILING = 2_000_000
_HISTORY_MERGE_TURNS = 8
_HISTORY_MERGE_CHARACTERS = 8_000
def memory_task_timeout_seconds(model_timeout_seconds: float) -> float:
@@ -61,83 +57,180 @@ def _coding_tool_result_max_bytes() -> int:
_HY_TOOL_CHARACTERS = _coding_tool_result_max_bytes()
@dataclass(frozen=True)
class TurnBatches:
"""What a conversation's records become: batches to write, records to keep."""
#: Each batch is whole turns; every batch holds at least one user message.
batches: tuple[tuple[EvidenceRecord, ...], ...]
#: Not written yet: the open last turn, complete turns short of a batch,
#: or records with no user message before them. Fed back in, in order,
#: with the conversation's next records.
carry: tuple[EvidenceRecord, ...]
def conversation_turns(records: Sequence[EvidenceRecord]) -> list[tuple[EvidenceRecord, ...]]:
"""One user message and every record after it up to the next user message.
Records before the first user message are not a turn of their own: they
join the first turn (an assistant proposal and the user turn adopting it
reach HY together). With no user message at all the result is one group
that does not open with a user, which `turn_batches` never writes.
"""
def _conversation_chunks(
records: list[EvidenceRecord],
*,
max_characters: int | None = None,
max_turns: int | None = None,
completed_only: bool = False,
) -> list[tuple[EvidenceRecord, ...]]:
"""Build bounded, complete user/assistant/tool windows."""
turns: list[list[EvidenceRecord]] = []
for record in records:
if not _is_hy_turn_record(record):
continue
# Until the first user message, the group is leading records only.
leading = bool(turns) and not any(_opens_turn(item) for item in turns[-1])
if not turns or (_opens_turn(record) and not leading):
pending_proposal = bool(turns) and not _opens_turn(turns[-1][0])
if not turns or (_opens_turn(record) and not pending_proposal):
turns.append([record])
else:
turns[-1].append(record)
return [tuple(turn) for turn in turns]
if completed_only and turns and _trailing_user_turn_is_open(turns[-1]):
turns.pop()
whole = _whole_turns() and max_characters is None
if whole:
size = _hy_read_length
max_characters = _WHOLE_TURN_CHARACTERS
max_turns = max_turns or 1_000_000
else:
size = lambda record: len(record.verbatim) # noqa: E731
max_characters = max_characters or 12_000
max_turns = max_turns or 24
chunks: list[tuple[EvidenceRecord, ...]] = []
for turn in turns:
original_question = turn[0] if _opens_turn(turn[0]) else None
expanded: list[EvidenceRecord] = []
for record in turn:
reserve = (
size(original_question)
if original_question is not None
and record is not original_question
and size(original_question) < max_characters
else 0
)
segment_limit = max(1, max_characters - reserve)
if size(record) <= segment_limit:
expanded.append(record)
continue
spans = _semantic_segment_spans(record.verbatim, segment_limit)
expanded.extend(
segment_evidence_record(record, index=index, start=start, end=end)
if len(spans) > 1
else record
for index, (start, end) in enumerate(spans)
)
question = expanded[0] if expanded and _opens_turn(expanded[0]) else None
current: list[EvidenceRecord] = []
for record in expanded:
if current and (
len(current) >= max_turns
or sum(size(item) for item in current) + size(record) > max_characters
or sum(_window_bytes(item) for item in current) + _window_bytes(record)
> _WINDOW_BYTE_CEILING
):
chunks.append(tuple(current))
current = (
[question]
if question is not None
and max_turns > 1
and size(question) + size(record) <= max_characters
else []
)
current.append(record)
chunks.append(tuple(current))
return _merge_tool_only_chunks(chunks)
def turn_batches(records: Sequence[EvidenceRecord], *, flush: bool) -> TurnBatches:
"""Group a conversation's records into write batches by the turn rule.
def _semantic_segment_spans(text: str, limit: int) -> list[tuple[int, int]]:
if len(text) <= limit:
return [(0, len(text))]
spans: list[tuple[int, int]] = []
start = 0
separators = ("\n\n", "\n", "。", "!", "?", ". ", "! ", "? ")
while start < len(text):
hard_end = min(len(text), start + limit)
if hard_end == len(text):
end = hard_end
else:
floor = start + max(1, limit // 2)
candidates = [text.rfind(separator, floor, hard_end) for separator in separators]
boundary = max(candidates)
if boundary < floor:
end = hard_end
else:
matched = next(
separator for separator in separators if text.startswith(separator, boundary)
)
end = boundary + len(matched)
spans.append((start, end))
start = end
return spans
- A turn is complete once the next user message follows it; `flush`
completes the last one too (history at end of file, or 10 idle minutes).
- Every 5 complete turns are one batch. Before 5, a turn that would take
the batch past 8000 characters starts the next batch; one turn over
8000 is a batch by itself and is never split.
- Characters are what HY reads: user/assistant text in full, a tool
record up to `_HY_TOOL_CHARACTERS`.
- Fewer than 5 complete turns within 8000 are written only on `flush`.
- No batch is written without a user message; records with none before
them are carried, and dropped on `flush`.
"""
turns = conversation_turns(records)
if turns and not any(_opens_turn(record) for record in turns[0]):
# Records with no user message yet: nothing to write until one comes.
return TurnBatches(batches=(), carry=() if flush else turns[0])
complete = turns if flush else turns[:-1]
open_tail = () if flush or not turns else turns[-1]
batches: list[tuple[EvidenceRecord, ...]] = []
current: list[tuple[EvidenceRecord, ...]] = []
def _merge_tool_only_chunks(
chunks: list[tuple[EvidenceRecord, ...]],
) -> list[tuple[EvidenceRecord, ...]]:
if not any(chunk and all(record.role == "tool" for record in chunk) for chunk in chunks):
return chunks
merged: list[tuple[EvidenceRecord, ...]] = []
leading_tools: list[EvidenceRecord] = []
for chunk in chunks:
if not chunk:
continue
if all(record.role == "tool" for record in chunk):
if merged:
merged[-1] = (*merged[-1], *chunk)
else:
leading_tools.extend(chunk)
continue
if leading_tools:
chunk = (*leading_tools, *chunk)
leading_tools = []
merged.append(chunk)
return [] if leading_tools else merged
def _merge_historical_chunks(
chunks: list[tuple[EvidenceRecord, ...]],
*,
max_turns: int = _HISTORY_MERGE_TURNS,
max_characters: int = _HISTORY_MERGE_CHARACTERS,
) -> list[tuple[EvidenceRecord, ...]]:
if not chunks:
return []
merged: list[tuple[EvidenceRecord, ...]] = []
current: list[EvidenceRecord] = []
current_turns = 0
current_characters = 0
for turn in complete:
characters = sum(_hy_read_length(record) for record in turn)
if current and current_characters + characters > TURN_BATCH_CHARACTERS:
batches.append(tuple(record for item in current for record in item))
current, current_characters = [], 0
current.append(turn)
current_characters += characters
if len(current) >= TURN_BATCH_TURNS or current_characters > TURN_BATCH_CHARACTERS:
batches.append(tuple(record for item in current for record in item))
current, current_characters = [], 0
if current and flush:
batches.append(tuple(record for item in current for record in item))
current = []
carry = tuple(record for item in current for record in item) + tuple(open_tail)
return TurnBatches(batches=tuple(batches), carry=carry)
for chunk in chunks:
chunk_characters = sum(len(record.verbatim) for record in chunk)
if current and (
current_turns >= max_turns or current_characters + chunk_characters > max_characters
):
merged.append(tuple(current))
current = []
current_turns = 0
current_characters = 0
current.extend(chunk)
current_turns += 1
current_characters += chunk_characters
if current:
merged.append(tuple(current))
return merged
def _chunk_input_ref(chunk: tuple[EvidenceRecord, ...]) -> str:
user_records = tuple(item for item in chunk if item.role == "user")
return user_records[-1].evidence_id if user_records else chunk[-1].evidence_id
def _opens_turn(record: EvidenceRecord) -> bool:
return record.role == "user"
def _trailing_user_turn_is_open(turn: list[EvidenceRecord]) -> bool:
if len(turn) != 1:
return False
opener = turn[0]
return (
_opens_turn(opener)
and opener.content_kind is EvidenceContentKind.RAW_CONVERSATION
and not _is_hand_added_memory(opener)
)
def _is_hy_turn_record(record: EvidenceRecord) -> bool:
if record.role == "tool":
return record.content_kind is EvidenceContentKind.RAW_CONVERSATION and bool(
@@ -161,6 +254,23 @@ def _is_hy_input_turn(record: EvidenceRecord) -> bool:
return _HOST_ENVELOPE.search(source[:2_000]) is None
def _is_hand_added_memory(record: EvidenceRecord) -> bool:
meta = record.origin_meta.get(EvidenceOrigin.MANUAL_IMPORT)
return (
record.source_id == "one-memory-manual"
and meta is not None
and meta.raw_meta.get("input_mode") == "direct_memory_note"
)
def _window_bytes(record: EvidenceRecord) -> int:
return len(record.verbatim.encode("utf-8"))
def _whole_turns() -> bool:
return os.environ.get("ONE_MEMORY_SPLIT_TURNS", "").strip() != "1"
def _hy_read_length(record: EvidenceRecord) -> int:
if record.role == "tool":
return min(len(record.verbatim), _HY_TOOL_CHARACTERS)
@@ -82,10 +82,7 @@ def repair_sdk_metadata_from_bundle(
database_path: Path,
now: datetime | None = None,
) -> MetadataRepairResult:
"""Restore metadata lost by historical cutovers from their verified bundles.
Offline only: no SDK client may be open on this home (see `_repair_vector`).
"""
"""Restore metadata lost by historical cutovers from their verified bundles."""
migration_id = _sdk_migration_id(database_path)
if migration_id is None:
return MetadataRepairResult(None, 0, 0, 0)
@@ -332,11 +329,8 @@ def _repair_vector(
from one_memory.sdk_vector_reattach import open_offline_chroma
client = open_offline_chroma(directory)
# This must be the only Chroma client on the directory: Chroma shares one
# System per path, and this client's close() drops it from under any
# other (KeyError on its next lookup). `sdk-migrate` runs it after the
# cutover with Runtime stopped; Core's startup fallback runs it while
# `hold_stores_offline` keeps every SDK client waiting.
# The SDK client is not open yet (startup has not created its handle), so
# this is the only writer; release the store before the SDK takes it.
try:
repaired = 0
ids = tuple(mapping)
@@ -36,11 +36,7 @@ from one_memory.hy_graph_admin import (
)
from one_memory.hy_sdk_types import HySdkRuntimeError
from one_memory.local_embedding import LocalEmbedder
from one_memory.model_profiles import (
ModelProfileSnapshot,
ProviderKind,
disable_thinking_params,
)
from one_memory.model_profiles import ModelProfileSnapshot, ProviderKind
_RUNTIME_LOCK = threading.RLock()
BUILTIN_RETRIEVAL_PROFILE_ID = "builtin-retrieval"
@@ -56,16 +52,6 @@ class _RuntimeEntry:
_RUNTIMES: dict[Path, _RuntimeEntry] = {}
# Homes whose SDK-owned local stores are held offline for maintenance, and
# the client constructions in flight per home. Chroma keeps one process-wide
# System per path with no lock around create, lookup and release, so a
# second client that opens or closes beside the SDK's pulls the System out
# from under it (KeyError on the next lookup). Maintenance therefore runs
# only while no SDK client exists for that home, and new clients wait.
_STORE_GATE = threading.Condition()
_OFFLINE_STORES: set[Path] = set()
_OPENING: dict[Path, int] = {}
class LocalOnnxEmbeddingService:
"""Public ``EmbeddingService`` implementation backed by MemHub's ONNX model."""
@@ -258,22 +244,14 @@ def build_sdk_config(
embedding_provider, embedding_endpoint, embedding_model, dimensions
)
llm: dict[str, Any] = {
"provider": "openai",
"model": profile.model,
"api_key": generation_key,
"base_url": generation_endpoint,
}
# The SDK sends `extra_body` on every chat call, chat and coding/working
# alike. Without it DeepSeek spent the whole agent_max_tokens on thinking
# and answered finish_reason=length with no content.
thinking_off = disable_thinking_params(profile)
if thinking_off:
llm["extra_body"] = thinking_off
return MemoryConfig.from_dict(
{
"llm": llm,
"llm": {
"provider": "openai",
"model": profile.model,
"api_key": generation_key,
"base_url": generation_endpoint,
},
"embedder": {
"provider": "openai",
"model": (
@@ -317,60 +295,7 @@ def acquire_client(
data_dir: Path,
http_transport_factory: Callable[[], Any] | None = None,
) -> ClientLease:
"""Construct a public SDK client and return its explicit runtime lease.
Waits while `hold_stores_offline` holds this home's stores.
"""
key = data_dir.resolve()
with _STORE_GATE:
while key in _OFFLINE_STORES:
_STORE_GATE.wait()
_OPENING[key] = _OPENING.get(key, 0) + 1
try:
return _acquire_client(
profile,
api_key=api_key,
data_dir=data_dir,
http_transport_factory=http_transport_factory,
)
finally:
with _STORE_GATE:
_OPENING[key] -= 1
if not _OPENING[key]:
del _OPENING[key]
_STORE_GATE.notify_all()
def hold_stores_offline(data_dir: Path) -> bool:
"""Hold this home's SDK stores for offline maintenance; False if a client is open.
While held, `acquire_client` for the home waits. Every SDK client in Core
is built there, so nothing reads or writes Chroma beside the maintenance.
`release_stores` ends the hold; it is idempotent.
"""
key = data_dir.resolve()
with _STORE_GATE:
with _RUNTIME_LOCK:
open_runtime = key in _RUNTIMES
if key in _OFFLINE_STORES or _OPENING.get(key) or open_runtime:
return False
_OFFLINE_STORES.add(key)
return True
def release_stores(data_dir: Path) -> None:
with _STORE_GATE:
_OFFLINE_STORES.discard(data_dir.resolve())
_STORE_GATE.notify_all()
def _acquire_client(
profile: ModelProfileSnapshot,
*,
api_key: str | None,
data_dir: Path,
http_transport_factory: Callable[[], Any] | None,
) -> ClientLease:
"""Construct a public SDK client and return its explicit runtime lease."""
config = build_sdk_config(profile, api_key=api_key, data_dir=data_dir)
config.http_transport_factory = http_transport_factory
mismatch = inspect_graph_width_mismatch(config)
@@ -662,15 +662,11 @@ class ImportRunner:
and self.sdk_queue is not None
and event.cursor.read_to_end
):
# History ends at this end of file, so its last turn
# and a short batch are written. Live polling keeps
# them for the next increment or the idle flush.
self.sdk_queue.finalize_sdk_file(
user_id=current.user_id,
source_id=current.source_id,
file_key=event.cursor.file_key,
connection=connection,
flush=not self._live_import,
)
continue
if self.on_stable_evidence is not None and event.cursor.read_to_end:
@@ -256,19 +256,24 @@ def record_capture_rejection(
def _submit_live_memory(store: EvidenceStore, record: EvidenceRecord, *, changed: bool) -> None:
"""Add the record to its conversation's live assembling row.
The turn rule decides what is written (`turn_batches`): a turn is
complete when the next user message arrives, batches are 5 turns or
8000 characters, and Core writes the rest after 10 quiet minutes.
"""
if not changed or not _sync_open(store.database_path, record.source_id):
return
from one_memory.conversation_processing import _conversation_chunks
from one_memory.memory_server.durable_queue import DurableWriteQueue
from one_memory.memory_server.unified_entry import UnifiedWriteIntake
DurableWriteQueue(store.database_path, evidence=store).assemble_live_records(
(record,), file_key=f"hook:{record.conversation_id}"
)
intake = UnifiedWriteIntake(DurableWriteQueue(store.database_path, evidence=store))
for chunk in _conversation_chunks(
store.list_conversation(record.evidence_id),
max_turns=12,
completed_only=True,
):
if (
record in chunk
and any(item.role == "user" for item in chunk)
and any(item.role == "assistant" for item in chunk)
):
intake.submit_records(chunk, priority="live")
def _conversation(payload: dict[str, object]) -> str | None:
@@ -130,18 +130,10 @@ class MemoryAdditionStore:
raise RuntimeError("sdk_memory_add_intake_unavailable")
record = materialize_write_input(value, observed_at=now)
with main_database(self.write_queue.database_path).read() as database:
# No grant row is written here: a missing row means allowed,
# only an explicit revocation refuses (same as the claim gate).
grant = database.execute(
"SELECT revoked_at FROM source_grants WHERE user_id='local' AND source_id=?",
(SOURCE_ID,),
).fetchone()
existing = database.execute(
"SELECT request_id FROM memory_note_requests WHERE note_id=?",
(record.evidence_id,),
).fetchone()
if grant is not None and grant[0] is not None:
raise ValueError("memory_add_source_revoked")
if existing is not None:
current = self.require(record.evidence_id)
if current.verbatim != record.verbatim:
@@ -24,11 +24,10 @@ from collections.abc import Awaitable, Callable
from dataclasses import dataclass
from datetime import UTC, datetime, timedelta
from pathlib import Path
from typing import Any, Literal
from typing import Any
from uuid import uuid4
from one_memory.background_work import in_the_background
from one_memory.conversation_processing import TURN_IDLE_FLUSH_SECONDS, turn_batches
from one_memory.database_access import main_database
from one_memory.diagnostic_log import diagnostic_event
from one_memory.imports.models import EvidenceOrigin, EvidenceRecord
@@ -895,30 +894,16 @@ class DurableWriteQueue:
priority: str,
connection: sqlite3.Connection,
) -> None:
"""Append input to its conversation's assembling queue row.
"""Append fresh-install input to an assembling queue row.
The row is not claimable. `finalize_sdk_file` turns what is complete
into write jobs (`turn_batches`) and keeps the rest here for the next
increment; `flush_idle_assemblies` writes a live row's rest once the
conversation has been quiet. `updated_at` moves only when a record is
new or revised, so a re-read does not keep a quiet conversation open.
No Evidence table is involved.
The row is not claimable until the scanner commits EOF and finalizes
it. No Evidence table is involved.
"""
assembly_key = (
f"assembly:{record.user_id}:{record.source_id}:"
f"{hashlib.sha256(file_key.encode()).hexdigest()}:"
f"{hashlib.sha256(record.conversation_id.encode()).hexdigest()}"
)
session_key = internal_session_id_for(record.conversation_id, record.source_id)
written = connection.execute(
"SELECT 1 FROM memory_write_jobs AS job, json_each(job.evidence_ids_json) AS ref "
"WHERE job.session_key=? AND job.state!='assembling' AND ref.value=? LIMIT 1",
(session_key, record.evidence_id),
).fetchone()
if written is not None:
# A re-read of a record a job already holds: staging it again
# would regroup it with later turns into a second job.
return
row = connection.execute(
"SELECT job_id,payload_json,priority FROM memory_write_jobs "
"WHERE idempotency_key=? AND state='assembling'",
@@ -929,20 +914,8 @@ class DurableWriteQueue:
payload = json.loads(str(row["payload_json"]))
records = list(payload.get("input_records") or [])
dumped = record.model_dump(mode="json")
index = next(
(
position
for position, item in enumerate(records)
if item.get("evidence_id") == record.evidence_id
),
None,
)
changed = index is None or records[index] != dumped
if index is None:
if not any(item.get("evidence_id") == record.evidence_id for item in records):
records.append(dumped)
elif changed:
# A revision replaces the carried copy in place.
records[index] = dumped
now = datetime.now(UTC).isoformat()
payload_json = json.dumps(
{
@@ -953,13 +926,15 @@ class DurableWriteQueue:
sort_keys=True,
)
if row is not None:
merged_priority = "live" if "live" in (row["priority"], priority) else "normal"
if not changed and merged_priority == row["priority"]:
return
connection.execute(
"UPDATE memory_write_jobs SET payload_json=?,priority=?,"
"updated_at=CASE WHEN ? THEN ? ELSE updated_at END WHERE job_id=?",
(payload_json, merged_priority, int(changed), now, str(row["job_id"])),
"UPDATE memory_write_jobs SET payload_json=?,priority=?,updated_at=? "
"WHERE job_id=?",
(
payload_json,
"live" if row["priority"] == "live" or priority == "live" else "normal",
now,
str(row["job_id"]),
),
)
return
queue_seq = int(
@@ -977,7 +952,7 @@ class DurableWriteQueue:
(
str(uuid4()),
queue_seq,
session_key,
internal_session_id_for(record.conversation_id, record.source_id),
record.source_id,
assembly_key,
assembly_key,
@@ -1007,215 +982,92 @@ class DurableWriteQueue:
source_id: str,
file_key: str,
connection: sqlite3.Connection,
flush: bool = True,
) -> tuple[str, ...]:
"""Write what one file's assembling rows hold that the turn rule completes.
`flush` (history at end of file) completes the last turn and writes a
short batch; without it (live polling) those stay in the row.
"""
rows = connection.execute(
"SELECT * FROM memory_write_jobs WHERE state='assembling' "
"AND source_key=? ORDER BY queue_seq",
(source_id,),
).fetchall()
finalized: list[str] = []
finalized = []
for row in rows:
payload = json.loads(str(row["payload_json"]))
if payload.get("file_key") != file_key:
continue
finalized.extend(
self._write_assembled_turns(connection, row, user_id=user_id, flush=flush)
records = tuple(
EvidenceRecord.model_validate(item)
for item in payload.get("input_records") or []
if item.get("user_id") == user_id
)
return tuple(finalized)
def assemble_live_records(
self,
records: tuple[EvidenceRecord, ...],
*,
file_key: str,
) -> tuple[str, ...]:
"""Stage live records and write what is complete, in one transaction.
For live capture outside the import runner (host hooks): the same
assembling rows, turn rule and idle flush as live file polling.
"""
if not records:
return ()
first = records[0]
with (
main_database(self.database_path).write(row_factory=sqlite3.Row) as connection,
connection,
):
for record in records:
self.stage_sdk_input(
record, file_key=file_key, priority="live", connection=connection
if not records:
connection.execute(
"DELETE FROM memory_write_jobs WHERE job_id=?",
(str(row["job_id"]),),
)
return self.finalize_sdk_file(
user_id=first.user_id,
continue
first = records[0]
session_id = internal_session_id_for(
first.conversation_id,
first.source_id,
)
material = ":".join(f"{item.evidence_id}:{item.content_hash}" for item in records)
request_id = f"turn:v2:{hashlib.sha256(material.encode()).hexdigest()}"
existing = connection.execute(
"SELECT job_id FROM memory_write_jobs WHERE idempotency_key=? AND job_id!=?",
(request_id, str(row["job_id"])),
).fetchone()
if existing is not None:
connection.execute(
"DELETE FROM memory_write_jobs WHERE job_id=?",
(str(row["job_id"]),),
)
finalized.append(str(existing["job_id"]))
continue
request = WriteRequest(
internal_session_id=session_id,
source_id=first.source_id,
file_key=file_key,
connection=connection,
flush=False,
batch_identity=BatchIdentity(
internal_session_id=session_id,
first_round=1,
last_round=1,
),
evidence_ids=[item.evidence_id for item in records],
input_records=records,
idempotency_key=request_id,
priority="live" if str(row["priority"]) == "live" else "normal",
event_at=max(
(item.created_at for item in records if item.created_at),
default=first.imported_at,
),
time_quality="exact" if any(item.created_at for item in records) else "approximate",
)
def flush_idle_assemblies(self, *, now: datetime | None = None) -> tuple[str, ...]:
"""Write the rest of every live conversation quiet for 10 minutes.
Its last turn counts as complete and a batch short of 5 turns is
written. History rows are left alone: their end of file flushes them.
"""
current = now or datetime.now(UTC)
cutoff = (current - timedelta(seconds=TURN_IDLE_FLUSH_SECONDS)).isoformat()
query = (
"SELECT * FROM memory_write_jobs WHERE state='assembling' AND priority='live' "
"AND updated_at<=? ORDER BY queue_seq"
)
# A read first: most ticks find nothing and must not take the writer.
with main_database(self.database_path).read(row_factory=sqlite3.Row) as connection:
if connection.execute(query, (cutoff,)).fetchone() is None:
return ()
finalized: list[str] = []
with (
main_database(self.database_path).write(row_factory=sqlite3.Row) as connection,
connection,
):
for row in connection.execute(query, (cutoff,)).fetchall():
finalized.extend(self._write_assembled_turns(connection, row, flush=True))
return tuple(finalized)
def _write_assembled_turns(
self,
connection: sqlite3.Connection,
row: sqlite3.Row,
*,
flush: bool,
user_id: str | None = None,
) -> list[str]:
"""Turn one assembling row's complete batches into jobs; keep the carry."""
payload = json.loads(str(row["payload_json"]))
records = tuple(
EvidenceRecord.model_validate(item)
for item in payload.get("input_records") or []
if user_id is None or item.get("user_id") == user_id
)
grouped = turn_batches(records, flush=flush)
priority: Literal["live", "normal"] = "live" if str(row["priority"]) == "live" else "normal"
job_ids = [
self._insert_turn_batch(connection, batch, priority=priority)
for batch in grouped.batches
]
if not grouped.carry:
connection.execute(
"DELETE FROM memory_write_jobs WHERE job_id=?", (str(row["job_id"]),)
)
elif grouped.batches or len(grouped.carry) != len(records):
# `updated_at` stays: the idle clock counts new messages only.
connection.execute(
"UPDATE memory_write_jobs SET payload_json=? WHERE job_id=?",
"UPDATE memory_write_jobs SET session_key=?,batch_id=?,"
"idempotency_key=?,payload_json=?,evidence_ids_json=?,state='pending',"
"event_at=?,time_quality=?,updated_at=? WHERE job_id=?",
(
session_id,
request.batch_identity.batch_id,
request_id,
json.dumps(
{
**payload,
"input_records": [
record.model_dump(mode="json") for record in grouped.carry
],
},
request.model_dump(
mode="json",
exclude={"event_at", "time_quality"},
),
sort_keys=True,
),
json.dumps(list(request.evidence_ids)),
(
request.event_at.isoformat()
if request.event_at
else datetime.now(UTC).isoformat()
),
request.time_quality,
datetime.now(UTC).isoformat(),
str(row["job_id"]),
),
)
return job_ids
def _insert_turn_batch(
self,
connection: sqlite3.Connection,
records: tuple[EvidenceRecord, ...],
*,
priority: Literal["live", "normal"],
) -> str:
"""One pending job for one batch of whole turns, keyed by its content.
Same records, same key: a re-read returns the job already written.
"""
first = records[0]
session_id = internal_session_id_for(first.conversation_id, first.source_id)
material = ":".join(f"{item.evidence_id}:{item.content_hash}" for item in records)
request_id = f"turn:v2:{hashlib.sha256(material.encode()).hexdigest()}"
existing = connection.execute(
"SELECT job_id FROM memory_write_jobs WHERE idempotency_key=?",
(request_id,),
).fetchone()
if existing is not None:
return str(existing["job_id"])
request = WriteRequest(
internal_session_id=session_id,
source_id=first.source_id,
batch_identity=BatchIdentity(
internal_session_id=session_id,
first_round=1,
last_round=1,
),
evidence_ids=[item.evidence_id for item in records],
input_records=records,
idempotency_key=request_id,
priority=priority,
)
# The batch's time is its first user message's, as for every other job.
event_at, time_quality = self._event_time_for_request(request)
now = datetime.now(UTC)
job_id = str(uuid4())
queue_seq = int(
connection.execute(
"SELECT COALESCE(MAX(queue_seq),0)+1 FROM memory_write_jobs"
).fetchone()[0]
)
event = event_at or now
if event.tzinfo is None:
event = event.replace(tzinfo=UTC)
connection.execute(
"INSERT INTO memory_write_jobs ("
"job_id,queue_seq,session_key,source_key,batch_id,idempotency_key,"
"payload_json,evidence_ids_json,state,priority,attempts,max_attempts,"
"lease_owner,lease_expires_at,hy_invoked,last_error,event_at,time_quality,"
"created_at,updated_at,sdk_raw_ids_json,sdk_memory_ids_json"
") VALUES (?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?)",
(
job_id,
queue_seq,
session_id,
first.source_id,
request.batch_identity.batch_id,
request_id,
json.dumps(
request.model_dump(mode="json", exclude={"event_at", "time_quality"}),
sort_keys=True,
),
json.dumps(list(request.evidence_ids)),
PENDING,
priority,
0,
3,
None,
None,
0,
None,
event.astimezone(UTC).isoformat(),
time_quality,
now.isoformat(),
now.isoformat(),
"[]",
"[]",
),
)
if not self.sdk_authority:
# Legacy catch-up reads coverage from these references.
connection.executemany(
"INSERT INTO memory_write_job_evidence (job_id, evidence_id) VALUES (?,?)",
((job_id, evidence_id) for evidence_id in request.evidence_ids),
)
return job_id
finalized.append(str(row["job_id"]))
return tuple(finalized)
def _freeze_request_inputs(self, request: WriteRequest) -> WriteRequest:
if request.input_records or self.evidence is None:
@@ -2648,9 +2500,6 @@ class DurableWriteDrain:
#: a model outage clears on its own, and a queue that never goes idle
#: must still re-admit the write once the wait has elapsed.
self._last_model_readmit: datetime | None = None
#: A live conversation quiet for 10 minutes has its last turn and short
#: batch written (`flush_idle_assemblies`); checked on the same timer.
self._last_assembly_flush: datetime | None = None
#: Idle polls do not need to hit the single writer every 50ms. A
#: 1m Evidence table makes a miss expensive enough that the tail
#: call-event write can be behind it; back off only while idle and
@@ -3183,15 +3032,6 @@ class DurableWriteDrain:
self.queue.readmit_model_waiters,
operation_name="readmit_model_waiters",
)
if (
self._last_assembly_flush is None
or tick_started - self._last_assembly_flush >= self._reap_interval
):
self._last_assembly_flush = tick_started
await self._run_idle_maintenance(
self.queue.flush_idle_assemblies,
operation_name="flush_idle_assemblies",
)
if self.reconcile_unknown is not None and (
self._last_unknown_reconcile is None
or tick_started - self._last_unknown_reconcile
@@ -1136,31 +1136,20 @@ class ModelProfileStore:
)
def disable_thinking_params(
profile: ModelProfile | ModelProfileSnapshot,
) -> dict[str, object]:
"""Provider-specific thinking-off params, empty unless the profile opts in.
The one mapping for every request Core builds itself and for the SDK's
`llm.extra_body`, so a model that thinks on one path thinks on none.
"""
if not profile.disable_thinking:
return {}
if profile.provider_kind is ProviderKind.OPENAI_COMPATIBLE:
if urlparse(profile.base_url or "").hostname == "api.deepseek.com":
return {"thinking": {"type": "disabled"}}
return {"reasoning_effort": "none"}
if profile.provider_kind is ProviderKind.OLLAMA:
return {"think": False}
return {}
def attach_disable_thinking(
profile: ModelProfile | ModelProfileSnapshot,
payload: dict[str, object],
) -> dict[str, object]:
"""Attach provider-specific thinking-off params when the profile opts in."""
payload.update(disable_thinking_params(profile))
if not profile.disable_thinking:
return payload
if profile.provider_kind is ProviderKind.OPENAI_COMPATIBLE:
if urlparse(profile.base_url or "").hostname == "api.deepseek.com":
payload["thinking"] = {"type": "disabled"}
else:
payload["reasoning_effort"] = "none"
elif profile.provider_kind is ProviderKind.OLLAMA:
payload["think"] = False
return payload
@@ -125,31 +125,6 @@ def describe_error(error: BaseException) -> dict[str, object]:
return {"chain": chain}
_PY_FRAME = re.compile(r"^[A-Za-z0-9_][A-Za-z0-9_.\-]*\.py:\d{1,7}:[A-Za-z_][A-Za-z0-9_]*$")
def error_frames(error: BaseException, *, limit: int = 16) -> tuple[str, ...]:
"""The raised error's innermost `file.py:line:function` frames, for a stack field.
Library frames are kept (a KeyError raised inside Chroma is located by
them); `<module>`-style names are dropped because stack fields refuse them.
"""
chain = describe_error(error)["chain"]
frames = chain[0].get("frames", ()) if chain else () # type: ignore[index,union-attr]
return tuple(frame for frame in frames if _PY_FRAME.fullmatch(frame))[-limit:]
def error_chain_text(error: BaseException) -> str:
"""`describe_error` as one log token: `Type@file:line:function>...;Cause@...`.
For a plain text log (core.log). Types and frames only, never a message.
"""
return ";".join(
f"{entry['type']}@{'>'.join(entry.get('frames', ()))}" # type: ignore[arg-type]
for entry in describe_error(error)["chain"] # type: ignore[union-attr]
)
def _build() -> dict[str, object]:
from one_memory import __version__
@@ -829,7 +829,6 @@ def run_startup_migration(
ignore_errors=True,
)
discard_failed_state(data_dir)
_repair_metadata_after_cutover(data_dir, database_path, log)
progress("cleanup")
with log.step("cleanup.drop_legacy_tables") as step:
dropped = resume_legacy_cleanup(data_dir=data_dir, database_path=database_path)
@@ -848,25 +847,6 @@ def run_startup_migration(
)
def _repair_metadata_after_cutover(data_dir: Path, database_path: Path, log: _StepLog) -> None:
"""Restore lost metadata here, where Runtime is stopped and no Chroma client is open.
Writes the repair receipt, so Core's startup fallback finds it and does
nothing. Authority is already committed: a failure is logged and left to
that fallback instead of failing the migration.
"""
from one_memory.hy_sdk_migration import repair_sdk_metadata_from_bundle
try:
with log.step("cutover.metadata_repair") as step:
repair = repair_sdk_metadata_from_bundle(data_dir=data_dir, database_path=database_path)
step["mapped"] = repair.mapped
step["vector_rows"] = repair.vector_rows
step["coding_rows"] = repair.coding_rows
except Exception:
return
def _restore_after_failure(
*,
data_dir: Path,
@@ -16,7 +16,6 @@ from one_memory.secrets import SecretStore, SecretStoreUnavailableError
PRODUCT_AGENT_ID = "all-apps"
_MESSAGE_ROLES = frozenset({"user", "assistant", "tool"})
_CONVERSATION_ROLES = frozenset({"user", "assistant"})
@dataclass(frozen=True)
@@ -42,23 +41,12 @@ class SdkWriteProcessor:
) -> SdkWriteReceipt:
if not records:
raise RuntimeError("hy_memory_task_without_inputs")
extracted = all(_is_extracted_content(record) for record in records)
if not extracted and not any(
record.content_kind is EvidenceContentKind.RAW_CONVERSATION
and record.role in _CONVERSATION_ROLES
for record in records
):
# Nothing to remember: tool records alone. The SDK strips them on
# the chat path and answers 400 "content or messages is
# required", which used to settle the job as unknown. Completed
# with no memories, and the SDK is never called.
return SdkWriteReceipt()
scope = _write_scope(records)
metadata = _write_metadata(records, scope)
client = await self._client_handle.client()
if extracted:
if all(_is_extracted_content(record) for record in records):
contents = [sanitize_model_input(record.verbatim) for record in records]
await before_hy_invocation()
result = await asyncio.to_thread(
@@ -1,199 +1,90 @@
"""The turn rule: what a conversation's records become before they reach HY.
A turn is one user message and everything up to the next one. Every 5
complete turns are written together, sooner when the next turn would pass
8000 characters; a short batch waits for the end of a history file or 10
quiet minutes. No batch is written without a user message.
"""
"""A proposal and the turn that adopts it must reach HY as one turn."""
from __future__ import annotations
from datetime import UTC, datetime
from one_memory.conversation_processing import (
TURN_BATCH_CHARACTERS,
TURN_BATCH_TURNS,
conversation_turns,
turn_batches,
)
from one_memory.conversation_processing import _conversation_chunks
from one_memory.imports.models import EvidenceInput, EvidenceOrigin, OriginMetadata, WrittenBy
from one_memory.imports.storage import EvidenceStore
_WRITER = {"user": "person", "assistant": "agent", "tool": "tool"}
def _records(tmp_path, *speakers: tuple[str, str]) -> list:
def _records(tmp_path, *speakers: tuple[str, str, str]) -> list:
store = EvidenceStore(tmp_path / "one-memory.sqlite3")
return [
store.put(
EvidenceInput(
source_id="cursor",
conversation_id="conversation",
message_id=f"m{index}",
role=role,
written_by=WrittenBy(_WRITER[role]),
verbatim=text,
created_at=datetime.now(UTC),
origin_meta={EvidenceOrigin.FILE: OriginMetadata()},
)
).record
for index, (role, text) in enumerate(speakers)
]
records = []
for index, (role, written_by, text) in enumerate(speakers):
records.append(
store.put(
EvidenceInput(
source_id="cursor",
conversation_id="conversation",
message_id=f"m{index}",
role=role,
written_by=WrittenBy(written_by),
verbatim=text,
created_at=datetime.now(UTC),
origin_meta={EvidenceOrigin.FILE: OriginMetadata()},
)
).record
)
return records
def _turns(count: int, *, answer: str = "好的。") -> list[tuple[str, str]]:
return [
part
for index in range(count)
for part in (("user", f"第 {index} 个问题"), ("assistant", answer))
]
def test_an_unanswered_assistant_prefix_joins_the_turn_that_adopts_it(tmp_path) -> None:
"""`assistant` proposal -> `user` explicit adoption is one turn, not two.
def _roles(batches) -> list[list[str]]:
return [[record.role for record in batch] for batch in batches]
def test_rule_constants_are_the_product_rule() -> None:
assert (TURN_BATCH_TURNS, TURN_BATCH_CHARACTERS) == (5, 8_000)
def test_a_turn_runs_from_one_user_message_to_the_next(tmp_path) -> None:
records = _records(
tmp_path,
("user", "跑一下测试"),
("assistant", "好"),
("tool", "3 passed"),
("assistant", "都通过了"),
("user", "提交吧"),
("assistant", "已提交"),
)
assert [len(turn) for turn in conversation_turns(records)] == [4, 2]
def test_records_before_the_first_user_message_join_the_first_turn(tmp_path) -> None:
"""An assistant proposal and the user turn adopting it reach HY together."""
records = _records(
tmp_path,
("assistant", "建议把超时改成 30 秒。"),
("user", "就这么做,超时改成 30 秒。"),
("assistant", "改好了。"),
("user", "再把重试改成 3 次。"),
("assistant", "也改好了。"),
)
assert _roles(conversation_turns(records)) == [
["assistant", "user", "assistant"],
["user", "assistant"],
]
def test_five_complete_turns_are_one_batch_and_the_open_turn_waits(tmp_path) -> None:
records = _records(tmp_path, *_turns(6))
grouped = turn_batches(records, flush=False)
assert [len(batch) for batch in grouped.batches] == [10]
assert grouped.batches[0] == tuple(records[:10])
assert grouped.carry == tuple(records[10:])
def test_complete_turns_short_of_five_wait_for_flush(tmp_path) -> None:
records = _records(tmp_path, *_turns(3))
waiting = turn_batches(records, flush=False)
assert waiting.batches == ()
assert waiting.carry == tuple(records)
flushed = turn_batches(records, flush=True)
assert flushed.batches == (tuple(records),)
assert flushed.carry == ()
def test_a_lone_user_message_is_not_written_until_flush(tmp_path) -> None:
records = _records(tmp_path, ("user", "把超时改成 30 秒。"))
assert turn_batches(records, flush=False).batches == ()
assert turn_batches(records, flush=True).batches == (tuple(records),)
def test_the_turn_that_would_pass_8000_characters_starts_the_next_batch(tmp_path) -> None:
answer = "句" * 3_000
records = _records(tmp_path, *_turns(4, answer=answer))
grouped = turn_batches(records, flush=False)
# Turns 1+2 fit; turn 3 would pass 8000, so 1+2 are written now. Turn 3
# is complete but alone, and turn 4 is still open.
assert grouped.batches == (tuple(records[:4]),)
assert grouped.carry == tuple(records[4:])
assert sum(len(record.verbatim) for record in grouped.batches[0]) <= TURN_BATCH_CHARACTERS
def test_one_turn_over_8000_characters_is_a_batch_of_its_own_and_not_split(tmp_path) -> None:
records = _records(
tmp_path,
("user", "先问个小问题"),
("assistant", "好"),
("user", "写一份方案"),
("assistant", "段落。" * 7_000),
("user", "谢谢"),
)
grouped = turn_batches(records, flush=False)
assert grouped.batches == (tuple(records[:2]), tuple(records[2:4]))
assert grouped.carry == (records[4],)
assert all(":segment:" not in record.evidence_id for record in grouped.batches[1])
def test_tool_output_counts_only_what_hy_reads(tmp_path) -> None:
"""A tool record counts up to `_HY_TOOL_CHARACTERS`, not its full text."""
from one_memory.conversation_processing import _HY_TOOL_CHARACTERS
parts = [
part
for index in range(5)
for part in (("user", f"第 {index} 步"), ("tool", "x" * 50_000), ("assistant", "完成"))
]
records = _records(tmp_path, *parts, ("user", "下一步"))
grouped = turn_batches(records, flush=False)
assert _HY_TOOL_CHARACTERS == 512
assert [len(batch) for batch in grouped.batches] == [15]
def test_records_with_no_user_message_are_never_a_batch(tmp_path) -> None:
"""The SDK strips tool records on the chat path and refuses what is left.
A tool-only batch came back 400 "content or messages is required" and
was recorded unknown, 16 times. Carried until a user message arrives;
dropped when the conversation is flushed without one.
Splitting it sends HY an answer without what it answers, and loses the
authority pair the author/adoption quality gate depends on.
"""
records = _records(tmp_path, ("tool", "ls output"), ("assistant", "看完了"))
waiting = turn_batches(records, flush=False)
assert waiting.batches == ()
assert waiting.carry == tuple(records)
assert turn_batches(records, flush=True) == turn_batches((), flush=True)
later = _records(tmp_path, ("user", "那就开始吧"), ("assistant", "开始"))
joined = turn_batches((*waiting.carry, *later), flush=True)
assert _roles(joined.batches) == [["tool", "assistant", "user", "assistant"]]
def test_every_batch_holds_a_user_message(tmp_path) -> None:
records = _records(
tmp_path,
("tool", "前置输出"),
*_turns(7, answer="答" * 2_500),
("tool", "尾部输出"),
("assistant", "agent", "建议把超时改成 30 秒。"),
("user", "person", "就这么做,超时改成 30 秒。"),
)
for flush in (False, True):
grouped = turn_batches(records, flush=flush)
assert grouped.batches
assert all(any(record.role == "user" for record in batch) for batch in grouped.batches)
written = [record for batch in grouped.batches for record in batch]
assert written + list(grouped.carry) == records
chunks = _conversation_chunks(records)
assert [[record.role for record in chunk] for chunk in chunks] == [["assistant", "user"]]
def test_a_lone_user_message_does_not_become_a_model_input(tmp_path) -> None:
"""A user message with no reply yet is Evidence, not a completed turn.
Concurrent-import fixtures write `user` then `assistant` pairs, but a lone
user message must not create an HY task: that is the complete-turn rule,
not a queue stall.
"""
records = _records(tmp_path, ("user", "person", "把超时改成 30 秒。"))
assert _conversation_chunks(records, completed_only=True) == []
def test_only_the_unanswered_trailing_turn_is_held_back(tmp_path) -> None:
"""Earlier complete turns stay eligible while the open tail waits."""
records = _records(
tmp_path,
("user", "person", "先把超时改成 30 秒。"),
("assistant", "agent", "已经改好了。"),
("user", "person", "接着把发布包传上去。"),
)
assert _conversation_chunks(records, completed_only=True) == [(records[0], records[1])]
def test_the_complete_turn_contract_used_for_concurrent_import_reds(tmp_path) -> None:
"""Node-id level answer for `[BLOCK C2 -> C1]` concurrent-import reds.
Those fixtures write `user` turns and never emit the closing
`assistant`/tool event, so under the complete-turn rule no HY task is
created inside the 5s window. That is the contract, not a queue stall:
Evidence is still committed and searchable, and the task appears once the
closing event arrives.
"""
closed = _records(
tmp_path,
("user", "person", "把超时改成 30 秒。"),
("assistant", "agent", "已改为 30 秒。"),
)
assert [[record.role for record in chunk] for chunk in _conversation_chunks(closed)] == [
["user", "assistant"]
]
@@ -142,112 +142,3 @@ def test_shutdown_during_recovery_stops_it_without_starting_more(tmp_path, monke
release.set()
asyncio.run(scenario())
def test_metadata_repair_never_overlaps_an_sdk_client(tmp_path, monkeypatch):
"""The repair's own Chroma client dropped the SDK's shared System (KeyError).
The first library request opened the SDK client while the repair ran
after readiness. Now every SDK client of the home waits for the repair.
"""
from one_memory import hy_sdk_runtime
from one_memory.hy_sdk_runtime import ClientLease
release = threading.Event()
entered = threading.Event()
events: list[str] = []
def slow_repair(**_):
entered.set()
events.append("repair_started")
release.wait(10)
events.append("repair_finished")
return MetadataRepairResult(None, 0, 0, 0)
def open_client(profile, *, api_key, data_dir, http_transport_factory):
events.append("client_opened")
return ClientLease(object(), data_dir.resolve())
monkeypatch.setattr(hy_sdk_migration, "repair_sdk_metadata_from_bundle", slow_repair)
monkeypatch.setattr(hy_sdk_runtime, "_acquire_client", open_client)
monkeypatch.setattr(ClientLease, "close", lambda self: None)
async def scenario():
app = application(tmp_path)
try:
async with app.router.lifespan_context(app), client_for(app) as client:
assert await asyncio.to_thread(entered.wait, 5)
handle = hy_sdk_runtime.SdkClientHandle(
profile=hy_sdk_runtime.builtin_retrieval_snapshot(),
api_key=None,
data_dir=app.state.settings.data_dir,
)
opening = asyncio.create_task(handle.client())
await asyncio.sleep(0.2)
# Readiness and reads answer; the SDK client waits.
assert (await health(client))["status"] == "ok"
assert not opening.done()
release.set()
await asyncio.wait_for(opening, 5)
await wait_for_recovery(client)
finally:
release.set()
asyncio.run(scenario())
assert events == ["repair_started", "repair_finished", "client_opened"]
def test_stores_are_not_held_beside_an_open_sdk_client(tmp_path):
from one_memory import hy_sdk_runtime
key = tmp_path.resolve()
with hy_sdk_runtime._STORE_GATE:
hy_sdk_runtime._OPENING[key] = 1
try:
assert hy_sdk_runtime.hold_stores_offline(tmp_path) is False
finally:
with hy_sdk_runtime._STORE_GATE:
del hy_sdk_runtime._OPENING[key]
assert hy_sdk_runtime.hold_stores_offline(tmp_path) is True
assert hy_sdk_runtime.hold_stores_offline(tmp_path) is False
hy_sdk_runtime.release_stores(tmp_path)
hy_sdk_runtime.release_stores(tmp_path)
assert hy_sdk_runtime.hold_stores_offline(tmp_path) is True
hy_sdk_runtime.release_stores(tmp_path)
def test_a_failed_recovery_step_records_where_it_failed(tmp_path, monkeypatch, caplog):
"""`error_type=KeyError` alone named no location; frames, never the message."""
from one_memory import api
def lookup(systems):
return systems["private-identifier"]
def broken_repair(**_):
return lookup({})
events: list[dict] = []
monkeypatch.setattr(hy_sdk_migration, "repair_sdk_metadata_from_bundle", broken_repair)
monkeypatch.setattr(api, "diagnostic_event", lambda **values: events.append(values))
async def scenario():
app = application(tmp_path)
async with app.router.lifespan_context(app), client_for(app) as client:
await wait_for_recovery(client)
with caplog.at_level("WARNING"):
asyncio.run(scenario())
[line] = [
record.getMessage()
for record in caplog.records
if "core_recovery_step_failed" in record.getMessage()
]
assert "step=sdk_metadata_repair error_type=KeyError chain=KeyError@" in line
assert "test_core_ready_before_recovery.py:" in line and ":lookup" in line
assert "private-identifier" not in line
[failed] = [event for event in events if event.get("event") == "core.recovery_step_failed"]
assert failed["error_type"] == "KeyError"
assert failed["safe_stack"][-1].startswith("test_core_ready_before_recovery.py:")
assert failed["safe_stack"][-1].endswith(":lookup")
assert "private-identifier" not in repr(failed)
@@ -8,7 +8,6 @@ import json
import httpx
from pydantic import SecretStr
from one_memory.hy_sdk_runtime import build_sdk_config
from one_memory.imports.models import (
EvidenceInput,
EvidenceOrigin,
@@ -18,7 +17,6 @@ from one_memory.imports.models import (
from one_memory.imports.storage import EvidenceStore
from one_memory.memory_batch_extraction import MemoryBatchExtractor
from one_memory.memory_qa import _answer_request
from one_memory.model_probe import _structured_request
from one_memory.model_profiles import (
ModelProfileInput,
ModelProfileStore,
@@ -245,94 +243,3 @@ def test_ollama_batch_sends_think_false_only_when_disabled(tmp_path) -> None:
)
assert payloads[0]["think"] is False
assert "reasoning_effort" not in payloads[0]
def _deepseek(store, secrets, *, disable_thinking: bool):
return store.create(
ModelProfileInput(
name=f"deepseek-{disable_thinking}",
provider_kind=ProviderKind.OPENAI_COMPATIBLE,
base_url="https://api.deepseek.com/v1",
model="deepseek-v4-flash",
api_key=SecretStr("sk-test"),
embedding_model="embedding-model",
embedding_provider_kind=ProviderKind.LOCAL_ONNX,
disable_thinking=disable_thinking,
),
secrets,
)
def test_sdk_config_turns_deepseek_thinking_off_like_the_probe(tmp_path) -> None:
"""DeepSeek Flash spent the SDK's 2000 agent tokens thinking: EMPTY_RESPONSE.
The SDK sends `llm.extra_body` on every chat call (chat and the shared
coding/working provider), and the probe must ask the model the same way.
"""
database = tmp_path / "one-memory.sqlite3"
secrets = InMemorySecretStore()
store = ModelProfileStore(database)
profile = _deepseek(store, secrets, disable_thinking=True)
snapshot = store.snapshot(profile.profile_id, profile.revision)
config = build_sdk_config(snapshot, api_key="sk-test", data_dir=tmp_path / "data")
assert config.llm.extra_body == {"thinking": {"type": "disabled"}}
_m, _u, _h, probe = _structured_request(profile, "sk-test")
assert probe["thinking"] == config.llm.extra_body["thinking"]
assert "reasoning_effort" not in probe
def test_sdk_config_sends_no_thinking_fields_unless_the_profile_opts_in(tmp_path) -> None:
database = tmp_path / "one-memory.sqlite3"
secrets = InMemorySecretStore()
store = ModelProfileStore(database)
profile = _deepseek(store, secrets, disable_thinking=False)
snapshot = store.snapshot(profile.profile_id, profile.revision)
config = build_sdk_config(snapshot, api_key="sk-test", data_dir=tmp_path / "data")
assert not config.llm.extra_body
def test_sdk_config_maps_other_providers_through_the_same_table(tmp_path) -> None:
database = tmp_path / "one-memory.sqlite3"
secrets = InMemorySecretStore()
store = ModelProfileStore(database)
openai = store.create(
ModelProfileInput(
name="openai",
provider_kind=ProviderKind.OPENAI_COMPATIBLE,
base_url="https://api.example.com/v1",
model="gpt-test",
api_key=SecretStr("sk-test"),
embedding_model="embedding-model",
embedding_provider_kind=ProviderKind.LOCAL_ONNX,
),
secrets,
)
ollama = store.create(
ModelProfileInput(
name="ollama",
provider_kind=ProviderKind.OLLAMA,
base_url="http://127.0.0.1:11434",
model="qwen",
embedding_model="embedding-model",
embedding_provider_kind=ProviderKind.LOCAL_ONNX,
),
secrets,
)
openai_config = build_sdk_config(
store.snapshot(openai.profile_id, openai.revision),
api_key="sk-test",
data_dir=tmp_path / "data",
)
ollama_config = build_sdk_config(
store.snapshot(ollama.profile_id, ollama.revision),
api_key=None,
data_dir=tmp_path / "data",
)
assert openai_config.llm.extra_body == {"reasoning_effort": "none"}
assert ollama_config.llm.extra_body == {"think": False}
@@ -773,131 +773,3 @@ def test_cancel_during_callback_keeps_a_replayable_pending_marker(tmp_path) -> N
assert evidence_store.count() == 1
assert len(_pending_markers(evidence_store.database_path)) == 1
def said(message_id: str, role: str, text: str) -> EvidenceInput:
writer = {"user": WrittenBy.PERSON, "assistant": WrittenBy.AGENT, "tool": WrittenBy.TOOL}
return evidence(message_id).model_copy(
update={"role": role, "written_by": writer[role], "verbatim": text}
)
def _sdk_runner(tmp_path):
database = tmp_path / "one-memory.sqlite3"
bootstrap_fresh_management_schema(database)
job_store = ImportJobStore(database)
queue = DurableWriteQueue(database, sdk_authority=True)
runner = ImportRunner(
evidence_store=RetiredEvidenceStore(database), # type: ignore[arg-type]
job_store=job_store,
sdk_queue=queue,
sdk_authority=lambda: True,
)
return database, job_store, queue, runner
def _read(runner, job_store, parts, *, operation: str, first_line: int = 1):
events: list[ScanEvent] = [
ScannedEvidence(evidence=part, cursor=cursor(f"line:{first_line + index}"))
for index, part in enumerate(parts)
]
last = f"line:{first_line + len(parts) - 1}"
events.append(ScanCursorTouch(cursor=cursor(last).model_copy(update={"read_to_end": True})))
job = job_store.create(user_id="local", source_id="fixture", operation=operation)
asyncio.run(runner.run(adapter=FixtureAdapter(events), job_id=job.job_id))
def _jobs(database) -> list[tuple[str, list[str]]]:
import json
with sqlite3.connect(database) as connection:
rows = connection.execute(
"SELECT state,payload_json FROM memory_write_jobs ORDER BY queue_seq"
).fetchall()
return [
(state, [item["role"] for item in json.loads(payload)["input_records"]])
for state, payload in rows
]
def _turns(count: int, *, start: int = 0) -> list[EvidenceInput]:
return [
said(f"{role[0]}{index}", role, f"{role} {index}")
for index in range(start, start + count)
for role in ("user", "assistant")
]
def test_history_import_writes_five_turn_batches_and_flushes_the_rest_at_eof(tmp_path):
"""A first import used to be one giant job per conversation."""
database, job_store, _queue, runner = _sdk_runner(tmp_path)
_read(runner, job_store, _turns(7), operation="history")
assert _jobs(database) == [
("pending", ["user", "assistant"] * 5),
("pending", ["user", "assistant"] * 2),
]
def test_live_polling_carries_an_open_turn_and_a_tool_only_increment(tmp_path):
"""A transcript read mid-turn used to give tool-only jobs (16 unknown)."""
database, job_store, queue, runner = _sdk_runner(tmp_path)
_read(runner, job_store, _turns(2), operation="sync")
assert _jobs(database) == [("assembling", ["user", "assistant"] * 2)]
tools = [said("t1", "tool", "ls"), said("t2", "tool", "3 passed")]
_read(runner, job_store, tools, operation="sync", first_line=5)
assert _jobs(database) == [
("assembling", ["user", "assistant", "user", "assistant", "tool", "tool"])
]
# Ten quiet minutes: the open turn is complete and the short batch goes.
assert queue.flush_idle_assemblies(now=datetime.now(UTC) + timedelta(minutes=9)) == ()
flushed = queue.flush_idle_assemblies(now=datetime.now(UTC) + timedelta(minutes=11))
assert len(flushed) == 1
assert _jobs(database) == [
("pending", ["user", "assistant", "user", "assistant", "tool", "tool"])
]
def test_live_polling_writes_five_complete_turns_and_keeps_the_open_one(tmp_path):
database, job_store, queue, runner = _sdk_runner(tmp_path)
_read(runner, job_store, _turns(5), operation="sync")
assert [state for state, _ in _jobs(database)] == ["assembling"]
_read(runner, job_store, [said("u5", "user", "user 5")], operation="sync", first_line=11)
assert _jobs(database) == [
("assembling", ["user"]),
("pending", ["user", "assistant"] * 5),
]
def test_a_reread_increment_creates_no_second_job(tmp_path):
database, job_store, queue, runner = _sdk_runner(tmp_path)
_read(runner, job_store, _turns(6), operation="sync")
_read(runner, job_store, _turns(6), operation="sync")
queue.flush_idle_assemblies(now=datetime.now(UTC) + timedelta(minutes=11))
_read(runner, job_store, _turns(6), operation="sync")
queue.flush_idle_assemblies(now=datetime.now(UTC) + timedelta(minutes=11))
assert _jobs(database) == [
("pending", ["user", "assistant"] * 5),
("pending", ["user", "assistant"]),
]
def test_history_records_with_no_user_message_are_dropped_at_eof(tmp_path):
database, job_store, _queue, runner = _sdk_runner(tmp_path)
_read(
runner,
job_store,
[said("t1", "tool", "ls"), said("a1", "assistant", "ok")],
operation="history",
)
assert _jobs(database) == []
+1 -49
View File
@@ -5,7 +5,6 @@ import json
import os
import sqlite3
from contextlib import closing
from datetime import UTC, datetime, timedelta
from io import StringIO
from pathlib import Path
@@ -108,15 +107,7 @@ def test_same_message_id_from_the_file_does_not_duplicate(tmp_path) -> None:
assert store.count() == 1
def _quiet_for_ten_minutes(database: Path, store: EvidenceStore) -> tuple[str, ...]:
"""What Core's drain does once a live conversation has been quiet."""
return DurableWriteQueue(database, evidence=store).flush_idle_assemblies(
now=datetime.now(UTC) + timedelta(minutes=11)
)
def test_hook_submits_complete_turn_to_the_live_durable_lane(tmp_path) -> None:
"""The turn rule holds the turn until the next user message or 10 quiet minutes."""
store = _grant(tmp_path, "workbuddy", sync=True)
database = tmp_path / "one-memory.sqlite3"
@@ -141,13 +132,10 @@ def test_hook_submits_complete_turn_to_the_live_durable_lane(tmp_path) -> None:
database,
)
with closing(sqlite3.connect(database)) as db:
assert db.execute("SELECT state FROM memory_write_jobs").fetchall() == [("assembling",)]
assert len(_quiet_for_ten_minutes(database, store)) == 1
with closing(sqlite3.connect(database)) as db:
row = db.execute(
"SELECT priority,evidence_ids_json FROM memory_write_jobs "
"WHERE idempotency_key LIKE 'turn:v2:%'"
"WHERE idempotency_key LIKE 'turn:v1:%'"
).fetchone()
assert row[0] == "live"
assert len(json.loads(row[1])) == 2
@@ -173,41 +161,6 @@ def test_hook_submits_complete_turn_to_the_live_durable_lane(tmp_path) -> None:
assert db.execute("SELECT COUNT(*) FROM evidence").fetchone() == (2,)
def test_hook_writes_five_turns_once_the_next_user_message_arrives(tmp_path) -> None:
store = _grant(tmp_path, "workbuddy", sync=True)
database = tmp_path / "one-memory.sqlite3"
def say(event: str, field: str, message_id: str, text: str) -> None:
assert capture_hook(
"workbuddy",
{
"hook_event_name": event,
"session_id": "sess-five",
"message_id": message_id,
field: text,
},
database,
)
for index in range(5):
say("UserPromptSubmit", "prompt", f"u{index}", f"第 {index} 个偏好")
say("Stop", "last_assistant_message", f"a{index}", f"记住第 {index} 个")
with closing(sqlite3.connect(database)) as db:
assert db.execute("SELECT state FROM memory_write_jobs").fetchall() == [("assembling",)]
say("UserPromptSubmit", "prompt", "u5", "第 5 个偏好")
with closing(sqlite3.connect(database)) as db:
rows = db.execute(
"SELECT state,evidence_ids_json FROM memory_write_jobs ORDER BY queue_seq"
).fetchall()
assert [(state, len(json.loads(ids))) for state, ids in rows] == [
("assembling", 0),
("pending", 10),
]
assert store.count() == 11
def test_file_adopts_the_hook_row_when_the_text_is_unique(tmp_path) -> None:
store = _grant(tmp_path, "workbuddy", sync=True)
database = tmp_path / "one-memory.sqlite3"
@@ -258,7 +211,6 @@ def test_hook_job_completes_after_file_identity_adoption(tmp_path) -> None:
},
database,
)
assert len(_quiet_for_ten_minutes(database, store)) == 1
for message_id, role, author, text in (
("file-user", "user", WrittenBy.PERSON, "我喜欢蓝莓"),
("file-assistant", "assistant", WrittenBy.AGENT, "我会记住你喜欢蓝莓"),
@@ -166,57 +166,6 @@ def test_revoked_manual_source_is_not_silently_reauthorized(tmp_path):
asyncio.run(scenario())
@pytest.mark.fresh_sdk
def test_sdk_manual_add_without_a_grant_row_is_claimed(tmp_path, monkeypatch):
"""The SDK add path writes no grant row; the claim gate used to require one.
The intake admitted the job, the drain skipped it on every tick, and the
note stayed Pending with attempts=0 while later jobs ran. A missing grant
row is allowed; only an explicit revocation holds the job.
"""
async def scenario():
app = application(tmp_path)
async with app.router.lifespan_context(app), client_for(app) as client:
addition = {"request_id": str(uuid4()), "text": "Remember this.", "scope": "all-apps"}
assert (await client.post("/api/v2/memories", json=addition)).status_code == 201
database = app.state.settings.data_dir / "one-memory.sqlite3"
with sqlite3.connect(database) as db:
assert (
db.execute(
"SELECT COUNT(*) FROM source_grants WHERE source_id='one-memory-manual'"
).fetchone()[0]
== 0
)
request_id = db.execute("SELECT request_id FROM memory_note_requests").fetchone()[0]
job = app.state.memory_write_queue.get(request_id)
assert job is not None and job.state == "pending"
monkeypatch.setattr(
app.state.model_profile_store, "active_identity", lambda: ("profile", 1)
)
assert app.state.memory_write_drain.claim(limit=1) == (job.job_id,)
# An explicit revocation still holds the job and refuses new adds.
app.state.memory_write_queue.settle(job.job_id, state="pending", owner=None)
with sqlite3.connect(database) as db:
db.execute(
"INSERT INTO source_grants (user_id,source_id,root_path,requested_path,"
"root_identity,candidate_kind,granted_at,revoked_at,revision) VALUES "
"('local','one-memory-manual','x','x','manual','default',"
"'2026-10-01T00:00:00+00:00','2026-10-27T00:00:00+00:00',1)"
)
assert app.state.memory_write_drain.claim(limit=1) == ()
refused = await client.post(
"/api/v2/memories",
json={**addition, "request_id": str(uuid4())},
)
assert refused.status_code == 409
assert refused.json()["detail"] == "memory_add_source_revoked"
asyncio.run(scenario())
@pytest.mark.fresh_sdk
def test_sdk_memory_write_accepts_openai_messages_without_evidence_tables(tmp_path):
async def scenario():
@@ -90,7 +90,6 @@ class FakeSdk:
self.productivity = {}
self.search_calls = []
self.list_calls = []
self.stats_calls = []
self.get_calls = []
self.update_calls = []
self.delete_calls = []
@@ -228,22 +227,6 @@ class FakeSdk:
"elapsed_ms": 1.0,
}
def memory_stats(self, *, user_id, statuses=None):
self.stats_calls.append({"user_id": user_id, "statuses": statuses})
experiences = [
variant for (kind, variant, _id) in self.productivity if kind == "experience"
]
facts = [variant for (kind, variant, _id) in self.productivity if kind == "fact"]
scenes = {"chat": {"count": len(self.chat)}}
for variant in ("coding", "working"):
scenes[variant] = {"count": experiences.count(variant), "facts": facts.count(variant)}
return {
"total": len(self.chat) + len(experiences),
"scenes": scenes,
"request_id": "stats",
"elapsed_ms": 1.0,
}
def get(self, memory_id, kind=None, variant=None):
self.get_calls.append((memory_id, kind, variant))
match = self._matches(memory_id, kind, variant)
@@ -664,33 +647,3 @@ def test_scene_filter_narrows_search_and_binds_the_window(tmp_path, monkeypatch)
assert len(sdk.search_calls) == 2
run_with_sdk(tmp_path, monkeypatch, scenario)
@pytest.mark.fresh_sdk
def test_home_summary_counts_what_the_memory_page_lists(tmp_path, monkeypatch):
"""Home showed 55 (chat only) while the memory page showed 94."""
async def scenario(app, sdk, client):
sdk.add_chat(chat_row("chat-1", "cursor chat"))
sdk.add_chat(chat_row("chat-2", "codebuddy chat", source="codebuddy"))
sdk.add_productivity("experience", experience_row("exp-w", "task w", "solution w"))
sdk.add_productivity(
"experience", experience_row("exp-c", "task c", "solution c", variant="coding")
)
# Facts are not listed on the memory page, so they are not counted.
sdk.add_productivity("fact", fact_row("fact-1", "The pytest suite runs in CI"))
response = await client.get("/api/v2/home-summary")
assert response.status_code == 200
body = response.json()
library = (await client.get("/api/v2/memory-library")).json()
assert body["memory_count"] == library["total"] == 4
assert sdk.stats_calls == [{"user_id": "local", "statuses": ["active"]}]
# The newest memory across scenes, not the newest chat memory.
home_list = sdk.list_calls[0]
assert home_list["scene"] == "all"
assert (home_list["offset"], home_list["limit"], home_list["order"]) == (0, 1, "desc")
assert body["latest"]["memory_id"] == "exp-w"
assert body["latest"]["scene"] == "working"
run_with_sdk(tmp_path, monkeypatch, scenario)
@@ -1118,75 +1118,6 @@ def test_sdk_write_processor_uses_add_extracted_for_memory_content_and_additions
assert len(client.calls) == 1
def test_a_batch_with_no_user_or_assistant_message_is_nothing_to_remember(tmp_path) -> None:
"""Tool-only input came back 400 "content or messages is required" (16 unknown).
The SDK strips tool records on the chat path. Such a batch completes with
no memories; the SDK is never called and nothing is recorded unknown.
"""
from one_memory.imports.models import WrittenBy
from one_memory.sdk_write_processor import SdkWriteProcessor
client = _SdkPublicClient()
processor = SdkWriteProcessor(_SdkPublicHandle(client)) # type: ignore[arg-type]
tools = tuple(
_sdk_evidence(f"tool-{index}", text="3 passed").model_copy(
update={"role": "tool", "written_by": WrittenBy.TOOL}
)
for index in range(2)
)
path = tmp_path / "one-memory.sqlite3"
queue = DurableWriteQueue(path)
record = queue.submit(
WriteRequest(
internal_session_id="sess-tools",
source_id="cursor",
batch_identity=BatchIdentity(
internal_session_id="sess-tools", first_round=1, last_round=1
),
evidence_ids=[item.evidence_id for item in tools],
input_records=tools,
idempotency_key="tools-only",
)
)
_claim_through_drain(queue, record.job_id)
asyncio.run(
execute_job(
record.job_id,
queue=queue,
supervisor=processor,
evidence=_EvidenceStore(set()),
)
)
assert client.calls == []
assert client.events == []
[row] = _rows(path)
assert (row["state"], row["hy_invoked"], row["sdk_memory_ids_json"]) == (COMPLETED, 0, "[]")
def test_the_drain_tick_writes_a_quiet_live_conversation(tmp_path, monkeypatch) -> None:
"""The 10-minute idle flush runs on the drain's maintenance timer."""
path = tmp_path / "one-memory.sqlite3"
queue = DurableWriteQueue(path)
flushed: list[str] = []
monkeypatch.setattr(queue, "flush_idle_assemblies", lambda: flushed.append("flush") or ())
drain = DurableWriteDrain(queue, execute=_never_execute, reap_interval=timedelta(0))
async def tick() -> None:
task = asyncio.create_task(drain.run_forever())
for _ in range(100):
await asyncio.sleep(0.01)
if flushed:
break
drain.stop()
await task
asyncio.run(tick())
assert flushed
def test_sdk_unknown_outcome_inquiry_uses_only_public_receipt() -> None:
from one_memory.sdk_write_processor import SdkWriteProcessor, SdkWriteReceipt
@@ -127,28 +127,6 @@ def test_the_manual_source_is_admitted_by_grant_without_a_toggle(tmp_path) -> No
assert _sedimentation_claim_enabled(app, "one-memory-manual") is False
def test_the_manual_source_is_allowed_without_a_grant_row_until_revoked(tmp_path) -> None:
"""The SDK add path writes no grant row, and intake admits the record.
Claim must answer the same: a missing row is allowed, an explicit
revocation is not.
"""
receipt: list[object] = [None]
app = SimpleNamespace(
state=SimpleNamespace(
config_store=ConfigStore(tmp_path),
model_profile_store=SimpleNamespace(active_identity=lambda: ("profile", 1)),
source_grant_store=SimpleNamespace(receipt=lambda **_kwargs: receipt[0]),
)
)
assert _source_writes_on(app, "one-memory-manual") is True
assert _sedimentation_claim_enabled(app, "one-memory-manual") is True
receipt[0] = SimpleNamespace(revoked_at="2026-10-27")
assert _source_writes_on(app, "one-memory-manual") is False
assert _sedimentation_claim_enabled(app, "one-memory-manual") is False
def test_claim_gate_waits_without_polling_when_no_model_is_active(tmp_path) -> None:
config = ConfigStore(tmp_path)
app = SimpleNamespace(
@@ -705,29 +705,6 @@ def test_cutover_waits_until_the_new_runtime_reads_every_chat_memory(store, acti
assert store.authority() is MemoryAuthority.SDK
def test_sdk_migrate_repairs_metadata_after_cutover_and_writes_the_receipt(store, active_profile):
"""76/76 memories showed source=default after an upgrade whose repair failed.
The repair now runs here, offline, right after the cutover commits; Core's
startup finds the receipt and does nothing.
"""
_confirmed(store, "m1")
home = store.database_path.parent
result = run_startup_migration(
data_dir=home,
runtime_build="21",
sdk_version="2.0.5",
namespace="governed-v2",
open_client=_reading({"m1"}),
)
assert result.state == "cutover"
receipt = home / f".sdk-metadata-repair-v2-{result.migration_id}.json"
assert receipt.is_file()
assert json.loads(receipt.read_text())["migration_id"] == result.migration_id
def test_retired_coding_experiences_do_not_block_but_an_empty_store_does(store, active_profile):
_confirmed(store, "chat-1")
_confirmed(store, "exp-kept", "exp-retired", kind="coding")
@@ -16,7 +16,6 @@ from __future__ import annotations
import asyncio
import contextlib
import json
import sqlite3
from uuid import uuid4
@@ -198,20 +197,17 @@ def test_live_import_callback_submits_realtime_priority(tmp_path) -> None:
)
app.state.config_store.set_sync(source_id, True)
user = _committed_record(app, source_id=source_id, message_id="live-user")
# Live writes by the turn rule: 5 complete turns, the fifth
# completed by the next user message.
for index in range(10):
record = app.state.evidence_store.put(
EvidenceInput(
source_id=source_id,
conversation_id=user.conversation_id,
message_id=f"live-{index}",
role="assistant" if index % 2 == 0 else "user",
written_by=WrittenBy.AGENT if index % 2 == 0 else WrittenBy.PERSON,
verbatim=f"Live line {index}",
origin_meta={EvidenceOrigin.FILE: OriginMetadata()},
)
).record
record = app.state.evidence_store.put(
EvidenceInput(
source_id=source_id,
conversation_id=user.conversation_id,
message_id="live-assistant",
role="assistant",
written_by=WrittenBy.AGENT,
verbatim="Live reply",
origin_meta={EvidenceOrigin.FILE: OriginMetadata()},
)
).record
callback = app.state.import_supervisor.on_stable_evidence
assert callback is not None
token = live_sync_arrival.set(True)
@@ -220,12 +216,11 @@ def test_live_import_callback_submits_realtime_priority(tmp_path) -> None:
finally:
live_sync_arrival.reset(token)
with sqlite3.connect(app.state.evidence_store.database_path) as db:
rows = db.execute(
"SELECT priority,evidence_ids_json FROM memory_write_jobs "
"WHERE idempotency_key LIKE ?",
priority = db.execute(
"SELECT priority FROM memory_write_jobs WHERE idempotency_key LIKE ?",
("turn:v1:%",),
).fetchall()
assert [(priority, len(json.loads(ids))) for priority, ids in rows] == [("live", 10)]
).fetchone()[0]
assert priority == "live"
asyncio.run(scenario())
@@ -1,8 +1,8 @@
"""D74: a turn reaches HY whole; only what HY will read counts toward the batch size."""
"""D74: a turn reaches HY whole; only what HY will read counts toward the ceiling."""
from datetime import UTC, datetime
from one_memory.conversation_processing import turn_batches
from one_memory.conversation_processing import _conversation_chunks
from one_memory.imports.models import EvidenceInput, EvidenceOrigin, OriginMetadata, WrittenBy
from one_memory.imports.storage import EvidenceStore
@@ -36,33 +36,66 @@ def test_tool_heavy_turn_is_one_window(tmp_path):
*[("assistant", "定位中") for _ in range(30)],
("assistant", "原因是 fixture 没清理" * 100),
)
batches = turn_batches(records, flush=True).batches
assert batches == (tuple(records),)
chunks = _conversation_chunks(records)
assert len(chunks) == 1
assert chunks[0] == tuple(records)
def test_long_answer_is_not_cut(tmp_path):
records = _turn(tmp_path, ("user", "写一份方案"), ("assistant", "段落。" * 7_000))
batches = turn_batches(records, flush=True).batches
assert batches == (tuple(records),)
assert all(":segment:" not in record.evidence_id for record in batches[0])
chunks = _conversation_chunks(records)
assert chunks == [tuple(records)]
assert all(":segment:" not in record.evidence_id for record in chunks[0])
def test_a_turn_far_over_the_batch_size_is_still_one_window(tmp_path):
"""The rule never splits a turn: it is written alone, whole.
def test_text_over_the_ceiling_still_splits(tmp_path):
records = _turn(tmp_path, ("user", "整理"), ("assistant", "句子。" * 25_000))
chunks = _conversation_chunks(records)
assert len(chunks) > 1
assert all(chunk[0].evidence_id == records[0].evidence_id for chunk in chunks)
The old windows cut text past 60k characters and raw bytes past 2 MB into
segments that repeated the user question. HY reads at most
`_HY_TOOL_CHARACTERS` of each tool record, so the window HY reads stays
bounded by the turn's own text.
def test_switch_restores_the_old_split(tmp_path, monkeypatch):
monkeypatch.setenv("ONE_MEMORY_SPLIT_TURNS", "1")
records = _turn(tmp_path, ("user", "写一份方案"), ("assistant", "段落。" * 7_000))
assert len(_conversation_chunks(records)) > 1
def test_explicit_limits_keep_old_semantics(tmp_path):
records = _turn(tmp_path, ("user", "q"), *[("assistant", "a" * 100) for _ in range(30)])
assert len(_conversation_chunks(records, max_turns=6, max_characters=1200)) > 1
def test_one_window_is_capped_in_raw_bytes(tmp_path):
"""HY counts 2 KiB per tool record, but we still hold the raw text.
`client.add` is in-process, so a window's raw bytes are what the process
actually holds even though HY will cut each tool record down before it
reaches the model. Past the byte ceiling the window is cut, so no single
call can grow without bound.
"""
from one_memory.conversation_processing import _WINDOW_BYTE_CEILING
records = _turn(
tmp_path,
("user", "跑一下全部测试"),
*[("tool", "y" * 400_000) for _ in range(20)],
("assistant", "句子。" * 25_000),
)
batches = turn_batches(records, flush=True).batches
assert batches == (tuple(records),)
chunks = _conversation_chunks(records)
assert len(chunks) > 1
assert all(
sum(len(item.verbatim.encode("utf-8")) for item in chunk) <= _WINDOW_BYTE_CEILING
for chunk in chunks
)
def test_the_byte_ceiling_counts_bytes_not_characters(tmp_path):
"""One CJK character is three bytes; a character count would let three
times as much through."""
from one_memory.conversation_processing import _WINDOW_BYTE_CEILING, _window_bytes
assert _window_bytes(_turn(tmp_path, ("user", "中文"))[0]) > len("中文")
assert _WINDOW_BYTE_CEILING == 2_000_000
def test_the_sdk_strips_tool_messages_on_the_chat_path():
@@ -28,11 +28,15 @@ describe("ConnectionFallback", () => {
} as unknown as Window["oneMemory"];
render(
<LanguageProvider>
<ConnectionFallback />
<ConnectionFallback defaultExpanded />
</LanguageProvider>,
);
const user = userEvent.setup();
await user.click(await screen.findByText("Conectar otras herramientas"));
expect(
(await screen.findByText("Conectar otras herramientas")).closest(
"details",
),
).toHaveProperty("open", true);
await user.type(
screen.getByLabelText("Nombre del Harness"),
"Harness 原名",
@@ -59,8 +63,11 @@ describe("ConnectionFallback", () => {
createCustomHarnessSkill,
copyText,
} as unknown as Window["oneMemory"];
render(<ConnectionFallback />);
await user.click(screen.getByText("连接其他工具"));
render(<ConnectionFallback defaultExpanded />);
expect(screen.getByText("连接其他工具").closest("details")).toHaveProperty(
"open",
true,
);
const generate = screen.getByRole("button", { name: "生成配对 Skill" });
expect(generate).toHaveProperty("disabled", true);
await user.type(screen.getByLabelText("Harness 名称"), "Kimi Code");
@@ -90,8 +97,7 @@ describe("ConnectionFallback", () => {
createCustomHarnessSkill,
copyText: vi.fn().mockRejectedValue(new Error()),
} as unknown as Window["oneMemory"];
render(<ConnectionFallback />);
await user.click(screen.getByText("连接其他工具"));
render(<ConnectionFallback defaultExpanded />);
await user.type(screen.getByLabelText("Harness 名称"), "Kimi Code");
await user.click(screen.getByRole("button", { name: "生成配对 Skill" }));
await user.click(await screen.findByRole("button", { name: "重试" }));
@@ -103,6 +109,10 @@ describe("ConnectionFallback", () => {
});
it("does not invent runtime paths in the browser", () => {
render(<ConnectionFallback />);
expect(screen.getByText("连接其他工具").closest("details")).toHaveProperty(
"open",
false,
);
expect(screen.queryByLabelText("配对 Skill")).toBeNull();
expect(screen.getByText("本机配对仅在桌面 App 中可用。")).toBeTruthy();
});
+11 -2
View File
@@ -13,8 +13,13 @@ type MaterialBridge = {
getConnectionMaterial(): Promise<ConnectionMaterial>;
};
export function ConnectionFallback() {
export function ConnectionFallback({
defaultExpanded = false,
}: {
defaultExpanded?: boolean;
} = {}) {
const { locale, t } = useGuiLanguage();
const [expanded, setExpanded] = useState(defaultExpanded);
const [displayName, setDisplayName] = useState("");
const [material, setMaterial] = useState<Material | null>(null);
const [loading, setLoading] = useState(false);
@@ -42,7 +47,11 @@ export function ConnectionFallback() {
}
return (
<details className="connection-fallback">
<details
className="connection-fallback"
open={expanded}
onToggle={(event) => setExpanded(event.currentTarget.open)}
>
<summary>{t("connectionFallback.title")}</summary>
{materialBridge?.getConnectionMaterial ? (
<ConnectionMaterialFallback
+36 -7
View File
@@ -15,7 +15,7 @@ afterEach(() => {
it.each(["zh-CN", "en", "es"] as const)(
"hides the compact label while preserving its accessible name in %s",
(locale) => {
async (locale) => {
localStorage.setItem(LANGUAGE_STORAGE_KEY, locale);
render(
<LanguageProvider>
@@ -28,6 +28,7 @@ it.each(["zh-CN", "en", "es"] as const)(
expect(label.hidden).toBe(true);
expect(label.htmlFor).toBe(selector.id);
expect(screen.getByRole("region", { name })).toBeTruthy();
await userEvent.click(selector);
expect(screen.getByRole("option", { name: "Español" })).toBeTruthy();
expect(screen.getByRole("option", { name: "English" })).toBeTruthy();
expect(screen.getByRole("option", { name: "简体中文" })).toBeTruthy();
@@ -60,18 +61,19 @@ it.each(["zh-CN", "en", "es"] as const)(
it.each(["system", "zh-CN", "en", "es"] as const)(
"keeps one stable settings row for the %s preference",
(preference) => {
async (preference) => {
localStorage.setItem(LANGUAGE_STORAGE_KEY, preference);
render(
<LanguageProvider>
<LanguageSelector />
</LanguageProvider>,
);
const selector = screen.getByRole("combobox") as HTMLSelectElement;
const selector = screen.getByRole("combobox") as HTMLButtonElement;
const row = selector.closest(".r6-language-setting-row");
expect(selector.value).toBe(preference);
expect(row).toBeTruthy();
expect(row?.querySelector("label")?.hidden).toBe(false);
await userEvent.click(selector);
expect(
screen
.getByRole("option", {
@@ -102,9 +104,10 @@ it("offers an accessible selector, persists and updates its label", async () =>
const selector = screen.getByRole("combobox", {
name: translate("zh-CN", "languageLabel"),
});
expect(screen.getAllByRole("option")).toHaveLength(4);
expect(selector.getAttribute("aria-describedby")).toBeTruthy();
await userEvent.selectOptions(selector, "es");
await userEvent.click(selector);
expect(screen.getAllByRole("option")).toHaveLength(4);
await userEvent.click(screen.getByRole("option", { name: "西班牙语" }));
expect(localStorage.getItem(LANGUAGE_STORAGE_KEY)).toBe("es");
expect(
screen.getByRole("combobox", { name: translate("es", "languageLabel") }),
@@ -112,6 +115,31 @@ it("offers an accessible selector, persists and updates its label", async () =>
expect(document.documentElement.lang).toBe("es");
});
it("supports keyboard selection without using the system select menu", async () => {
localStorage.setItem(LANGUAGE_STORAGE_KEY, "system");
render(
<LanguageProvider>
<LanguageSelector />
</LanguageProvider>,
);
const selector = screen.getByRole("combobox");
selector.focus();
await userEvent.keyboard("{Enter}");
expect(selector.getAttribute("aria-expanded")).toBe("true");
await userEvent.tab();
expect(selector.getAttribute("aria-expanded")).toBe("false");
selector.focus();
await userEvent.keyboard("{Enter}");
await userEvent.keyboard("{Escape}");
expect(selector.getAttribute("aria-expanded")).toBe("false");
await userEvent.keyboard(" ");
expect(selector.getAttribute("aria-expanded")).toBe("true");
await userEvent.keyboard("{Escape}");
await userEvent.keyboard("{ArrowDown}{ArrowDown}{Enter}");
expect(localStorage.getItem(LANGUAGE_STORAGE_KEY)).toBe("zh-CN");
expect(selector.getAttribute("aria-expanded")).toBe("false");
});
it("announces storage errors without replacing the confirmed choice", async () => {
localStorage.setItem(LANGUAGE_STORAGE_KEY, "zh-CN");
render(
@@ -122,11 +150,12 @@ it("announces storage errors without replacing the confirmed choice", async () =
vi.spyOn(Storage.prototype, "setItem").mockImplementation(() => {
throw new Error("denied");
});
await userEvent.selectOptions(screen.getByRole("combobox"), "es");
await userEvent.click(screen.getByRole("combobox"));
await userEvent.click(screen.getByRole("option", { name: "西班牙语" }));
expect(screen.getByRole("alert").textContent).toBe(
translate("zh-CN", "languageSaveError"),
);
expect((screen.getByRole("combobox") as HTMLSelectElement).value).toBe(
expect((screen.getByRole("combobox") as HTMLButtonElement).value).toBe(
"zh-CN",
);
});
+27 -17
View File
@@ -1,11 +1,30 @@
import { isLanguagePreference } from "@one-memory/i18n";
import { useId, useState } from "react";
import { useGuiLanguage } from "./gui-language";
import { OptionMenu } from "./OptionMenu";
export function LanguageSelector({ compact = false }: { compact?: boolean }) {
const id = useId();
const { preference, setPreference, t, error } = useGuiLanguage();
const [pending, setPending] = useState(false);
const options = [
{ value: "system", label: t("languageSystem") },
{
value: "zh-CN",
label: compact ? "简体中文" : t("languageChinese"),
lang: "zh-CN",
},
{
value: "en",
label: compact ? "English" : t("languageEnglish"),
lang: "en",
},
{
value: "es",
label: compact ? "Español" : t("languageSpanish"),
lang: "es",
},
] as const;
return (
<section
aria-labelledby={compact ? `${id}-label` : `${id}-title`}
@@ -26,14 +45,16 @@ export function LanguageSelector({ compact = false }: { compact?: boolean }) {
<label id={`${id}-label`} htmlFor={`${id}-select`} hidden={compact}>
{t(compact ? "languageTitle" : "languageLabel")}
</label>
<select
<OptionMenu
id={`${id}-select`}
aria-label={compact ? t("languageTitle") : undefined}
aria-describedby={compact ? undefined : `${id}-description`}
ariaLabel={compact ? t("languageTitle") : undefined}
labelledBy={compact ? undefined : `${id}-label`}
describedBy={compact ? undefined : `${id}-description`}
value={preference}
disabled={pending}
onChange={async (event) => {
const value = event.currentTarget.value;
options={options}
className="r6-language-option-menu"
onChange={async (value) => {
if (!isLanguagePreference(value)) return;
setPending(true);
try {
@@ -42,18 +63,7 @@ export function LanguageSelector({ compact = false }: { compact?: boolean }) {
setPending(false);
}
}}
>
<option value="system">{t("languageSystem")}</option>
<option value="zh-CN" lang="zh-CN">
{compact ? "简体中文" : t("languageChinese")}
</option>
<option value="en" lang="en">
{compact ? "English" : t("languageEnglish")}
</option>
<option value="es" lang="es">
{compact ? "Español" : t("languageSpanish")}
</option>
</select>
/>
</div>
{!compact && (
<p id={`${id}-description`} className="r6-language-setting-description">
+91
View File
@@ -212,6 +212,7 @@ it("searches only when 搜索 is pressed, not on every keystroke", async () => {
}}
/>,
);
expect(screen.getByRole("combobox", { name: "来源" })).toBeTruthy();
await userEvent.type(
screen.getByRole("textbox", { name: "筛选地图记忆" }),
"版本号",
@@ -265,6 +266,29 @@ it("OBS-15: switching memory shows a read in progress instead of the previous me
expect(screen.getByRole("status").textContent).toContain("正在读取这条记忆");
});
it("keeps the detail header outside the scrolling body and resets detail scroll on selection", async () => {
const { container } = render(
<Host simplified sources={[importedSources[0]]} />,
);
const heading = screen
.getByRole("heading", { name: "记忆详情" })
.closest(".map-inspector-heading");
const body = container.querySelector<HTMLElement>(".map-inspector-body");
expect(heading?.nextElementSibling).toBe(body);
if (!body) throw new Error("Memory detail scroll body is missing");
body.scrollTop = 180;
await userEvent.click(
within(screen.getByRole("list", { name: "地图记忆列表" })).getByRole(
"button",
{ name: /第二条/ },
),
);
expect(body.scrollTop).toBe(0);
body.scrollTop = 120;
await userEvent.click(screen.getByRole("button", { name: "图中选择 Codex" }));
expect(body.scrollTop).toBe(0);
});
it("OBS-12: the graph legend stays with the graph instead of sitting under a stage without one", () => {
// While the first page is still being read there is no graph yet: the
// legend used to sit there anyway, describing nothing.
@@ -546,6 +570,73 @@ it("D96: lists a memory by when its conversation happened, not when it was gener
).toBe(true);
});
it("keeps pagination and current detail stable while the next page is loading", async () => {
const onPage = vi.fn();
const page = {
items: memories,
total: 202,
offset: 0,
limit: 100,
source_ids_by_memory: { m0: ["codex"], m1: ["codex"] },
};
const libraryView = { ...pagedView(page), onPage };
const view = render(<Host simplified libraryView={libraryView} />);
const list = screen.getByRole("list", { name: "地图记忆列表" });
list.scrollTop = 160;
const pager = screen.getByRole("navigation", { name: "地图记忆分页" });
await userEvent.click(
within(pager).getByRole("button", { name: "下一页记忆" }),
);
expect(onPage).toHaveBeenCalledWith(100);
view.rerender(
<Host simplified libraryView={{ ...libraryView, loading: true }} />,
);
expect(
screen
.getByRole("navigation", { name: "地图记忆分页" })
.getAttribute("aria-busy"),
).toBe("true");
expect(
(
screen.getByRole("button", {
name: "下一页记忆",
}) as HTMLButtonElement
).disabled,
).toBe(true);
expect(screen.getByRole("heading", { name: "第一条" })).toBeTruthy();
expect(list.scrollTop).toBe(160);
const nextItems = memories.map((memory, index) => ({
...memory,
memory_id: `next-${index}`,
content: `下一页第 ${index + 1} 条`,
}));
view.rerender(
<Host
simplified
items={nextItems}
libraryView={{
...libraryView,
page: {
...page,
items: nextItems,
offset: 100,
source_ids_by_memory: {
"next-0": ["codex"],
"next-1": ["codex"],
},
},
loading: false,
}}
/>,
);
expect(list.scrollTop).toBe(0);
expect(within(list).getByText("下一页第 1 条")).toBeTruthy();
await waitFor(() =>
expect(screen.queryByRole("heading", { name: "第一条" })).toBeNull(),
);
});
it("names the SDK's no-source bucket 未识别来源, apart from a source under review", () => {
const items = ["有来源", "无来源"].map((content, i) => ({
...memories[0],
+157 -129
View File
@@ -25,6 +25,7 @@ import {
type RefObject,
useCallback,
useEffect,
useLayoutEffect,
useMemo,
useRef,
useState,
@@ -220,6 +221,8 @@ export function MemoryMap(props: Props) {
const compact = useMediaPreference("(max-width: 1100px)");
const composing = useRef(false);
const pane = useRef<HTMLElement>(null);
const memoryList = useRef<HTMLUListElement>(null);
const inspectorBody = useRef<HTMLDivElement>(null);
const openedDefault = useRef(false);
const scrollSelection = useRef(false);
const controlledSource = library
@@ -367,6 +370,11 @@ export function MemoryMap(props: Props) {
const activeId = active.some((memory) => memory.memory_id === requestedId)
? requestedId
: null;
const inspectorIdentity = activeId
? `memory:${activeId}`
: selectedSource
? `source:${selectedSource.source_id}`
: (visibleGroupNode?.node_id ?? "empty");
const visibleDetail =
detail &&
(!activeId || activeId === detail.memory.memory_id) &&
@@ -427,6 +435,19 @@ export function MemoryMap(props: Props) {
container.scrollTop += itemBounds.bottom - containerBounds.bottom;
}
}, [activeId]);
useLayoutEffect(() => {
// Detail content owns its own scrollport. A newly selected memory must
// start at its heading instead of inheriting the previous inspector view.
if (inspectorIdentity === "empty") return;
if (inspectorBody.current) inspectorBody.current.scrollTop = 0;
}, [inspectorIdentity]);
const settledPageOffset = library?.page?.offset;
useLayoutEffect(() => {
// A settled page is a new reading surface, not a continuation of the
// previous page's scroll position.
if (settledPageOffset === undefined) return;
if (memoryList.current) memoryList.current.scrollTop = 0;
}, [settledPageOffset]);
const clear = useCallback(() => {
setSelectedId(null);
@@ -586,6 +607,7 @@ export function MemoryMap(props: Props) {
<label htmlFor="selected-source-filter">{text("来源")}</label>
<OptionMenu
id="selected-source-filter"
ariaLabel={text("来源")}
className="om-option-menu--plain"
value={controlledSource}
options={[
@@ -781,6 +803,7 @@ export function MemoryMap(props: Props) {
</p>
)}
<ul
ref={memoryList}
className="map-memory-list"
aria-label={text("地图记忆列表")}
id="memory-pane-memories"
@@ -874,18 +897,21 @@ export function MemoryMap(props: Props) {
</p>
)}
{library?.page &&
!library.loading &&
(!library.loading || library.page.total > 0) &&
!library.failed &&
!library.stale && (
<nav aria-label={text("地图记忆分页")} className="map-pagination">
<nav
aria-label={text("地图记忆分页")}
aria-busy={library.loading || undefined}
className="map-pagination"
>
<button
type="button"
className="quiet map-tool"
aria-label={text("上一页记忆")}
title={text("上一页记忆")}
disabled={library.page.offset === 0}
disabled={library.loading || library.page.offset === 0}
onClick={() => {
clear();
if (axis === "topic") setGroupKey("");
if (library.page)
library.onPage(library.page.offset - library.page.limit);
@@ -914,10 +940,10 @@ export function MemoryMap(props: Props) {
aria-label={text("下一页记忆")}
title={text("下一页记忆")}
disabled={
library.loading ||
library.page.offset + library.page.limit >= library.page.total
}
onClick={() => {
clear();
if (axis === "topic") setGroupKey("");
if (library.page)
library.onPage(library.page.offset + library.page.limit);
@@ -1117,142 +1143,144 @@ export function MemoryMap(props: Props) {
</button>
)}
</header>
{selectedSource ? (
inspectorOpen &&
!overlaysSuspended && (
<SourceImportReceipt
key={selectedSource.source_id}
source={selectedSource}
/>
)
) : props.simplified && inspectorDetail ? (
<div
aria-busy={
refreshing ||
inspectorDetail.memory.memory_id !== activeId ||
undefined
}
onClickCapture={(event) => {
// React portal events follow this owner too, including editor saves.
if (
refreshing &&
(event.target as Element).closest("button, summary")
) {
event.preventDefault();
event.stopPropagation();
<div className="map-inspector-body" ref={inspectorBody}>
{selectedSource ? (
inspectorOpen &&
!overlaysSuspended && (
<SourceImportReceipt
key={selectedSource.source_id}
source={selectedSource}
/>
)
) : props.simplified && inspectorDetail ? (
<div
aria-busy={
refreshing ||
inspectorDetail.memory.memory_id !== activeId ||
undefined
}
}}
onSubmitCapture={(event) => {
if (refreshing) {
event.preventDefault();
event.stopPropagation();
}
}}
>
{/* The pane used to keep the previously read memory on screen
onClickCapture={(event) => {
// React portal events follow this owner too, including editor saves.
if (
refreshing &&
(event.target as Element).closest("button, summary")
) {
event.preventDefault();
event.stopPropagation();
}
}}
onSubmitCapture={(event) => {
if (refreshing) {
event.preventDefault();
event.stopPropagation();
}
}}
>
{/* The pane used to keep the previously read memory on screen
until the new one arrived, which read as "nothing happened"
for two or three seconds. A read is not a value: show that a
read is happening instead. */}
{activeId && inspectorDetail.memory.memory_id !== activeId ? (
<div className="map-detail-loading">
{activeId && inspectorDetail.memory.memory_id !== activeId ? (
<div className="map-detail-loading">
<div className="map-detail-skeleton" aria-hidden="true">
<i />
<i />
<i />
<i />
</div>
<p role="status">{text("正在读取这条记忆…")}</p>
</div>
) : (
<SelectedMemoryDetail
detail={inspectorDetail}
sources={sources}
conversationAt={
library?.page?.source_time_by_memory?.[
inspectorDetail.memory.memory_id
] ?? null
}
/>
)}
</div>
) : inspectorDetail || visibleGroupNode ? (
<NodeDetail
key={
inspectorDetail?.memory.memory_id ||
visibleGroupNode?.node_id ||
"empty"
}
compact
node={visibleGroupNode}
graph={projection}
detail={inspectorDetail}
draft={props.draft}
busy={busy}
matterLabel={label}
onDraft={props.onDraft}
onSave={props.onSave}
onDelete={props.onDelete}
requestIds={props.requestIds}
onClose={closeInspector}
/>
) : (
<div
className={`map-inspector-empty${activeId ? " map-inspector-loading" : ""}`}
>
{activeId ? (
<div className="map-detail-skeleton" aria-hidden="true">
<i />
<i />
<i />
<i />
</div>
<p role="status">{text("正在读取这条记忆…")}</p>
</div>
) : (
<SelectedMemoryDetail
detail={inspectorDetail}
sources={sources}
conversationAt={
library?.page?.source_time_by_memory?.[
inspectorDetail.memory.memory_id
] ?? null
}
/>
)}
</div>
) : inspectorDetail || visibleGroupNode ? (
<NodeDetail
key={
inspectorDetail?.memory.memory_id ||
visibleGroupNode?.node_id ||
"empty"
}
compact
node={visibleGroupNode}
graph={projection}
detail={inspectorDetail}
draft={props.draft}
busy={busy}
matterLabel={label}
onDraft={props.onDraft}
onSave={props.onSave}
onDelete={props.onDelete}
requestIds={props.requestIds}
onClose={closeInspector}
/>
) : (
<div
className={`map-inspector-empty${activeId ? " map-inspector-loading" : ""}`}
>
{activeId ? (
<div className="map-detail-skeleton" aria-hidden="true">
<i />
<i />
<i />
<i />
</div>
) : (
<FileText size={28} strokeWidth={1} />
)}
{props.detailLoad?.memoryId === activeId &&
props.detailLoad.status === "failed" ? (
<>
<p role="alert">
{text("这条记忆的原文暂时无法读取,选择已保留。")}
</p>
<button
type="button"
className="secondary"
onClick={() => activeId && onOpenMemory(activeId)}
>
{text("重试读取原文")}
</button>
</>
) : props.simplified && !activeId && filtered.length ? (
// Nothing is selected, but this conversation has already
// settled: naming what came out of it beats a placeholder that
// asks for the settle that already happened. Only the
// single-column surface needs it — the three-column one already
// lists the same memories in the pane beside this one.
<>
) : (
<FileText size={28} strokeWidth={1} />
)}
{props.detailLoad?.memoryId === activeId &&
props.detailLoad.status === "failed" ? (
<>
<p role="alert">
{text("这条记忆的原文暂时无法读取,选择已保留。")}
</p>
<button
type="button"
className="secondary"
onClick={() => activeId && onOpenMemory(activeId)}
>
{text("重试读取原文")}
</button>
</>
) : props.simplified && !activeId && filtered.length ? (
// Nothing is selected, but this conversation has already
// settled: naming what came out of it beats a placeholder that
// asks for the settle that already happened. Only the
// single-column surface needs it — the three-column one already
// lists the same memories in the pane beside this one.
<>
<p>
{text(
"已沉淀 {count} 条记忆",
{ count: plain(filtered.length) },
filtered.length,
)}
</p>
<ul
className="map-settled-list"
aria-label={text("已沉淀的记忆")}
>
{filtered.map((m) => (
<li key={m.memory_id}>{m.content}</li>
))}
</ul>
</>
) : (
<p>
{text(
"已沉淀 {count} 条记忆",
{ count: plain(filtered.length) },
filtered.length,
)}
{text(activeId ? "正在读取记忆详情…" : "沉淀记忆后查看详情")}
</p>
<ul
className="map-settled-list"
aria-label={text("已沉淀的记忆")}
>
{filtered.map((m) => (
<li key={m.memory_id}>{m.content}</li>
))}
</ul>
</>
) : (
<p>
{text(activeId ? "正在读取记忆详情…" : "沉淀记忆后查看详情")}
</p>
)}
</div>
)}
)}
</div>
)}
</div>
</MemoryEvidencePanel>
</section>
);
+2 -1
View File
@@ -201,8 +201,9 @@ it("keeps an added memory visible while it waits for model processing", async ()
render(<MemorySpace simplified onConfigureModel={vi.fn()} />);
fireEvent.click(screen.getByRole("button", { name: "补充记忆" }));
expect(
screen.getByText(/原话已保存,正在等待整理:项目周会统一安排在周三上午。/),
screen.getByText(/正在等待整理:项目周会统一安排在周三上午。/),
).toBeTruthy();
expect(screen.queryByText(/原话已保存/)).toBeNull();
expect(screen.getByRole("button", { name: "配置整理模型" })).toBeTruthy();
expect(screen.queryByText("还没有已生成的记忆")).toBeNull();
});
+1 -1
View File
@@ -953,7 +953,7 @@ export function MemorySpace({
{simplified && pendingAdd ? (
<p className="memory-pending-add" role="status">
<span>
{t("memory:原话已保存,正在等待整理:")}
{t("memory:正在等待整理:")}
{pendingAdd.text}
</span>
{pendingAdd.state === "waiting_for_model" ? (
@@ -117,7 +117,8 @@ it("changes the first-screen language without losing source choices or starting
),
);
await user.click(screen.getByLabelText("导入 Cursor 历史"));
await user.selectOptions(selector, "es");
await user.click(selector);
await user.click(screen.getByRole("option", { name: "Español" }));
expect(screen.getByRole("combobox", { name: "Idioma de la interfaz" })).toBe(
selector,
);
@@ -125,7 +126,8 @@ it("changes the first-screen language without losing source choices or starting
screen.getByLabelText("Importar el historial de Cursor"),
).toHaveProperty("checked", false);
await user.click(screen.getByLabelText("Importar el historial de Cursor"));
await user.selectOptions(selector, "zh-CN");
await user.click(selector);
await user.click(screen.getByRole("option", { name: "简体中文" }));
expect(screen.getByLabelText("导入 Cursor 历史")).toHaveProperty(
"checked",
true,
+105 -14
View File
@@ -1,10 +1,11 @@
import { useEffect, useRef, useState } from "react";
import { useEffect, useId, useRef, useState } from "react";
import { useGuiLanguage } from "./gui-language";
export interface OptionMenuItem {
value: string;
label: string;
disabled?: boolean;
lang?: string;
}
/** Replaces a native select so the open list follows the product field, not the OS menu. */
@@ -20,11 +21,50 @@ export function OptionMenu(props: {
onChange: (value: string) => void;
}) {
const { t } = useGuiLanguage();
const generatedId = useId();
const [open, setOpen] = useState(false);
const selectedIndex = props.options.findIndex(
(item) => item.value === props.value,
);
const firstEnabledIndex = props.options.findIndex((item) => !item.disabled);
const preferredIndex =
selectedIndex >= 0 && !props.options[selectedIndex]?.disabled
? selectedIndex
: firstEnabledIndex;
const [activeIndex, setActiveIndex] = useState(preferredIndex);
const root = useRef<HTMLDivElement>(null);
const button = useRef<HTMLButtonElement>(null);
const selected = props.options.find((item) => item.value === props.value);
const visibleLabel = selected?.label ?? t("wb.select");
const controlId = props.id ?? `${generatedId}-control`;
const panelId = `${controlId}-options`;
function enabledIndex(from: number, direction: 1 | -1) {
if (firstEnabledIndex < 0) return -1;
let next = from;
for (let checked = 0; checked < props.options.length; checked += 1) {
next =
next < 0
? direction === 1
? 0
: props.options.length - 1
: (next + direction + props.options.length) % props.options.length;
if (!props.options[next]?.disabled) return next;
}
return -1;
}
function choose(index: number) {
const item = props.options[index];
if (!item || item.disabled) return;
props.onChange(item.value);
setOpen(false);
button.current?.focus();
}
useEffect(() => {
if (!open) return;
setActiveIndex(preferredIndex);
const close = (event: MouseEvent) => {
if (!root.current?.contains(event.target as Node)) setOpen(false);
};
@@ -37,47 +77,98 @@ export function OptionMenu(props: {
document.removeEventListener("mousedown", close);
document.removeEventListener("keydown", onKey);
};
}, [open]);
}, [open, preferredIndex]);
return (
<div
className={["om-option-menu", props.className].filter(Boolean).join(" ")}
ref={root}
>
<button
ref={button}
type="button"
id={props.id}
id={controlId}
value={props.value}
className="om-option-menu-button"
role="combobox"
aria-labelledby={props.labelledBy}
aria-label={props.ariaLabel}
aria-label={
props.ariaLabel ?? (props.labelledBy ? undefined : t("wb.select"))
}
aria-describedby={props.describedBy}
aria-haspopup="listbox"
aria-expanded={open}
aria-controls={panelId}
aria-activedescendant={
open && activeIndex >= 0
? `${panelId}-option-${activeIndex}`
: undefined
}
disabled={props.disabled}
onClick={() => setOpen((current) => !current)}
onBlur={(event) => {
if (!root.current?.contains(event.relatedTarget as Node | null))
setOpen(false);
}}
onClick={() => {
if (preferredIndex < 0) return;
setActiveIndex(preferredIndex);
setOpen((current) => !current);
}}
onKeyDown={(event) => {
if (event.key === "ArrowDown" || event.key === "Enter") {
if (event.key === "ArrowDown") {
event.preventDefault();
setOpen(true);
if (!open) {
if (preferredIndex >= 0) {
setActiveIndex(preferredIndex);
setOpen(true);
}
} else {
setActiveIndex((current) => enabledIndex(current, 1));
}
} else if (event.key === "ArrowUp") {
event.preventDefault();
if (!open) {
if (preferredIndex >= 0) {
setActiveIndex(preferredIndex);
setOpen(true);
}
} else {
setActiveIndex((current) => enabledIndex(current, -1));
}
} else if (event.key === "Home" && open) {
event.preventDefault();
setActiveIndex(enabledIndex(-1, 1));
} else if (event.key === "End" && open) {
event.preventDefault();
setActiveIndex(enabledIndex(0, -1));
} else if (event.key === "Enter" || event.key === " ") {
event.preventDefault();
if (open) choose(activeIndex);
else if (preferredIndex >= 0) {
setActiveIndex(preferredIndex);
setOpen(true);
}
}
}}
>
{selected?.label ?? t("wb.select")}
{visibleLabel}
</button>
{open && (
<div className="om-option-menu-panel" role="listbox">
{props.options.map((item) => (
<div id={panelId} className="om-option-menu-panel" role="listbox">
{props.options.map((item, index) => (
<button
key={item.value || item.label}
id={`${panelId}-option-${index}`}
type="button"
role="option"
lang={item.lang}
data-active={activeIndex === index || undefined}
aria-selected={item.value === props.value}
disabled={item.disabled}
onClick={() => {
if (item.disabled) return;
props.onChange(item.value);
setOpen(false);
tabIndex={-1}
onMouseEnter={() => {
if (!item.disabled) setActiveIndex(index);
}}
onClick={() => choose(index)}
>
{item.label}
</button>
@@ -99,7 +99,7 @@ it("keeps selection status outside decorative fades and readable when disabled",
).toMatch(/opacity:\s*1/);
});
it("keeps the native selector keyboard-focusable and switches both languages", async () => {
it("keeps the product language menu keyboard-focusable and switches both languages", async () => {
render(
<LanguageProvider>
<R6ColdStartFrame
@@ -116,17 +116,19 @@ it("keeps the native selector keyboard-focusable and switches both languages", a
const selector = screen.getByRole("combobox");
await user.tab();
expect(document.activeElement).toBe(selector);
await user.selectOptions(selector, "es");
await user.click(selector);
await user.click(screen.getByRole("option", { name: "Español" }));
expect(document.documentElement.lang).toBe("es");
expect(screen.getByRole("combobox").getAttribute("id")).toBe(selector.id);
await user.selectOptions(selector, "zh-CN");
await user.click(selector);
await user.click(screen.getByRole("option", { name: "简体中文" }));
expect(document.documentElement.lang).toBe("zh-CN");
expect(screen.getByRole("combobox", { name: "界面语言" })).toBe(selector);
});
it.each([1, 2, 3] as const)(
"offers a compact language selector on step %s",
(step) => {
async (step) => {
render(
<LanguageProvider>
<R6ColdStartFrame
@@ -141,6 +143,7 @@ it.each([1, 2, 3] as const)(
);
const selector = screen.getByRole("combobox", { name: "界面语言" });
expect(selector.closest(".r6-language-selector--compact")).toBeTruthy();
await userEvent.click(selector);
expect(
screen.getByRole("option", { name: "简体中文" }).getAttribute("lang"),
).toBe("zh-CN");
+9 -2
View File
@@ -27,7 +27,8 @@ it("changes settings copy live without rereading profiles or changing permission
}),
).toBeNull();
const reads = vi.mocked(getModelProfiles).mock.calls.length;
await user.selectOptions(screen.getByRole("combobox"), "zh-CN");
await user.click(screen.getByRole("combobox"));
await user.click(screen.getByRole("option", { name: "Chino simplificado" }));
expect(await screen.findByRole("heading", { name: "记忆模型" })).toBeTruthy();
expect(vi.mocked(getModelProfiles).mock.calls).toHaveLength(reads);
});
@@ -66,7 +67,9 @@ vi.mock("./ModelSettings", async () => {
};
});
vi.mock("./ConnectionFallback", () => ({
ConnectionFallback: () => null,
ConnectionFallback: ({ defaultExpanded }: { defaultExpanded?: boolean }) => (
<p data-testid="connection-default">{String(defaultExpanded)}</p>
),
}));
vi.mock("./runtime-state", () => ({
refreshLoginPreference: vi.fn(),
@@ -95,7 +98,11 @@ it("shows the help and diagnostics group by default and hides it when opted out"
const user = userEvent.setup();
const view = render(<R6Settings onDiagnostics={vi.fn()} />);
expect(screen.getByRole("combobox")).toBeTruthy();
await user.click(screen.getByRole("combobox"));
expect(screen.getAllByRole("option")).toHaveLength(4);
await user.keyboard("{Escape}");
await user.click(screen.getByRole("button", { name: "连接其他应用" }));
expect(screen.getByTestId("connection-default").textContent).toBe("true");
await user.click(screen.getByRole("button", { name: "帮助与诊断" }));
expect(screen.getByRole("heading", { name: "帮助与诊断" })).toBeTruthy();
expect(screen.getByRole("heading", { name: "导出日志" })).toBeTruthy();
+1 -1
View File
@@ -401,7 +401,7 @@ export function R6Settings({
className="r6-settings-connections"
>
<h2>{text("连接其他应用")}</h2>
<ConnectionFallback />
<ConnectionFallback defaultExpanded />
</section>
{showDiagnostics && (
<section
+8 -4
View File
@@ -14,16 +14,20 @@ import { LANGUAGE_STORAGE_KEY, LanguageProvider } from "./gui-language";
import { OptionMenu } from "./OptionMenu";
import { Answer, NodeDetail } from "./Workbench";
it("localiza la selección vacía del menú auxiliar", () => {
it("localiza y mantiene cerrado el menú auxiliar sin opciones", async () => {
window.localStorage.setItem(LANGUAGE_STORAGE_KEY, "es");
render(
<LanguageProvider>
<OptionMenu value="" options={[]} onChange={vi.fn()} />
</LanguageProvider>,
);
expect(
screen.getByRole("button", { name: "Selecciona una opción" }),
).toBeTruthy();
const menu = screen.getByRole("combobox", {
name: "Selecciona una opción",
});
menu.focus();
await userEvent.keyboard("{Enter}{ArrowDown}");
expect(menu.getAttribute("aria-expanded")).toBe("false");
expect(menu.getAttribute("aria-activedescendant")).toBeNull();
window.localStorage.removeItem(LANGUAGE_STORAGE_KEY);
});
+12 -2
View File
@@ -1086,8 +1086,9 @@ button.map-tool {
border-radius: 6px;
}
.map-inspector {
overflow-y: auto;
overscroll-behavior: contain;
display: flex;
flex-direction: column;
overflow: hidden;
padding: 24px;
background: var(--glass-drawer);
border-left: 1px solid var(--bd);
@@ -1095,10 +1096,19 @@ button.map-tool {
}
.map-inspector-heading {
display: flex;
flex: none;
justify-content: space-between;
align-items: center;
margin-bottom: 22px;
}
.map-inspector-body {
flex: 1;
min-height: 0;
overflow-y: auto;
overflow-anchor: none;
overscroll-behavior: contain;
scrollbar-gutter: stable;
}
.map-inspector-heading h2 {
font-size: 13px;
font-weight: 600;
+14 -3
View File
@@ -55,18 +55,29 @@
gap: 6px 10px;
font-size: 12px;
}
.onboarding-r6-titlebar select {
.onboarding-r6-titlebar .r6-language-option-menu {
width: 144px;
max-width: 100%;
}
.onboarding-r6-titlebar .r6-language-option-menu .om-option-menu-button {
height: 32px;
min-width: 0;
max-width: 100%;
min-height: 32px;
padding: 4px 8px;
padding: 4px 32px 4px 10px;
border: 1px solid #9badbd;
border-radius: 8px;
color: inherit;
background: #f7fafc;
font: inherit;
}
.onboarding-r6-titlebar select:focus-visible {
.onboarding-r6-titlebar .r6-language-option-menu .om-option-menu-button::after {
top: 10px;
right: 11px;
}
.onboarding-r6-titlebar
.r6-language-option-menu
.om-option-menu-button:focus-visible {
outline: 2px solid #356d99;
outline-offset: 2px;
}
+249 -72
View File
@@ -1,8 +1,8 @@
/* R6 source 139:5728 / 6765 / 7176 / 7585. Overrides stay inside settings. */
.r6-settings {
box-sizing: border-box;
padding: 26px 40px 46px;
color: #262a2e;
padding: 30px 40px 48px;
color: #304757;
min-width: 0;
width: 100%;
flex: 1;
@@ -10,161 +10,230 @@
letter-spacing: 0;
}
.r6-settings > h1 {
height: 44px;
height: auto;
font:
500 28px / 44px "Noto Sans SC",
600 26px / 36px "Noto Sans SC",
"PingFang SC",
sans-serif;
margin: 0 0 28px;
margin: 0 0 22px;
}
.r6-settings .r6-language-setting {
margin-bottom: 24px;
position: relative;
z-index: 2;
margin-bottom: 20px;
padding: 20px 24px;
border: 1px solid #dce4eb;
border-radius: 16px;
background: #fff;
border: 1px solid #d5dfe8;
border-radius: 12px;
background: rgb(255 255 255 / 78%);
}
.r6-language-setting-title {
margin: 0 0 18px;
font-size: 18px;
line-height: 28px;
margin: 0 0 14px;
font-size: 16px;
font-weight: 600;
line-height: 26px;
}
.r6-settings .r6-language-setting-row {
display: grid;
grid-template-columns: 96px minmax(0, 1fr);
grid-template-columns: 104px minmax(0, 1fr);
align-items: center;
gap: 16px;
min-height: 44px;
}
.r6-settings .r6-language-setting-row label {
min-width: 0;
font-size: 14px;
line-height: 24px;
color: #4f6373;
font-size: 13px;
font-weight: 500;
line-height: 22px;
white-space: nowrap;
}
.r6-settings .r6-language-setting select {
box-sizing: border-box;
.r6-settings .r6-language-option-menu {
width: 100%;
max-width: 100%;
min-width: 0;
min-height: 44px;
padding: 8px 12px;
border: 1px solid #dce4eb;
border-radius: 8px;
color: inherit;
background: #fff;
font: inherit;
}
.r6-settings .r6-language-setting select:focus-visible {
outline: 2px solid #225f9d;
.r6-settings .r6-language-option-menu .om-option-menu-button {
height: 44px;
min-height: 44px;
padding: 0 42px 0 14px;
border: 1px solid #c6d2da;
border-radius: 8px;
color: #304757;
background: #fff;
font-size: 13px;
line-height: 22px;
}
.r6-settings .r6-language-option-menu .om-option-menu-button::after {
top: 16px;
right: 16px;
}
.r6-settings
.r6-language-option-menu
.om-option-menu-button[aria-expanded="true"] {
border-color: #7892a6;
box-shadow: 0 0 0 3px rgb(85 124 147 / 12%);
}
.r6-settings .r6-language-option-menu .om-option-menu-panel {
top: calc(100% + 6px);
padding: 6px;
border-color: #c6d2da;
box-shadow: 0 12px 32px rgb(48 71 87 / 13%);
}
.r6-settings .r6-language-option-menu .om-option-menu-panel [role="option"] {
min-height: 38px;
padding: 8px 10px;
color: #304757;
font-size: 13px;
line-height: 22px;
}
.r6-settings
.r6-language-option-menu
.om-option-menu-panel
[role="option"][aria-selected="true"] {
background: #dfe9ef;
font-weight: 500;
}
.r6-settings
.r6-language-option-menu
.om-option-menu-panel
[role="option"][data-active="true"] {
color: #304757;
background: #e9f0f4;
}
.r6-settings
.r6-language-option-menu
.om-option-menu-panel
[role="option"]:active:not(:disabled) {
color: #304757;
background: #d4e2ea;
}
.r6-settings .r6-language-option-menu .om-option-menu-button:focus-visible {
outline: 2px solid #557c93;
outline-offset: 2px;
}
.r6-language-setting-description {
margin: 16px 0 0;
margin: 12px 0 0 120px;
color: #5e6e7a;
font-size: 13px;
line-height: 22px;
line-height: 21px;
overflow-wrap: anywhere;
}
.r6-settings .r6-settings-layout {
display: grid;
grid-template-columns: 220px minmax(0, 1fr);
gap: 24px;
align-items: start;
grid-template-columns: 190px minmax(0, 1fr);
gap: 0;
align-items: stretch;
border: 1px solid #d5dfe8;
border-radius: 12px;
background: #fff;
}
.r6-settings .r6-settings-layout > nav {
box-sizing: border-box;
min-height: 710px;
padding: 13px 11px;
background: rgb(255 255 255 / 27.45%);
border: 1px solid white;
border-radius: 16px;
box-shadow: inset 0 1px white;
backdrop-filter: blur(72px);
min-height: 560px;
padding: 16px 12px;
background: #f3f7f9;
border: 0;
border-right: 1px solid #d5dfe8;
border-radius: 11px 0 0 11px;
box-shadow: none;
backdrop-filter: none;
display: flex;
flex-direction: column;
align-items: stretch;
gap: 14px;
gap: 4px;
}
.r6-settings .r6-settings-layout > nav > button {
box-sizing: border-box;
padding: 8px 16px;
padding: 8px 12px;
height: auto;
min-height: 46px;
min-height: 42px;
text-align: left;
border: 0;
border-radius: 8px;
background: transparent;
color: #262a2e;
color: #405665;
font:
400 16px / 28px "Noto Sans SC",
400 14px / 22px "Noto Sans SC",
"PingFang SC",
sans-serif;
box-shadow: none;
transform: none;
cursor: pointer;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
transition:
color 140ms ease-out,
background-color 140ms ease-out;
}
.r6-settings .r6-settings-layout > nav > button[aria-current="page"] {
background: #e1eaf0;
color: #304757;
background: #dfe9ef;
font-weight: 500;
}
.r6-settings .r6-settings-layout > nav > button:hover:not(:disabled) {
background: rgb(225 234 240 / 65%);
color: #304757;
background: #e9f0f4;
transform: none;
}
.r6-settings .r6-settings-layout > nav > button:active:not(:disabled) {
color: #304757;
background: #d4e2ea;
}
.r6-settings .r6-settings-content {
box-sizing: border-box;
min-width: 0;
min-height: 710px;
padding: 25px 31px;
min-height: 560px;
padding: 28px 34px;
background: #fff;
border: 1px solid #dce4eb;
border-radius: 16px;
border: 0;
border-radius: 0 11px 11px 0;
overflow-wrap: anywhere;
}
.r6-settings .r6-settings-content > section[hidden] {
display: none;
}
.r6-settings .r6-settings-content > section > h2 {
min-height: 36px;
min-height: 32px;
font:
500 22px / 36px "Noto Sans SC",
600 20px / 32px "Noto Sans SC",
"PingFang SC",
sans-serif;
margin: 0;
text-wrap: balance;
}
.r6-settings .r6-setting-row {
box-sizing: border-box;
display: flex;
align-items: flex-start;
align-items: center;
justify-content: space-between;
gap: 24px;
padding: 30px 0 25px;
gap: 32px;
padding: 24px 0;
border-bottom: 1px solid #d8e2ea;
min-height: 132px;
min-height: 108px;
}
.r6-settings .r6-setting-row > div {
flex: 1;
min-width: 0;
max-width: 68ch;
}
.r6-settings .r6-setting-row > button {
margin-top: 12px;
margin-top: 0;
flex-shrink: 0;
}
.r6-settings .r6-setting-row h3 {
font:
400 18px / 28px "Noto Sans SC",
500 16px / 26px "Noto Sans SC",
"PingFang SC",
sans-serif;
margin: 0 0 10px;
margin: 0 0 6px;
}
.r6-settings .r6-setting-row p {
font-size: 14px;
line-height: 26px;
color: #63707b;
font-size: 13px;
line-height: 23px;
color: #5e6e7a;
margin: 0;
overflow-wrap: anywhere;
text-wrap: pretty;
}
.r6-settings .r6-setting-row > span {
font-size: 13px;
@@ -205,6 +274,97 @@
font-size: 14px;
color: #4f6373;
}
.r6-settings .connection-fallback {
margin: 22px 0 0;
padding: 0;
border: 0;
}
.r6-settings .connection-fallback summary {
width: fit-content;
min-height: 36px;
padding: 6px 8px;
margin-left: -8px;
border-radius: 6px;
color: #405665;
font-size: 14px;
font-weight: 500;
line-height: 24px;
transition:
color 140ms ease-out,
background-color 140ms ease-out;
}
.r6-settings .connection-fallback summary:hover {
color: #304757;
background: #e9f0f4;
}
.r6-settings .connection-fallback summary:active {
color: #304757;
background: #d4e2ea;
}
.r6-settings .connection-fallback .connection-boundary {
max-width: 68ch;
margin: 16px 0 20px;
color: #5e6e7a;
font-size: 13px;
line-height: 23px;
text-wrap: pretty;
}
.r6-settings .connection-fallback .custom-harness-name {
max-width: 560px;
gap: 8px;
margin: 0 0 14px;
color: #4f6373;
font-size: 13px;
font-weight: 500;
}
.r6-settings .connection-fallback .custom-harness-name input {
box-sizing: border-box;
width: 100%;
min-height: 44px;
padding: 10px 13px;
border: 1px solid #c6d2da;
border-radius: 8px;
background: #fff;
color: #304757;
font-size: 14px;
}
.r6-settings .connection-fallback .custom-harness-name input:hover {
border-color: #aabcc8;
}
.r6-settings .connection-fallback .custom-harness-name input:focus-visible {
outline: 2px solid #557c93;
outline-offset: 2px;
}
.r6-settings .connection-fallback > button:not(.quiet) {
min-height: 42px;
padding: 0 18px;
border: 1px solid #5b6f7e;
border-radius: 8px;
background: #4f6373;
color: #fff;
font-size: 13px;
font-weight: 500;
cursor: pointer;
transition:
background-color 140ms ease-out,
transform 90ms ease-out;
}
.r6-settings .connection-fallback > button:not(.quiet):hover:not(:disabled) {
border-color: #5b6f7e;
background: #435966;
color: #fff;
}
.r6-settings .connection-fallback > button:not(.quiet):active:not(:disabled) {
background: #3d4f5d;
color: #fff;
transform: scale(0.985);
}
.r6-settings .connection-fallback > button:not(.quiet):disabled {
border-color: #c6d2da;
background: #dbe4e9;
color: #647783;
cursor: default;
}
.r6-settings .r6-settings-error {
color: #953f3f;
font-size: 14px;
@@ -222,7 +382,7 @@
line-height: 24px;
}
.r6-settings .r6-model-overview {
padding: 32px 0 24px;
padding: 26px 0 20px;
border-bottom: 0;
}
.r6-settings .r6-model-caption {
@@ -233,7 +393,7 @@
}
.r6-settings .r6-current-model {
font:
400 24px / 40px "Noto Sans SC",
500 22px / 34px "Noto Sans SC",
"PingFang SC",
sans-serif;
margin: 0 0 7px;
@@ -363,23 +523,26 @@
}
.r6-settings .r6-settings-help .r6-setting-row {
display: flex;
min-height: 132px;
padding: 30px 0 25px;
min-height: 108px;
padding: 24px 0;
align-items: center;
gap: 32px;
}
.r6-settings .r6-settings-help .r6-setting-row h3 {
font-size: 18px;
margin-bottom: 10px;
font-size: 16px;
margin-bottom: 6px;
}
.r6-settings .r6-settings-help .r6-setting-row p {
font-size: 14px;
font-size: 13px;
}
.r6-settings .r6-settings-help .r6-setting-row > button {
.r6-settings .r6-settings-help .r6-setting-row > button:not(.r6-switch) {
min-width: 132px;
margin-top: 0;
margin-left: auto;
}
.r6-settings .r6-settings-help .r6-setting-row > .r6-switch {
margin-left: auto;
}
.r6-settings .r6-settings-help .r6-settings-about {
min-height: 0;
padding-bottom: 24px;
@@ -534,10 +697,9 @@
}
.r6-settings .r6-settings-layout {
grid-template-columns: 176px minmax(0, 1fr);
gap: 20px;
}
.r6-settings .r6-settings-content {
padding: 25px 23px;
padding: 26px 28px;
}
.r6-settings .r6-setting-row {
flex-wrap: wrap;
@@ -559,6 +721,9 @@
flex-direction: row;
flex-wrap: wrap;
gap: 8px;
border-right: 0;
border-bottom: 1px solid #d5dfe8;
border-radius: 11px 11px 0 0;
}
.r6-settings .r6-settings-layout > nav > button {
font-size: 14px;
@@ -566,6 +731,18 @@
.r6-settings .model-form {
grid-template-columns: minmax(0, 1fr);
}
.r6-settings .r6-settings-content {
min-height: 0;
padding: 24px 20px;
border-radius: 0 0 11px 11px;
}
.r6-settings .r6-language-setting-row {
grid-template-columns: minmax(0, 1fr);
gap: 8px;
}
.r6-language-setting-description {
margin-left: 0;
}
}
@media (prefers-reduced-transparency: reduce) {
.r6-settings .r6-settings-layout > nav {
+7 -8
View File
@@ -219,12 +219,14 @@
}
.r6-shell .r6-memory .map-inspector {
padding: 14px 22px 24px;
background: rgb(255 255 255 / 30%);
background: #f2f6f8;
border: 0;
border-radius: 0;
overflow-y: auto;
overflow-anchor: none;
overflow: hidden;
min-height: 0;
isolation: isolate;
backdrop-filter: none;
-webkit-backdrop-filter: none;
}
.r6-shell .r6-memory .map-inspector-heading {
padding: 0;
@@ -498,11 +500,8 @@
width: min(418px, calc(100vw - 32px));
}
.r6-shell .r6-memory .map-inspector-heading {
position: sticky;
top: -14px;
z-index: 1;
background: #eff5f8;
padding: 10px 0;
position: static;
padding: 0;
}
.selected-editor {
padding-left: 0;
+1 -1
View File
@@ -2,7 +2,7 @@
"name": "one-memory",
"version": "1.0.5",
"release": {
"build_number": 33,
"build_number": 32,
"macos_min_version": "13.0",
"app_update": "manual",
"runtime_manifest_schema": 1,
+1 -2
View File
@@ -1809,8 +1809,7 @@ const baseEnglishMessages = {
"{count} memories created{filter} · Showing {shown}",
"memory:({filter})": " ({filter})",
"memory:第 {page} 页": "Page {page}",
"memory:原话已保存,正在等待整理:":
"Original text saved, awaiting processing: ",
"memory:正在等待整理:": "Awaiting processing: ",
"memory:记忆未在生成": "Memories are not being created",
"memory:还没有开始生成记忆:{reason}({count} 条待处理)":
"Memories are not being created: {reason} ({count} pending)",
+2 -3
View File
@@ -370,7 +370,7 @@ export const memoryChineseMessages = {
"已沉淀 {count} 条{filter} · 当前展示 {shown} 条",
"memory:({filter})": "({filter})",
"memory:第 {page} 页": "第 {page} 页",
"memory:原话已保存,正在等待整理:": "原话已保存,正在等待整理:",
"memory:正在等待整理:": "正在等待整理:",
"memory:记忆未在生成": "记忆未在生成",
"memory:还没有开始生成记忆:{reason}({count} 条待处理)":
"还没有开始生成记忆:{reason}({count} 条待处理)",
@@ -786,8 +786,7 @@ export const memorySpanishMessages: Record<
"{count} memoria guardada{filter} · {shown} visibles",
"memory:({filter})": " ({filter})",
"memory:第 {page} 页": "Página {page}",
"memory:原话已保存,正在等待整理:":
"Texto guardado; pendiente de procesar: ",
"memory:正在等待整理:": "Pendiente de procesar: ",
"memory:记忆未在生成": "Generación de memorias detenida",
"memory:还没有开始生成记忆:{reason}({count} 条待处理)":
"Aún no se generan memorias: {reason} ({count} pendientes)",