From 5271bddce3ba717ba616ff58bc7359d9cc313d5b Mon Sep 17 00:00:00 2001 From: kristinhe Date: Tue, 3 Nov 2026 22:13:30 +0800 Subject: [PATCH] fix(memhub): preserve raw conversations in lite mode Co-authored-by: Cursor --- memhub/apps/core/src/one_memory/api.py | 76 ++++++++--- .../one_memory/memory_server/durable_queue.py | 6 +- .../apps/core/src/one_memory/model_backoff.py | 10 +- .../core/src/one_memory/sdk_memory_runtime.py | 7 +- .../src/one_memory/sdk_write_processor.py | 43 +++--- memhub/apps/core/tests/test_live_capture.py | 2 +- .../core/tests/test_memory_mode_and_evolve.py | 122 +++++++++++++++++- .../tests/test_memory_server_durable_queue.py | 10 +- .../tests/test_memory_server_unified_entry.py | 2 +- memhub/apps/core/tests/test_model_backoff.py | 13 +- .../tests/test_sdk_data_plane_migration.py | 2 +- memhub/apps/web/src/ApplicationsHome.test.tsx | 26 ++++ memhub/apps/web/src/ApplicationsHome.tsx | 11 +- memhub/apps/web/src/MemoryMap.test.tsx | 74 +++++++++++ memhub/apps/web/src/MemoryMap.tsx | 48 +++++-- memhub/apps/web/src/MemorySpace.r6.test.tsx | 97 +++++++++++++- memhub/apps/web/src/MemorySpace.tsx | 58 ++++++--- .../apps/web/src/SelectedRawDetail.test.tsx | 43 ++++++ memhub/apps/web/src/Workbench.tsx | 80 ++++++++---- memhub/apps/web/src/api.raw.test.ts | 57 ++++++++ memhub/apps/web/src/api.ts | 4 +- memhub/apps/web/src/r6-memory.css | 54 ++++++-- memhub/docs/ARCHITECTURE.md | 3 +- .../memory-event-time-scheduling-v1.md | 4 +- .../docs/contracts/memory-mode-evolve-v1.md | 3 + 25 files changed, 727 insertions(+), 128 deletions(-) create mode 100644 memhub/apps/web/src/SelectedRawDetail.test.tsx create mode 100644 memhub/apps/web/src/api.raw.test.ts diff --git a/memhub/apps/core/src/one_memory/api.py b/memhub/apps/core/src/one_memory/api.py index 6eca464..e0a0b28 100644 --- a/memhub/apps/core/src/one_memory/api.py +++ b/memhub/apps/core/src/one_memory/api.py @@ -1472,22 +1472,15 @@ def create_app( async def admit_hy_invocation( source_id: str, + requires_generation: bool, mark_invoked: Callable[[], Awaitable[bool]], ) -> bool: - """Serialize pause with the last boundary before an HY call.""" - source_lock = app.state.source_permission_locks.setdefault( - source_id, asyncio.Lock() + return await _admit_pipeline_invocation( + app, + source_id, + requires_generation, + mark_invoked, ) - async with app.state.sedimentation_lock, source_lock: - if not _config_store(app).sedimentation_enabled(): - raise RuntimeError("model_sedimentation_disabled") - if not _embedding_writes_open(app): - # Re-checked at the last boundary: a switch that began - # after the claim keeps this job queued, unsent. - raise RuntimeError("embedding_switch_in_progress") - if not _source_writes_on(app, source_id): - raise RuntimeError("model_content_source_disabled") - return await mark_invoked() memory_write_drain = DurableWriteDrain( memory_write_queue, @@ -1536,7 +1529,7 @@ def create_app( # therefore skipped on every drain tick — the note stayed # "saved, waiting" with no task behind it, forever. source_claim_enabled=lambda source_id: _sedimentation_claim_enabled(app, source_id), - model_claim_allowance=app.state.model_backoff.allowance, + model_claim_allowance=lambda: _model_claim_allowance(app), ) app.state.memory_write_drain = memory_write_drain @@ -1949,6 +1942,12 @@ def create_app( await step("embedding_spaces", resume_embedding_spaces) await step("graph_backfill", resume_graph_backfill) await step("write_drain", start_write_drain) + if sdk and _memory_mode(app) == "lite": + # A previous pro run may have parked jobs while model + # sedimentation was off. Lite needs no generation model: + # readmit them in the background so startup can finish + # while raw conversations begin appearing. + _resume_sedimentation(app) if sdk: await step("evolve_schedule", start_evolve_schedule) else: @@ -4742,6 +4741,8 @@ def create_app( outcome="succeeded", details={"mode": effective, "previous_mode": previous_effective}, ) + if effective == "lite" and effective != previous_effective: + _resume_sedimentation(app) return MemoryModeChange( **_memory_mode_state(app).model_dump(), previous_mode=previous_effective, @@ -5836,10 +5837,19 @@ def _resume_sedimentation(app: FastAPI) -> None: """ running = cast("asyncio.Task[None] | None", getattr(app.state, "sedimentation_resume", None)) if running is not None and not running.done(): + app.state.sedimentation_resume_again = True return + + async def resume_until_stable() -> None: + while True: + app.state.sedimentation_resume_again = False + await _sdk_memory_runtime(app).resume_allowed() + if not app.state.sedimentation_resume_again: + return + app.state.sedimentation_resume = watched_task( asyncio.get_running_loop(), - lambda: _sdk_memory_runtime(app).resume_allowed(), + resume_until_stable, component="memory.sedimentation", name="one-memory-sedimentation-resume", ) @@ -6589,7 +6599,7 @@ def _sedimentation_claim_enabled(app: FastAPI, source_id: str) -> bool: """ return ( - _config_store(app).sedimentation_enabled() + _pipeline_writes_enabled(app) and _embedding_writes_open(app) and (_memory_mode(app) == "lite" or _model_profile_store(app).active_identity() is not None) and _history_import_gate_released(app) @@ -6598,6 +6608,40 @@ def _sedimentation_claim_enabled(app: FastAPI, source_id: str) -> bool: ) +async def _admit_pipeline_invocation( + app: FastAPI, + source_id: str, + requires_generation: bool, + mark_invoked: Callable[[], Awaitable[bool]], +) -> bool: + """Serialize the final source and pipeline checks with the durable mark.""" + + source_lock = app.state.source_permission_locks.setdefault(source_id, asyncio.Lock()) + async with app.state.sedimentation_lock, source_lock: + if requires_generation and not _config_store(app).sedimentation_enabled(): + raise RuntimeError("model_sedimentation_disabled") + if not _embedding_writes_open(app): + raise RuntimeError("embedding_switch_in_progress") + if not _source_writes_on(app, source_id): + raise RuntimeError("model_content_source_disabled") + return await mark_invoked() + + +def _pipeline_writes_enabled(app: FastAPI) -> bool: + """Raw-only lite writes run even when model sedimentation is paused.""" + + return _memory_mode(app) == "lite" or _config_store(app).sedimentation_enabled() + + +def _model_claim_allowance(app: FastAPI) -> int | None: + """A failed generation model never pauses raw-only lite ingestion.""" + + backoff = cast(ModelBackoff, app.state.model_backoff) + if _memory_mode(app) == "lite": + return backoff.allowance(kind="embedding") + return backoff.allowance() + + def _user_evolving(app: FastAPI, user_id: str) -> bool: """Whether evolve holds this user's writes right now.""" coordinator = getattr(app.state, "sdk_write_coordinator", None) diff --git a/memhub/apps/core/src/one_memory/memory_server/durable_queue.py b/memhub/apps/core/src/one_memory/memory_server/durable_queue.py index 818f5ca..62c503a 100644 --- a/memhub/apps/core/src/one_memory/memory_server/durable_queue.py +++ b/memhub/apps/core/src/one_memory/memory_server/durable_queue.py @@ -2540,7 +2540,7 @@ async def execute_job( evidence: EvidenceStore, owner: str | None = None, source_writes_permitted: Callable[[str], bool] | None = None, - admit_hy_invocation: Callable[[str, Callable[[], Awaitable[bool]]], Awaitable[bool]] + admit_hy_invocation: Callable[[str, bool, Callable[[], Awaitable[bool]]], Awaitable[bool]] | None = None, model_failures: Callable[[], AbstractContextManager[ModelAttempt | None]] | None = None, ) -> None: @@ -2593,7 +2593,7 @@ async def execute_job( hy_invoked = False - async def mark_hy_invocation() -> None: + async def mark_hy_invocation(requires_generation: bool) -> None: """Persist the durable boundary immediately before the real HY call.""" nonlocal hy_invoked if hy_invoked: @@ -2621,7 +2621,7 @@ async def execute_job( return await in_the_background(queue.mark_invoked, job_id, owner=owner) if admit_hy_invocation is not None: - admitted = await admit_hy_invocation(source_id, mark) + admitted = await admit_hy_invocation(source_id, requires_generation, mark) else: if source_writes_permitted is not None and not source_writes_permitted(source_id): raise RuntimeError("model_content_source_disabled") diff --git a/memhub/apps/core/src/one_memory/model_backoff.py b/memhub/apps/core/src/one_memory/model_backoff.py index 501802c..73ed1f9 100644 --- a/memhub/apps/core/src/one_memory/model_backoff.py +++ b/memhub/apps/core/src/one_memory/model_backoff.py @@ -247,15 +247,17 @@ class ModelBackoff: and self._clock() < self._until ) - def allowance(self) -> int | None: - """How many model jobs may be claimed now: None for no limit. + def allowance(self, *, kind: str | None = None) -> int | None: + """How many matching provider jobs may be claimed now. 0 while paused; once the pause is over, one probe at a time until it - reports or `PROBE_HOLD_SECONDS` pass. + reports or `PROBE_HOLD_SECONDS` pass. ``kind`` keeps a failed chat + model from pausing raw-only work that needs embeddings, while an + embedding outage still paces that work. """ now = self._clock() with self._lock: - if self._failures == 0: + if self._failures == 0 or (kind is not None and kind not in self._failing): return None if now < self._until or now < self._probe_until: return 0 diff --git a/memhub/apps/core/src/one_memory/sdk_memory_runtime.py b/memhub/apps/core/src/one_memory/sdk_memory_runtime.py index 7a60258..e12e729 100644 --- a/memhub/apps/core/src/one_memory/sdk_memory_runtime.py +++ b/memhub/apps/core/src/one_memory/sdk_memory_runtime.py @@ -25,7 +25,7 @@ from one_memory.hy_runtime_config import HY_MEMORY_READER from one_memory.hy_sdk_runtime import SdkClientHandle, sdk_relevance_floor from one_memory.hy_sdk_types import HySdkError, parse_sdk_search from one_memory.memory_queue import ExtractionWindowPreset, QueueDepths -from one_memory.memory_server.durable_queue import DurableWriteQueue +from one_memory.memory_server.durable_queue import WAITING_FOR_MODEL, DurableWriteQueue from one_memory.sdk_write_processor import SdkWriteCoordinator from one_memory.semantic_deadline import SemanticMemoryHit @@ -309,7 +309,10 @@ class SdkMemoryRuntime: return [] async def resume_allowed(self) -> None: - await asyncio.to_thread(self.write_queue.readmit_parked) + await asyncio.to_thread( + self.write_queue.readmit_parked, + states=(WAITING_FOR_MODEL,), + ) def accepted_backfill_status(self) -> None: return None diff --git a/memhub/apps/core/src/one_memory/sdk_write_processor.py b/memhub/apps/core/src/one_memory/sdk_write_processor.py index 3ce09b6..2659770 100644 --- a/memhub/apps/core/src/one_memory/sdk_write_processor.py +++ b/memhub/apps/core/src/one_memory/sdk_write_processor.py @@ -23,11 +23,12 @@ from one_memory.hy_sdk_runtime import ( SdkClientHandle, builtin_retrieval_snapshot, language_kwargs, + retrieval_view, ) from one_memory.imports.models import EvidenceContentKind, EvidenceOrigin, EvidenceRecord from one_memory.input_sanitization import sanitize_model_input from one_memory.memory_features import FeatureOverrides -from one_memory.model_profiles import ModelProfileSnapshot, ModelProfileStore +from one_memory.model_profiles import ModelProfileSnapshot, ModelProfileStore, ProviderKind from one_memory.secrets import SecretStore, SecretStoreUnavailableError PRODUCT_AGENT_ID = "all-apps" @@ -112,7 +113,7 @@ class SdkWriteProcessor: records: tuple[EvidenceRecord, ...], *, request_id: str, - before_hy_invocation: Callable[[], Awaitable[None]], + before_hy_invocation: Callable[[bool], Awaitable[None]], ) -> SdkWriteReceipt: if not records: raise RuntimeError("hy_memory_task_without_inputs") @@ -139,7 +140,7 @@ class SdkWriteProcessor: if extracted and not self._lite: contents = [sanitize_model_input(record.verbatim) for record in records] - await before_hy_invocation() + await before_hy_invocation(True) result = await _write( request_id, client.add_extracted, @@ -167,7 +168,7 @@ class SdkWriteProcessor: ] if not messages: raise RuntimeError("hy_memory_task_without_inputs") - await before_hy_invocation() + await before_hy_invocation(not self._lite) result = await _write( request_id, client.add, @@ -283,10 +284,8 @@ class SdkWriteCoordinator: records: tuple[EvidenceRecord, ...], *, request_id: str, - before_hy_invocation: Callable[[], Awaitable[None]], + before_hy_invocation: Callable[[bool], Awaitable[None]], ) -> SdkWriteReceipt: - if not self.sedimentation_enabled(): - raise RuntimeError("model_sedimentation_disabled") if not self.writes_open(): # An embedding switch paused writes: the drain puts the job back. raise RuntimeError("embedding_switch_in_progress") @@ -297,9 +296,8 @@ class SdkWriteCoordinator: self._writing[user_id] = self._writing.get(user_id, 0) + 1 try: async with self._active_handle() as handle: - return await SdkWriteProcessor( - handle, lite=getattr(handle, "mode", None) == "lite" - ).process_sdk_job( + lite = getattr(handle, "mode", None) == "lite" + return await SdkWriteProcessor(handle, lite=lite).process_sdk_job( records, request_id=request_id, before_hy_invocation=before_hy_invocation, @@ -466,24 +464,27 @@ class SdkWriteCoordinator: return (builtin.profile_id, builtin.revision), builtin, None, space identity = (space.profile_id, space.profile_revision) profile = self.profiles.snapshot(*identity) - try: - secret = self.profiles.resolve_snapshot_secret(*identity, self.secrets) - except SecretStoreUnavailableError: - raise RuntimeError("secure_storage_unavailable") from None - if profile.has_api_key and secret is None: - raise RuntimeError("model_secret_unavailable") - return identity, profile, secret, space + view = retrieval_view(profile) + secret: str | None = None + if view.embedding_provider_kind is ProviderKind.OPENAI_COMPATIBLE: + try: + secret = self.profiles.resolve_snapshot_secret(*identity, self.secrets) + except SecretStoreUnavailableError: + raise RuntimeError("secure_storage_unavailable") from None + if secret is None: + raise RuntimeError("model_secret_unavailable") + return identity, view, secret, space def _read_active_credentials( self, mode: str = "pro", ) -> tuple[tuple[str, int], ModelProfileSnapshot, str | None, EmbeddingSpace | None]: + if mode == "lite": + # A lite write is an embedding-only view of the live space. Never + # resolve or inject the generation model endpoint or its secret. + return self._lite_credentials() identity = self.profiles.active_identity() if identity is None: - if mode == "lite": - # lite stores raw turns with one embedding and calls no model: - # the live space's own, so they land where reads look. - return self._lite_credentials() raise RuntimeError("model_profile_not_active") profile = self.profiles.snapshot(*identity) space = self._live_space() if self.spaces is not None else None diff --git a/memhub/apps/core/tests/test_live_capture.py b/memhub/apps/core/tests/test_live_capture.py index 75cf198..0f48313 100644 --- a/memhub/apps/core/tests/test_live_capture.py +++ b/memhub/apps/core/tests/test_live_capture.py @@ -298,7 +298,7 @@ def test_hook_job_completes_after_file_identity_adoption(tmp_path) -> None: assert current is not None assert ingest_journal.matches_current(connection, current) connection.rollback() - await before_hy_invocation() + await before_hy_invocation(True) return SdkWriteReceipt() drain = DurableWriteDrain(queue, execute=lambda _job: None) diff --git a/memhub/apps/core/tests/test_memory_mode_and_evolve.py b/memhub/apps/core/tests/test_memory_mode_and_evolve.py index fced7df..d1152f6 100644 --- a/memhub/apps/core/tests/test_memory_mode_and_evolve.py +++ b/memhub/apps/core/tests/test_memory_mode_and_evolve.py @@ -307,7 +307,7 @@ class _SdkHandle: return None -async def _noop() -> None: +async def _noop(_requires_generation: bool) -> None: return None @@ -330,14 +330,14 @@ def test_a_note_in_lite_is_stored_as_raw_and_never_calls_add_extracted() -> None ] -def test_a_lite_write_runs_through_the_coordinator_without_a_model(monkeypatch) -> None: +def test_a_lite_write_ignores_the_model_sedimentation_pause(monkeypatch) -> None: sdk = _WritingSdk() coordinator = sdk_write_processor.SdkWriteCoordinator( profiles=_Profiles(identity=None), # type: ignore[arg-type] secrets=None, # type: ignore[arg-type] data_dir=None, transport_factory=lambda _profile: None, - sedimentation_enabled=lambda: True, + sedimentation_enabled=lambda: False, memory_mode=lambda: "lite", ) monkeypatch.setattr( @@ -812,6 +812,13 @@ def test_mode_api_get_put_and_its_effects(tmp_path, monkeypatch) -> None: assert current["explicitly_configured"] is False assert current["forced_by_env"] is False assert app.state.sdk_write_coordinator.memory_mode() == "pro" + assert ( + await client.put("/api/v1/sedimentation", json={"enabled": False}) + ).status_code == 200 + from one_memory.api import _sedimentation_claim_enabled + + app.state.history_import_gate_released = True + assert not _sedimentation_claim_enabled(app, "one-memory-manual") lite = await client.put("/api/v1/settings/memory-mode", json={"mode": "lite"}) assert lite.status_code == 200, lite.text @@ -822,10 +829,7 @@ def test_mode_api_get_put_and_its_effects(tmp_path, monkeypatch) -> None: assert app.state.sdk_write_coordinator.memory_mode() == "lite" assert _sdk_client_handle(app).memory_mode() == "lite" # lite claims queued work without a generation model. - from one_memory.api import _sedimentation_claim_enabled - assert app.state.model_profile_store.active_identity() is None - app.state.history_import_gate_released = True assert _sedimentation_claim_enabled(app, "one-memory-manual") ultra = await client.put("/api/v1/settings/memory-mode", json={"mode": "ultra"}) @@ -851,6 +855,28 @@ def test_mode_api_get_put_and_its_effects(tmp_path, monkeypatch) -> None: run_app(tmp_path, monkeypatch, scenario) +@pytest.mark.fresh_sdk +def test_lite_startup_readmits_jobs_while_model_sedimentation_is_off(tmp_path, monkeypatch) -> None: + store = ConfigStore(tmp_path / "data") + store.set_memory_mode("lite") + store.set_sedimentation_enabled(False) + resumed = asyncio.Event() + original = SdkMemoryRuntime.resume_allowed + + async def resume_allowed(runtime): + await original(runtime) + resumed.set() + + monkeypatch.setattr(SdkMemoryRuntime, "resume_allowed", resume_allowed) + + async def scenario(app, _sdk, _client): + await asyncio.wait_for(resumed.wait(), 5) + assert app.state.config_store.sedimentation_enabled() is False + assert app.state.config_store.memory_mode() == "lite" + + run_app(tmp_path, monkeypatch, scenario) + + @pytest.mark.fresh_sdk def test_embedding_maintenance_blocks_mode_put_and_evolve_but_not_mode_get( tmp_path, monkeypatch @@ -1034,3 +1060,87 @@ def test_a_job_written_in_lite_stays_done_and_a_pending_job_takes_the_new_mode( await coordinator.close() run_app(tmp_path, monkeypatch, scenario) + + +@pytest.mark.fresh_sdk +def test_paused_model_still_writes_a_lite_job_through_the_durable_boundary( + tmp_path, monkeypatch +) -> None: + from one_memory.api import _admit_pipeline_invocation + from one_memory.memory_server.durable_queue import DurableWriteDrain, execute_job + + seen: list[str] = [] + + class Sdk: + mode = "lite" + + def add(self, messages, **kwargs): + seen.append("add") + return {"success": True, "raw_ids": ["raw-lite"]} + + def add_extracted(self, contents, **kwargs): + seen.append("add_extracted") + raise AssertionError("lite must never call add_extracted") + + monkeypatch.setattr( + sdk_write_processor, + "SdkClientHandle", + lambda profile, **kwargs: _SdkHandle(Sdk(), "lite"), + ) + coordinator = sdk_write_processor.SdkWriteCoordinator( + profiles=_Profiles(identity=None), # type: ignore[arg-type] + secrets=None, # type: ignore[arg-type] + data_dir=None, + transport_factory=lambda _profile: None, + sedimentation_enabled=lambda: False, + memory_mode=lambda: "lite", + ) + + async def never(_job_id): + return None + + async def scenario(app, _sdk, client): + app.state.memory_write_drain.stop() + if app.state.memory_write_drain_task is not None: + await app.state.memory_write_drain_task + app.state.memory_write_drain_task = None + await client.put("/api/v1/sedimentation", json={"enabled": False}) + await client.put("/api/v1/settings/memory-mode", json={"mode": "lite"}) + app.state.history_import_gate_released = True + written = await client.post( + "/api/v2/memory-writes", + json={ + "request_id": "paused-lite-turn", + "session_id": "paused-lite-session", + "source_id": "one-memory-manual", + "messages": [ + {"role": "user", "content": "保留原始会话"}, + {"role": "assistant", "content": "已保留"}, + ], + }, + ) + assert written.status_code == 202 + queue = app.state.memory_write_queue + drain = DurableWriteDrain(queue, execute=never, lease=timedelta(seconds=30)) + [job_id] = drain.claim(limit=1) + await execute_job( + job_id, + queue=queue, + supervisor=coordinator, + evidence=None, # type: ignore[arg-type] + admit_hy_invocation=lambda source_id, requires_generation, mark: ( + _admit_pipeline_invocation( + app, + source_id, + requires_generation, + mark, + ) + ), + ) + current = queue.get("paused-lite-turn") + assert current is not None and current.state == "completed" + assert current.sdk_raw_ids == ("raw-lite",) + assert seen == ["add"] + await coordinator.close() + + run_app(tmp_path, monkeypatch, scenario) diff --git a/memhub/apps/core/tests/test_memory_server_durable_queue.py b/memhub/apps/core/tests/test_memory_server_durable_queue.py index a0947ea..e065782 100644 --- a/memhub/apps/core/tests/test_memory_server_durable_queue.py +++ b/memhub/apps/core/tests/test_memory_server_durable_queue.py @@ -1045,7 +1045,7 @@ def test_sdk_write_processor_calls_add_once_with_public_scope_and_receipt() -> N ) async def run() -> SdkWriteReceipt: - async def before() -> None: + async def before(_requires_generation: bool) -> None: client.events.append("before") return await processor.process_sdk_job( @@ -1105,7 +1105,7 @@ def test_sdk_write_processor_uses_add_extracted_for_memory_content_and_additions processor = SdkWriteProcessor(_SdkPublicHandle(client)) # type: ignore[arg-type] async def run() -> SdkWriteReceipt: - async def before() -> None: + async def before(_requires_generation: bool) -> None: client.events.append("before") return await processor.process_sdk_job( @@ -1260,7 +1260,7 @@ def test_a_profile_change_does_not_close_the_client_of_a_write_in_flight(monkeyp async def process(self, records, *, request_id, before_hy_invocation): client = await self._client_handle.client() - await before_hy_invocation() + await before_hy_invocation(getattr(client, "mode", None) != "lite") seen.append((client.profile_id, client.closed)) if client.profile_id == "profile-a": await writing.wait() @@ -1270,7 +1270,7 @@ def test_a_profile_change_does_not_close_the_client_of_a_write_in_flight(monkeyp monkeypatch.setattr(sdk_write_processor.SdkWriteProcessor, "process_sdk_job", process) - async def noop(): + async def noop(_requires_generation: bool): return None async def scenario(): @@ -4758,7 +4758,7 @@ def test_sedimentation_pause_releases_claim_without_spending_an_attempt(tmp_path drain = DurableWriteDrain(queue, execute=_never_execute, owner="pause-owner") assert drain.claim(limit=1) == (record.job_id,) - async def refuse_before_hy(_source_id, _mark_invoked): + async def refuse_before_hy(_source_id, _requires_generation, _mark_invoked): raise RuntimeError("model_sedimentation_disabled") asyncio.run( diff --git a/memhub/apps/core/tests/test_memory_server_unified_entry.py b/memhub/apps/core/tests/test_memory_server_unified_entry.py index f9be9f4..408a505 100644 --- a/memhub/apps/core/tests/test_memory_server_unified_entry.py +++ b/memhub/apps/core/tests/test_memory_server_unified_entry.py @@ -259,7 +259,7 @@ def test_old_branch_job_executes_its_frozen_revision_after_harness_rewind(tmp_pa ): assert request_id == old_job.request_id captured.extend(records) - await before_hy_invocation() + await before_hy_invocation(True) return SimpleNamespace(raw_ids=(), memory_ids=()) asyncio.run( diff --git a/memhub/apps/core/tests/test_model_backoff.py b/memhub/apps/core/tests/test_model_backoff.py index 019459c..cec66ba 100644 --- a/memhub/apps/core/tests/test_model_backoff.py +++ b/memhub/apps/core/tests/test_model_backoff.py @@ -184,6 +184,17 @@ def test_a_working_embedding_endpoint_does_not_clear_a_broken_chat_model() -> No assert backoff.paused() +def test_claim_allowance_can_pace_embedding_without_pausing_lite_for_chat() -> None: + backoff = ModelBackoff(clock=_Clock()) + backoff.record_failure("model_not_found", "p:1", "chat") + assert backoff.allowance(kind="embedding") is None + assert backoff.allowance(kind="chat") == 0 + + backoff.reset() + backoff.record_failure("embedding_service_failed", "p:1", "embedding") + assert backoff.allowance(kind="embedding") == 0 + + def test_an_unreachable_provider_is_paused_like_a_refusing_one() -> None: clock = _Clock() backoff = ModelBackoff(clock=clock) @@ -310,7 +321,7 @@ class _FakeSdk: self.fail_with: str | None = None async def process_sdk_job(self, records, *, request_id, before_hy_invocation): - await before_hy_invocation() + await before_hy_invocation(True) self.request_ids.append(request_id) if request_id in self.outcomes: if self.outcomes[request_id] == "failed": diff --git a/memhub/apps/core/tests/test_sdk_data_plane_migration.py b/memhub/apps/core/tests/test_sdk_data_plane_migration.py index 9b4c411..e174cad 100644 --- a/memhub/apps/core/tests/test_sdk_data_plane_migration.py +++ b/memhub/apps/core/tests/test_sdk_data_plane_migration.py @@ -1613,7 +1613,7 @@ def test_a_completed_sdk_write_does_not_touch_retired_evidence(store) -> None: class Supervisor: async def process_sdk_job(self, records, *, request_id, before_hy_invocation): del records, request_id - await before_hy_invocation() + await before_hy_invocation(True) return SimpleNamespace(raw_ids=("raw-1",), memory_ids=("m-1",)) asyncio.run( diff --git a/memhub/apps/web/src/ApplicationsHome.test.tsx b/memhub/apps/web/src/ApplicationsHome.test.tsx index 2f59c2e..0405ed3 100644 --- a/memhub/apps/web/src/ApplicationsHome.test.tsx +++ b/memhub/apps/web/src/ApplicationsHome.test.tsx @@ -944,6 +944,32 @@ it("shows a paused switch immediately even when task rows still say running", as expect(screen.queryByText("新的对话正在整理")).toBeNull(); }); +it("shows lite raw ingestion as running even when model sedimentation is paused", async () => { + processing.mockResolvedValue({ + running: 0, + pending: 10, + pipeline: { mode: "lite" }, + }); + sedimentation.mockResolvedValue({ + enabled: false, + pending_retained: true, + imports_unaffected: true, + }); + + render( + {}} + onMemories={() => {}} + onSettings={() => {}} + />, + ); + + expect(await screen.findAllByText("整理中")).toHaveLength(2); + expect(screen.getByText("新的对话正在整理")).toBeTruthy(); + expect(screen.queryByText("已暂停")).toBeNull(); +}); + it("keeps the import-in-progress empty state stable without a progress link", async () => { summary.mockResolvedValue({ memory_count: 0, diff --git a/memhub/apps/web/src/ApplicationsHome.tsx b/memhub/apps/web/src/ApplicationsHome.tsx index acf6a93..678443e 100644 --- a/memhub/apps/web/src/ApplicationsHome.tsx +++ b/memhub/apps/web/src/ApplicationsHome.tsx @@ -644,14 +644,17 @@ export function ApplicationsHome({ const apps = imports.sources.filter( (s) => s.authorized || s.detected === true || s.location_state === "found", ); - const stateLabel = - sedimentation?.enabled === true + const rawOnly = processing?.pipeline?.mode === "lite"; + const stateLabel = rawOnly + ? t("apps.running") + : sedimentation?.enabled === true ? t("apps.running") : sedimentation?.enabled === false ? t("apps.paused") : t("apps.checkingStatus"); - const stateDetail = - sedimentation?.enabled === false + const stateDetail = rawOnly + ? t("apps.organizingNew") + : sedimentation?.enabled === false ? t("apps.stoppedNew") : sedimentation?.enabled === true ? t("apps.organizingNew") diff --git a/memhub/apps/web/src/MemoryMap.test.tsx b/memhub/apps/web/src/MemoryMap.test.tsx index 8fd2449..a66d1fa 100644 --- a/memhub/apps/web/src/MemoryMap.test.tsx +++ b/memhub/apps/web/src/MemoryMap.test.tsx @@ -149,6 +149,77 @@ it("OBS-22: the graph inspector has no editing entry and never says changes cann expect(screen.queryByText(/暂不能保存更改/)).toBeNull(); }); +it("replaces a topic detail with an off-page graph memory detail", async () => { + const onOpen = vi.fn(); + const topicGraph = { + total_memories: 2, + projected_memories: 2, + hy_graph_available: true, + nodes: [ + { + node_id: "memory:m0", + node_type: "memory", + memory_id: "m0", + label: "第一条", + }, + { + node_id: "memory:outside", + node_type: "memory", + memory_id: "outside", + label: "图中但不在当前页的记忆", + }, + { + node_id: "topic:git", + node_type: "topic", + memory_id: null, + label: "git", + }, + ], + edges: [ + { + edge_id: "topic-m0", + source_node_id: "topic:git", + target_node_id: "memory:m0", + relation: "tagged_with", + origin: "shared_tag", + }, + { + edge_id: "topic-outside", + source_node_id: "topic:git", + target_node_id: "memory:outside", + relation: "tagged_with", + origin: "shared_tag", + }, + ], + } as MemoryGraphResponse; + render( + , + ); + + await userEvent.click(screen.getByRole("button", { name: "图中选择 git" })); + expect(screen.getByText("对应记忆")).toBeTruthy(); + expect(screen.queryByText("两跳")).toBeNull(); + await userEvent.click( + screen.getByRole("button", { + name: "图中选择 图中但不在当前页的记忆", + }), + ); + + expect(onOpen).toHaveBeenLastCalledWith("outside"); +}); + it("OBS-19: Enter in the memory filter submits it; submitting it again re-reads", async () => { const onRetry = vi.fn(); const onQuery = vi.fn(); @@ -391,6 +462,7 @@ function Host({ deferMemoryId, failMemoryId, graphValue = graph, + onOpenSpy, onViewHistory, onConfigureModel = () => {}, libraryView, @@ -408,6 +480,7 @@ function Host({ deferMemoryId?: string; failMemoryId?: string; graphValue?: MemoryGraphResponse; + onOpenSpy?(memoryId: string): void; onViewHistory?(): void; onConfigureModel?(): void; libraryView?: Parameters[0]["libraryView"]; @@ -445,6 +518,7 @@ function Host({ streaming={null} requestIds={{ current: new Map() }} onOpenMemory={(id) => { + onOpenSpy?.(id); if (failMemoryId && id === failMemoryId) { setDetail(null); setDetailLoad({ memoryId: id, status: "failed" }); diff --git a/memhub/apps/web/src/MemoryMap.tsx b/memhub/apps/web/src/MemoryMap.tsx index 86418a2..9b83474 100644 --- a/memhub/apps/web/src/MemoryMap.tsx +++ b/memhub/apps/web/src/MemoryMap.tsx @@ -419,6 +419,15 @@ export function MemoryMap(props: Props) { )), ) ?? null) : null; + const projectedMemoryIds = useMemo( + () => + new Set( + projection.nodes.flatMap((node) => + node.memory_id ? [node.memory_id] : [], + ), + ), + [projection.nodes], + ); const selectedSource = visibleGroupNode?.node_type === "source" ? sources.find( @@ -436,9 +445,12 @@ export function MemoryMap(props: Props) { } }, [groupNode, visibleGroupNode]); const requestedId = selectedId ?? detail?.memory.memory_id ?? null; - const activeId = active.some((memory) => memory.memory_id === requestedId) - ? requestedId - : null; + const activeId = + requestedId && + (active.some((memory) => memory.memory_id === requestedId) || + projectedMemoryIds.has(requestedId)) + ? requestedId + : null; const inspectorIdentity = activeId ? `memory:${activeId}` : selectedSource @@ -448,7 +460,8 @@ export function MemoryMap(props: Props) { const visibleDetail = detail && (!activeId || activeId === detail.memory.memory_id) && - filtered.some((m) => m.memory_id === detail.memory.memory_id) + (filtered.some((m) => m.memory_id === detail.memory.memory_id) || + projectedMemoryIds.has(detail.memory.memory_id)) ? detail : null; const lastDetail = useRef(visibleDetail); @@ -466,7 +479,8 @@ export function MemoryMap(props: Props) { props.detailLoad.status === "failed"; const pick = useCallback( (memoryId: string, scroll = true) => { - if (!active.some((memory) => memory.memory_id === memoryId)) return; + const listed = active.some((memory) => memory.memory_id === memoryId); + if (!listed && !projectedMemoryIds.has(memoryId)) return; if (pendingPageOffset !== null && library?.failed && library.page) library.onPage(library.page.offset); setPendingSourceSelection(null); @@ -475,7 +489,7 @@ export function MemoryMap(props: Props) { pageRetrySawLoading.current = false; setDetailResetRevision((current) => current + 1); scrollSelection.current = scroll; - if (!filtered.some((memory) => memory.memory_id === memoryId)) { + if (listed && !filtered.some((memory) => memory.memory_id === memoryId)) { setGroupKey(""); setSearch(""); setFilterNotice("已清除筛选,定位到关联记忆。"); @@ -485,7 +499,14 @@ export function MemoryMap(props: Props) { setInspectorOpen(true); onOpenMemory(memoryId); }, - [active, filtered, library, onOpenMemory, pendingPageOffset], + [ + active, + filtered, + library, + onOpenMemory, + pendingPageOffset, + projectedMemoryIds, + ], ); useEffect(() => { if ( @@ -605,7 +626,8 @@ export function MemoryMap(props: Props) { return; if ( requestedId && - !active.some((memory) => memory.memory_id === requestedId) + !active.some((memory) => memory.memory_id === requestedId) && + !projectedMemoryIds.has(requestedId) ) { clear(); return; @@ -623,6 +645,7 @@ export function MemoryMap(props: Props) { overlaysSuspended, library?.loading, pendingPageOffset, + projectedMemoryIds, ]); function closeInspector() { if (!compact) { @@ -1359,7 +1382,13 @@ export function MemoryMap(props: Props) { : selectedSource ? text("记忆详情") : visibleGroupNode - ? text("关联节点") + ? text( + visibleGroupNode.node_type === "topic" + ? "主题" + : visibleGroupNode.node_type === "schema" + ? "关系归纳" + : "关联节点", + ) : text("记忆详情")} {(activeId || visibleGroupNode) && ( @@ -1499,6 +1528,7 @@ export function MemoryMap(props: Props) { onDraft={props.onDraft} onSave={props.onSave} onDelete={props.onDelete} + onOpenMemory={pick} requestIds={props.requestIds} onClose={closeInspector} /> diff --git a/memhub/apps/web/src/MemorySpace.r6.test.tsx b/memhub/apps/web/src/MemorySpace.r6.test.tsx index 447ef21..a76afb2 100644 --- a/memhub/apps/web/src/MemorySpace.r6.test.tsx +++ b/memhub/apps/web/src/MemorySpace.r6.test.tsx @@ -7,6 +7,7 @@ import { screen, waitFor, } from "@testing-library/react"; +import userEvent from "@testing-library/user-event"; import { afterEach, expect, it, vi } from "vitest"; import { LANGUAGE_STORAGE_KEY, LanguageProvider } from "./gui-language"; import { MemorySpace } from "./MemorySpace"; @@ -273,7 +274,95 @@ it("shows raw conversations instead of advanced memory in lite", async () => { signal: expect.any(AbortSignal), }), ); - expect(screen.getByText(/当前为 lite/)).toBeTruthy(); + expect(document.querySelector(".memory-mode-label")?.textContent).toBe( + "lite", + ); + expect(document.querySelector(".memory-mode-notice")).toBeNull(); +}); + +it("opens a search hit with the complete role transcript", async () => { + setup(); + reads.processing.mockResolvedValue({ + running: 0, + pending: 0, + blocked_stages: [], + completed: 1, + pipeline: { + mode: "lite", + configured_mode: "lite", + forced_by_env: false, + evolve: { + mode: "lite", + schedule: { enabled: true, time: "03:00" }, + users: {}, + running: [], + }, + last_evolve_at: null, + graph_build: { + state: "idle", + started_at: null, + finished_at: null, + users: {}, + }, + features: {}, + }, + }); + reads.raw.mockImplementation(async (options = {}) => + "sessionId" in options && options.sessionId + ? { + items: [ + { + raw_id: "raw-search", + content: "[user]: 完整问题\n[assistant]: 完整回答", + user_id: "local", + agent_id: "all-apps", + source: "cursor", + session_id: "session-search", + memory_at: null, + gmt_created: 1_761_000_000, + custom: {}, + messages: [ + { role: "user", content: "完整问题" }, + { role: "assistant", content: "完整回答" }, + ], + }, + ], + total: 1, + limit: 500, + offset: 0, + } + : { items: [], total: 0, limit: 20, offset: 0 }, + ); + reads.rawSearch.mockResolvedValue([ + { + kind: "raw", + raw_id: "raw-search", + content: "被截断的搜索摘要", + source: "cursor", + session_id: "session-search", + memory_at: null, + score: 0.9, + truncated: true, + }, + ]); + const user = userEvent.setup(); + render(); + + const search = await screen.findByRole("textbox", { + name: "搜索原始对话", + }); + await user.type(search, "完整"); + await user.click(screen.getByRole("button", { name: "搜索" })); + + expect(await screen.findByText("完整问题")).toBeTruthy(); + expect(screen.getByText("完整回答")).toBeTruthy(); + expect(reads.raw).toHaveBeenCalledWith( + expect.objectContaining({ + source: "cursor", + sessionId: "session-search", + limit: 500, + }), + ); }); it("does not request advanced memories before the pipeline mode is known", async () => { @@ -317,7 +406,11 @@ it("does not request advanced memories before the pipeline mode is known", async }, }); }); - await screen.findByText(/当前为 lite/); + await waitFor(() => + expect(document.querySelector(".memory-mode-label")?.textContent).toBe( + "lite", + ), + ); expect(reads.sdkLibrary).not.toHaveBeenCalled(); expect(reads.sdkGraph).not.toHaveBeenCalled(); }); diff --git a/memhub/apps/web/src/MemorySpace.tsx b/memhub/apps/web/src/MemorySpace.tsx index 67ee041..3a78ee2 100644 --- a/memhub/apps/web/src/MemorySpace.tsx +++ b/memhub/apps/web/src/MemorySpace.tsx @@ -682,7 +682,7 @@ export function MemorySpace({ setDetail(null); setSelectedRaw(null); if (lite) { - const record = rawRecords.current.get(memoryId); + let record = rawRecords.current.get(memoryId); if (!record) { setDetailLoad({ memoryId, @@ -691,6 +691,38 @@ export function MemorySpace({ }); return; } + if ("kind" in record) { + setDetailLoad({ memoryId, status: "loading" }); + try { + let offset = 0; + while (true) { + const page = await getSdkRaw({ + source: record.source || undefined, + sessionId: record.session_id || undefined, + offset, + limit: 500, + }); + const full = page.items.find((item) => item.raw_id === memoryId); + if (full) { + record = full; + rawRecords.current.set(memoryId, full); + break; + } + offset += page.items.length; + if (!page.items.length || offset >= page.total) + throw new Error("raw_not_found"); + } + } catch { + if (epoch === detailEpoch.current) + setDetailLoad({ + memoryId, + status: "failed", + error: t("sdk.raw.loadFailed"), + }); + return; + } + if (epoch !== detailEpoch.current) return; + } setSelectedRaw(record); setDetail(rawDetail(record)); setDetailLoad(null); @@ -831,9 +863,14 @@ export function MemorySpace({
-

- {simplified ? t("memory:记忆") : t("memory:记忆空间")} -

+
+

+ {simplified ? t("memory:记忆") : t("memory:记忆空间")} +

+ {simplified && pipelineMode ? ( + {pipelineMode} + ) : null} +
{simplified && !management ? pipelineMode === null @@ -952,19 +989,6 @@ export function MemorySpace({
)}
- {simplified && pipelineMode ? ( -

- {lite - ? t( - "memory:当前使用基础记忆。新对话会保存为原始记录,不会生成高级记忆;以前生成的高级记忆仍会保留,但当前不会显示或参与记忆召回。", - ) - : pipelineMode === "ultra" - ? t( - "memory:当前使用深度记忆。新对话会生成高级记忆,并由知识图谱和 evolve 继续整合。", - ) - : t("memory:当前使用智能记忆。新对话会由模型整理为高级记忆。")} -

- ) : null} {processing && (!simplified || managementOpen) ? (
diff --git a/memhub/apps/web/src/SelectedRawDetail.test.tsx b/memhub/apps/web/src/SelectedRawDetail.test.tsx new file mode 100644 index 0000000..bd38154 --- /dev/null +++ b/memhub/apps/web/src/SelectedRawDetail.test.tsx @@ -0,0 +1,43 @@ +// @vitest-environment jsdom + +import { cleanup, render, screen } from "@testing-library/react"; +import { afterEach, beforeEach, expect, it } from "vitest"; +import { LANGUAGE_STORAGE_KEY, LanguageProvider } from "./gui-language"; +import { SelectedRawDetail } from "./SelectedRawDetail"; + +beforeEach(() => localStorage.setItem(LANGUAGE_STORAGE_KEY, "zh-Hans")); +afterEach(() => { + cleanup(); + localStorage.clear(); +}); + +it("shows every role and preserves a long original message in the detail pane", () => { + const long = "原文不能截断。".repeat(500); + render( + + + , + ); + + expect(screen.getByText("你")).toBeTruthy(); + expect(screen.getByText("助手")).toBeTruthy(); + expect(screen.getByText(long)).toHaveProperty("textContent", long); + expect(screen.getByText("已收到")).toBeTruthy(); +}); diff --git a/memhub/apps/web/src/Workbench.tsx b/memhub/apps/web/src/Workbench.tsx index e93dc52..57755fb 100644 --- a/memhub/apps/web/src/Workbench.tsx +++ b/memhub/apps/web/src/Workbench.tsx @@ -26,6 +26,7 @@ export function NodeDetail({ onDraft, onSave, onDelete, + onOpenMemory, requestIds, onClose, compact = false, @@ -39,6 +40,7 @@ export function NodeDetail({ onDraft(next: string): void; onSave(event: FormEvent): void; onDelete(): void; + onOpenMemory?(memoryId: string): void; requestIds: RefObject>; onClose(): void; compact?: boolean; @@ -138,33 +140,67 @@ export function NodeDetail({ if (node) { const trace = graph ? traceFrom(graph, node.node_id, 2) : []; const direct = trace.filter((step) => step.hop === 1); + const relatedMemories = direct.filter( + (step) => step.node.memory_id !== null, + ); return (
- {nodeTypeLabel(node.node_type, t)} + + {compact && node.node_type === "schema" + ? t("memory:关系归纳") + : nodeTypeLabel(node.node_type, t)} + {node.label}
-

- {t("wb.nodeNote", { - direct: new Intl.NumberFormat(locale).format(direct.length), - indirect: new Intl.NumberFormat(locale).format( - trace.length - direct.length, - ), - })} -

-
    - {trace.slice(0, 8).map((step) => ( -
  • - - {step.hop === 1 ? t("wb.direct") : t("wb.twoHops")} - - {relationLabel(step.edge.relation, t)} · {step.node.label} - - {edgeOriginLabel(step.edge.origin, t)} - -
  • - ))} -
+ {compact ? ( + relatedMemories.length ? ( +
+

{t("selectedMemory.related")}

+
    + {relatedMemories.slice(0, 8).map((step) => ( +
  • + +
  • + ))} +
+
+ ) : null + ) : ( + <> +

+ {t("wb.nodeNote", { + direct: new Intl.NumberFormat(locale).format(direct.length), + indirect: new Intl.NumberFormat(locale).format( + trace.length - direct.length, + ), + })} +

+
    + {trace.slice(0, 8).map((step) => ( +
  • + + {step.hop === 1 ? t("wb.direct") : t("wb.twoHops")} + + {relationLabel(step.edge.relation, t)} · {step.node.label} + + {edgeOriginLabel(step.edge.origin, t)} + +
  • + ))} +
+ + )}
); } diff --git a/memhub/apps/web/src/api.raw.test.ts b/memhub/apps/web/src/api.raw.test.ts new file mode 100644 index 0000000..f6d9257 --- /dev/null +++ b/memhub/apps/web/src/api.raw.test.ts @@ -0,0 +1,57 @@ +import { beforeEach, expect, it, vi } from "vitest"; + +const { get, post } = vi.hoisted(() => ({ + get: vi.fn(), + post: vi.fn(), +})); +vi.mock("axios", () => ({ + default: { + create: () => ({ + get, + post, + interceptors: { request: { use: vi.fn() } }, + }), + }, +})); + +import { getSdkRaw, searchSdkRaw } from "./api"; + +beforeEach(() => vi.clearAllMocks()); + +it("lists raw conversations for the selected application", async () => { + get.mockResolvedValue({ + data: { items: [], total: 0, offset: 0, limit: 20 }, + }); + const signal = new AbortController().signal; + + await getSdkRaw({ source: "cursor", offset: 20, limit: 20, signal }); + + expect(get).toHaveBeenCalledWith("/api/v2/raw", { + params: { + source: "cursor", + session_id: undefined, + offset: 20, + limit: 20, + }, + signal, + }); +}); + +it("keeps the application filter when searching raw conversations", async () => { + post.mockResolvedValue({ data: { items: [] } }); + const signal = new AbortController().signal; + + await searchSdkRaw("release plan", { source: "claude-code", signal }); + + expect(post).toHaveBeenCalledWith( + "/api/v2/context/search", + { + query: "release plan", + kinds: ["raw"], + include_sources: ["claude-code"], + limit: 20, + character_budget: 12_000, + }, + { signal, timeout: 10_000 }, + ); +}); diff --git a/memhub/apps/web/src/api.ts b/memhub/apps/web/src/api.ts index b40ee17..fcce3b2 100644 --- a/memhub/apps/web/src/api.ts +++ b/memhub/apps/web/src/api.ts @@ -793,17 +793,19 @@ function isMemoryNotFound(reason: unknown): boolean { export async function getSdkRaw({ source, + sessionId, offset = 0, limit = 100, signal, }: { source?: string; + sessionId?: string; offset?: number; limit?: number; signal?: AbortSignal; } = {}): Promise { const response = await client.get("/api/v2/raw", { - params: { source, offset, limit }, + params: { source, session_id: sessionId, offset, limit }, signal, }); return response.data; diff --git a/memhub/apps/web/src/r6-memory.css b/memhub/apps/web/src/r6-memory.css index d4399ef..b1d24c9 100644 --- a/memhub/apps/web/src/r6-memory.css +++ b/memhub/apps/web/src/r6-memory.css @@ -18,20 +18,21 @@ font-size: 26px; font-weight: 600; } +.r6-memory .memory-title-line { + display: flex; + align-items: baseline; + gap: 10px; +} +.r6-memory .memory-mode-label { + color: #287b67; + font-size: 12px; + font-weight: 500; + line-height: 21px; +} .r6-memory .memory-loaded-count { font-size: 13px; color: #637786; } -.r6-memory .memory-mode-notice { - margin: -8px 0 18px; - padding: 12px 14px; - border: 1px solid #d5dfe8; - border-radius: 8px; - background: #f1f5f8; - color: #405665; - font-size: 13px; - line-height: 22px; -} .r6-memory .memory-pending-add { display: flex; align-items: center; @@ -184,6 +185,39 @@ gap: 12px; flex-wrap: wrap; } +.r6-memory .wb-node-related { + margin-top: 28px; +} +.r6-memory .wb-node-related h3 { + margin: 0 0 10px; + color: #5e6e7a; + font-size: 12px; + font-weight: 500; + line-height: 21px; +} +.r6-memory .wb-node-related ul { + margin: 0; + padding: 0; + list-style: none; +} +.r6-memory .wb-node-related li { + border-top: 1px solid #d8e2ea; +} +.r6-memory .wb-node-related .map-related { + padding: 12px 0; + color: #384a57; +} +.r6-memory .wb-node-related .map-related span { + display: -webkit-box; + overflow: hidden; + -webkit-box-orient: vertical; + -webkit-line-clamp: 3; + line-height: 1.7; +} +.r6-memory .wb-node-related .map-related small { + flex: none; + color: #637786; +} .r6-memory .map-inspector .wb-detail-actions button { min-height: 36px; border-radius: 8px; diff --git a/memhub/docs/ARCHITECTURE.md b/memhub/docs/ARCHITECTURE.md index 1ccb684..b26da63 100644 --- a/memhub/docs/ARCHITECTURE.md +++ b/memhub/docs/ARCHITECTURE.md @@ -102,7 +102,8 @@ Periodic polling / fs watch live in Core. UI does not schedule scans. ```text authorized harness / user action -> (optional) durable job row in MemHub queue // 调度与租约,不是 memory 实体 - -> sedimentation / model / source gates // 管理面门禁 + -> mode / source / embedding gates // lite raw 不受生成模型开关影响 + -> sedimentation / generation-model gate // 仅 pro / ultra -> public HyMemoryClient write (raw / chat / coding / …) -> SDK stores (vector, graph, cache, history, coding, …) via SDK config/env -> MemHub may persist memory_id only for explicit management receipts / UI keys diff --git a/memhub/docs/contracts/memory-event-time-scheduling-v1.md b/memhub/docs/contracts/memory-event-time-scheduling-v1.md index a923613..e73afd4 100644 --- a/memhub/docs/contracts/memory-event-time-scheduling-v1.md +++ b/memhub/docs/contracts/memory-event-time-scheduling-v1.md @@ -86,7 +86,9 @@ batch are not held. ## Pauses and failures -- Global sedimentation off: imports and enqueue continue; no job is claimed. +- Global sedimentation off: imports and enqueue continue. In pro/ultra no job + is claimed because it would call the generation model; in lite the same jobs + continue through raw-only ingestion and make no generation-model call. - Harness disabled/revoked: its jobs are parked/excluded from the active timeline and do not block other Harnesses. - Re-enable: old jobs re-enter by their original `event_at`. diff --git a/memhub/docs/contracts/memory-mode-evolve-v1.md b/memhub/docs/contracts/memory-mode-evolve-v1.md index c393592..3d5adae 100644 --- a/memhub/docs/contracts/memory-mode-evolve-v1.md +++ b/memhub/docs/contracts/memory-mode-evolve-v1.md @@ -356,6 +356,9 @@ The App's product rules are: - A new installation explicitly chooses lite or pro. Choosing lite persists the mode and completes onboarding without a generation model. Choosing pro opens model setup and completes onboarding only after activation succeeds. +- Lite always ingests authorized imported and live conversations into L1 raw, + even when model sedimentation is paused. That pause controls generation-model + work in pro/ultra; it never turns raw conversation storage off. - Existing installations keep the default pro behaviour; an absent `memory_mode` is never inferred from model availability. - Ultra is enabled only from Settings. Its nightly schedule is on by default