Compare commits

...
Author SHA1 Message Date
kristinheandCursor 4e7d752980 feat(memhub): add lite manual raw input
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-11-04 00:05:36 +08:00
kristinheandCursor fb13a6fda2 fix(memhub): keep mode-specific memory views consistent
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-11-03 23:20:41 +08:00
kristinheandCursor 6ff8b3a3b4 fix(memhub): simplify topic node details
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-11-03 22:49:47 +08:00
kristinheandCursor 5271bddce3 fix(memhub): preserve raw conversations in lite mode
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-11-03 22:18:12 +08:00
kristinhe1111 d938f1d5b2 Merge pull request #294 from Tencent-Hunyuan/chore/memhub-build-39
chore(release): MemHub 1.0.6, build 39
2026-11-03 20:05:08 +08:00
kristinheandCursor 8cee64c813 chore(release): bump MemHub build number to 39
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-11-03 19:59:27 +08:00
kristinhe1111 1ffcc9eb57 Merge pull request #292 from Tencent-Hunyuan/feat/memhub-mode-ui
feat(memhub): complete memory mode app lifecycle
2026-11-03 19:43:30 +08:00
33 changed files with 1256 additions and 191 deletions

No files matched your search

+1 -1
View File
@@ -83,7 +83,7 @@ Hy Memory and MemHub are versioned and released independently.
## MemHub 1.0.6 — 2026-11-02
Build 38, on `hy-memory==2.0.13`. Linux x64/arm64 Runtime and macOS arm64
Build 39, on `hy-memory==2.0.13`. Linux x64/arm64 Runtime and macOS arm64
App and Runtime.
- Interfaces in 11 languages: English, Simplified and Traditional Chinese,
+1 -1
View File
@@ -19,7 +19,7 @@ If you run into a problem, please open an Issue with the version, platform,repro
## 发行与平台合同
源码当前固定使用公开 PyPI 正式版 `hy-memory==2.0.13`。MemHub 源码版本为 `1.0.6`
(build 38),GitHub 仓库为
(build 39),GitHub 仓库为
[`Tencent-Hunyuan/Hy-Memory`](https://github.com/Tencent-Hunyuan/Hy-Memory),npm 包名为
`memhub-cli`。当前代码架构见
[`docs/ARCHITECTURE.md`](docs/ARCHITECTURE.md)。
+60 -16
View File
@@ -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)
@@ -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")
@@ -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
@@ -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
@@ -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
+1 -1
View File
@@ -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)
@@ -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)
@@ -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(
@@ -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(
+12 -1
View File
@@ -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":
@@ -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(
@@ -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(
<ApplicationsHome
imports={imports}
onManage={() => {}}
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,
+7 -4
View File
@@ -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")
+111 -1
View File
@@ -46,6 +46,7 @@ const api = vi.hoisted(() => ({
vi.mock("./api", () => api);
const REQUEST_ID = "11111111-2222-4333-8444-555555555555";
const OLD_REQUEST_ID = "aaaaaaaa-bbbb-4ccc-8ddd-eeeeeeeeeeee";
const addition: MemoryAddReceipt = {
evidence_id: "evidence-1",
text: "项目周会不排在周五。",
@@ -122,13 +123,14 @@ it("explains source verification failure without retrying or claiming success",
expect(screen.queryByRole("button", { name: "重新整理" })).toBeNull();
});
function page() {
function page(rawOnly = false) {
const onSaved = vi.fn(),
onOpenMemory = vi.fn(),
onConfigureModel = vi.fn(),
onPendingChange = vi.fn();
const view = render(
<MemoryAddComposer
rawOnly={rawOnly}
{...{ onSaved, onOpenMemory, onConfigureModel, onPendingChange }}
/>,
);
@@ -198,6 +200,114 @@ it("saves from an empty library with the linked-app scope by default", async ()
expect(screen.queryByRole("button", { name: "查看记忆" })).toBeNull();
});
it("saves a lite manual addition as original content without model UI", async () => {
api.addMemory.mockResolvedValue({
...addition,
state: "no_memories",
});
const callbacks = page(true);
await userEvent.click(screen.getByRole("button", { name: "手动添加" }));
expect(screen.getByRole("dialog", { name: "手动添加" })).toBeTruthy();
expect(screen.queryByText(/整理模型:/)).toBeNull();
expect(api.getModelProfiles).not.toHaveBeenCalled();
await userEvent.type(
screen.getByRole("textbox", { name: "记下这件事" }),
addition.text,
);
await userEvent.click(screen.getByRole("button", { name: "保存" }));
expect(await screen.findByText("已保存原文")).toBeTruthy();
expect(callbacks.onSaved).toHaveBeenCalledOnce();
expect(
callbacks.onPendingChange.mock.calls.some(([value]) => value === null),
).toBe(true);
expect(screen.queryByRole("dialog", { name: "手动添加" })).toBeNull();
});
it("refreshes the lite raw page when an asynchronous manual save completes", async () => {
api.addMemory.mockResolvedValue({
...addition,
state: "running",
});
api.getMemoryAddStatus.mockResolvedValue({
...addition,
state: "no_memories",
});
const callbacks = page(true);
await userEvent.click(screen.getByRole("button", { name: "手动添加" }));
await userEvent.type(screen.getByRole("textbox"), addition.text);
await userEvent.click(screen.getByRole("button", { name: "保存" }));
await waitFor(() => expect(callbacks.onSaved).toHaveBeenCalledTimes(2), {
timeout: 2_500,
});
expect(screen.getByText("已保存原文")).toBeTruthy();
expect(callbacks.onPendingChange).toHaveBeenLastCalledWith(null);
});
it("does not treat an older completed addition as a newly saved raw turn", async () => {
localStorage.setItem(
RECENT_ADDITIONS_KEY,
JSON.stringify([REQUEST_ID, OLD_REQUEST_ID]),
);
let currentState: MemoryAddReceipt["state"] = "running";
const calls = new Map<string, number>();
api.getMemoryAddStatus.mockImplementation(async (requestId: string) => {
calls.set(requestId, (calls.get(requestId) ?? 0) + 1);
return requestId === OLD_REQUEST_ID
? {
...addition,
evidence_id: "old-evidence",
state: "no_memories",
}
: { ...addition, state: currentState };
});
const callbacks = page(true);
await waitFor(
() => expect(calls.get(REQUEST_ID) ?? 0).toBeGreaterThanOrEqual(2),
{ timeout: 2_500 },
);
expect(callbacks.onSaved).not.toHaveBeenCalled();
currentState = "no_memories";
await waitFor(() => expect(callbacks.onSaved).toHaveBeenCalledOnce(), {
timeout: 2_500,
});
});
it("keeps a failed lite raw write visible after the composer closes", async () => {
api.addMemory.mockResolvedValue({
...addition,
state: "running",
});
api.getMemoryAddStatus.mockResolvedValue({
...addition,
state: "failed",
});
const callbacks = page(true);
await userEvent.click(screen.getByRole("button", { name: "手动添加" }));
await userEvent.type(screen.getByRole("textbox"), addition.text);
await userEvent.click(screen.getByRole("button", { name: "保存" }));
expect(screen.queryByText("已保存原文")).toBeNull();
await waitFor(
() =>
expect(callbacks.onPendingChange).toHaveBeenLastCalledWith({
evidenceId: addition.evidence_id,
text: addition.text,
state: "failed",
}),
{ timeout: 2_500 },
);
await waitFor(() =>
expect(screen.getByText("原话已保存,整理未完成")).toBeTruthy(),
);
});
it("preserves draft and request identity after an uncertain save", async () => {
api.addMemory
.mockRejectedValueOnce(new Error("timeout"))
+118 -30
View File
@@ -103,12 +103,14 @@ export function MemoryAddComposer({
onConfigureModel,
onPendingChange,
suspended = false,
rawOnly = false,
}: {
onSaved(): void;
onOpenMemory(id: string): void;
onConfigureModel(): void;
onPendingChange?(addition: PendingMemoryAdd | null): void;
suspended?: boolean;
rawOnly?: boolean;
}) {
const { text: copy } = useMemoryLanguage();
const [open, setOpen] = useState(false);
@@ -128,6 +130,7 @@ export function MemoryAddComposer({
const additionsRequest = useRef(0);
const intent = useRef<{ text: string; requestId: string } | null>(null);
const completed = useRef(new Set<string>());
const watched = useRef(new Set<string>());
const lastSaved = useRef<string | null>(null);
const onSavedRef = useRef(onSaved);
onSavedRef.current = onSaved;
@@ -156,34 +159,72 @@ export function MemoryAddComposer({
}, []);
const refreshAdditions = useCallback(
async (failure: string, notifyResults = false) => {
async (
failure: string,
notifyResults = false,
allowWhileClosed = false,
) => {
const request = ++additionsRequest.current;
try {
const value = await loadRecentAdditions();
if (!isOpen.current || request !== additionsRequest.current) return;
if (
(!allowWhileClosed && !isOpen.current) ||
request !== additionsRequest.current
)
return;
setAdditions(value);
reportPending(value);
if (!notifyResults) return;
if (!notifyResults) {
for (const { receipt } of value) {
if (
receipt.state === "ready" ||
(rawOnly && receipt.state === "no_memories")
)
completed.current.add(receipt.evidence_id);
else if (receipt.state === "pending" || receipt.state === "running")
watched.current.add(receipt.evidence_id);
}
return;
}
const latest = value.find(
(item) => item.receipt.evidence_id === lastSaved.current,
);
if (latest) setNotice(states[latest.receipt.state]);
if (latest) {
setNotice(
rawOnly && latest.receipt.state === "no_memories"
? "已保存原文"
: states[latest.receipt.state],
);
if (rawOnly && latest.receipt.state === "failed")
setSavedToast(states.failed);
}
const newResults = value.filter(
({ receipt }) =>
receipt.state === "ready" &&
(receipt.state === "ready" ||
(rawOnly && receipt.state === "no_memories")) &&
watched.current.has(receipt.evidence_id) &&
!completed.current.has(receipt.evidence_id),
);
if (newResults.length) {
for (const { receipt } of newResults)
for (const { receipt } of newResults) {
completed.current.add(receipt.evidence_id);
watched.current.delete(receipt.evidence_id);
}
if (rawOnly) setSavedToast("已保存原文");
onSavedRef.current();
}
for (const { receipt } of value)
if (receipt.state === "failed")
watched.current.delete(receipt.evidence_id);
} catch {
if (isOpen.current && request === additionsRequest.current)
if (
(isOpen.current || allowWhileClosed) &&
request === additionsRequest.current
)
setError(failure);
}
},
[reportPending],
[rawOnly, reportPending],
);
useEffect(() => {
@@ -193,6 +234,15 @@ export function MemoryAddComposer({
const before = document.activeElement;
panel.current?.querySelector("textarea")?.focus();
void refreshAdditions("最近添加的内容读取失败。");
if (rawOnly) {
setModel(null);
setModelRead(true);
return () => {
isOpen.current = false;
additionsRequest.current += 1;
if (before instanceof HTMLElement && before.isConnected) before.focus();
};
}
setModelRead(false);
getModelProfiles()
.then((value) => {
@@ -211,32 +261,38 @@ export function MemoryAddComposer({
additionsRequest.current += 1;
if (before instanceof HTMLElement && before.isConnected) before.focus();
};
}, [open, refreshAdditions, suspended]);
}, [open, rawOnly, refreshAdditions, suspended]);
useEffect(() => {
if (open || suspended || !onPendingChange) return;
void loadRecentAdditions()
.then((value) => reportPending(value))
.catch(() => {
// The page already keeps the last known state when a background read
// fails; do not turn an unknown pending count into zero here.
});
}, [open, onPendingChange, reportPending, suspended]);
void refreshAdditions(
"最近添加的内容读取失败。",
lastSaved.current !== null,
true,
);
}, [open, onPendingChange, refreshAdditions, suspended]);
const working = additions.some(
({ receipt }) => receipt.state === "pending" || receipt.state === "running",
);
useEffect(() => {
if (!open || suspended || !working) return;
if (suspended || !working) return;
const stop = subscribePoll({
// A key of its own: sharing one with another surface would make one
// tick read the wrong endpoint for the other caller.
key: "memory-add-processing",
intervalMs: 1_500,
run: () => refreshAdditions("整理状态暂时不可用,原话已保存。", true),
run: () =>
refreshAdditions(
rawOnly
? "保存状态暂时不可用,原文已保存。"
: "整理状态暂时不可用,原话已保存。",
true,
!open,
),
});
return stop;
}, [open, working, refreshAdditions, suspended]);
}, [open, rawOnly, working, refreshAdditions, suspended]);
useEffect(() => {
if (!savedToast) return;
@@ -273,12 +329,27 @@ export function MemoryAddComposer({
setNotice("");
const trace = traceUiAction("memory", "submit");
const completeSave = (receipt: MemoryAddReceipt) => {
if (
receipt.state === "ready" ||
(rawOnly && receipt.state === "no_memories")
)
completed.current.add(receipt.evidence_id);
else if (receipt.state === "pending" || receipt.state === "running")
watched.current.add(receipt.evidence_id);
remember({ requestId, receipt });
lastSaved.current = receipt.evidence_id;
setText("");
intent.current = null;
setNotice("");
setSavedToast("已保存,正在后台沉淀记忆");
setSavedToast(
rawOnly
? receipt.state === "ready" || receipt.state === "no_memories"
? "已保存原文"
: receipt.state === "failed"
? states.failed
: "正在保存…"
: "已保存,正在后台沉淀记忆",
);
isOpen.current = false;
additionsRequest.current += 1;
setOpen(false);
@@ -321,7 +392,8 @@ export function MemoryAddComposer({
data-tour-id="memory-add-trigger"
onClick={() => setOpen(true)}
>
<FilePlus2 size={16} aria-hidden="true" /> {copy("添加记忆")}
<FilePlus2 size={16} aria-hidden="true" />{" "}
{copy(rawOnly ? "手动添加" : "添加记忆")}
</button>
{savedToast && (
<p className="memory-add-toast" role="status">
@@ -360,11 +432,13 @@ export function MemoryAddComposer({
}}
>
<header className="memory-add-heading">
<h2 id="memory-add-title">{copy("添加记忆")}</h2>
<h2 id="memory-add-title">
{copy(rawOnly ? "手动添加" : "添加记忆")}
</h2>
<button
className="quiet"
type="button"
aria-label={copy("关闭新增记忆")}
aria-label={copy(rawOnly ? "关闭" : "关闭新增记忆")}
title={copy("关闭")}
disabled={busy}
onClick={close}
@@ -390,11 +464,13 @@ export function MemoryAddComposer({
placeholder={copy("例如:项目周会不排在周五。")}
onChange={(event) => setText(event.target.value)}
/>
<p className="memory-add-model">
{copy("整理模型:")}
{model?.name ??
copy(modelRead ? "尚未配置,原话仍可保存" : "正在读取")}
</p>
{!rawOnly && (
<p className="memory-add-model">
{copy("整理模型:")}
{model?.name ??
copy(modelRead ? "尚未配置,原话仍可保存" : "正在读取")}
</p>
)}
{error && (
<p className="error-banner" role="alert">
{copy(error)}
@@ -402,7 +478,7 @@ export function MemoryAddComposer({
)}
{notice && <p role="status">{copy(notice)}</p>}
<div className="consent-actions">
{modelRead && !model && (
{!rawOnly && modelRead && !model && (
<button
type="button"
className="secondary"
@@ -430,7 +506,19 @@ export function MemoryAddComposer({
{additions.map(({ requestId, receipt }) => (
<li key={requestId}>
<p>{receipt.text}</p>
<small>{copy(states[receipt.state])}</small>
<small>
{copy(
rawOnly &&
(receipt.state === "pending" ||
receipt.state === "running")
? "正在保存…"
: rawOnly &&
(receipt.state === "ready" ||
receipt.state === "no_memories")
? "已保存原文"
: states[receipt.state],
)}
</small>
{memoryWriteErrorText(receipt.error) && (
<p className="error-banner">
{copy(memoryWriteErrorText(receipt.error) ?? "")}
+99
View File
@@ -149,6 +149,102 @@ 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",
},
{
node_id: "schema:workflow",
node_type: "schema",
memory_id: null,
label: "先验证,再推送",
},
],
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",
},
{
edge_id: "schema-m0",
source_node_id: "schema:workflow",
target_node_id: "memory:m0",
relation: "derived_from",
origin: "hy_relation",
},
],
} as MemoryGraphResponse;
render(
<Host
simplified
items={[memories[0]]}
graphValue={topicGraph}
onOpenSpy={onOpen}
libraryView={pagedView({
items: [memories[0]],
total: 2,
offset: 0,
limit: 1,
source_ids_by_memory: { m0: ["codex"] },
})}
/>,
);
await userEvent.click(screen.getByRole("button", { name: "图中选择 git" }));
expect(screen.getByRole("heading", { name: "git" })).toBeTruthy();
expect(screen.getAllByText("第一条").length).toBeGreaterThan(0);
expect(screen.queryByText("对应记忆")).toBeNull();
expect(screen.queryByText("属于主题")).toBeNull();
expect(screen.queryByText("主题")).toBeNull();
expect(screen.queryByText("两跳")).toBeNull();
await userEvent.click(
screen.getByRole("button", { name: "图中选择 先验证,再推送" }),
);
expect(screen.getByRole("heading", { name: "先验证,再推送" })).toBeTruthy();
expect(screen.queryByText("关系归纳")).toBeNull();
expect(screen.queryByText("归纳自")).toBeNull();
await userEvent.click(
screen.getByRole("button", {
name: "图中选择 图中但不在当前页的记忆",
}),
);
expect(onOpen).toHaveBeenLastCalledWith("outside");
expect(screen.getByTestId("canvas").dataset.selected).toBe("memory:outside");
expect(screen.queryByText("所选记忆不在当前筛选内,已取消选择。")).toBeNull();
});
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 +487,7 @@ function Host({
deferMemoryId,
failMemoryId,
graphValue = graph,
onOpenSpy,
onViewHistory,
onConfigureModel = () => {},
libraryView,
@@ -408,6 +505,7 @@ function Host({
deferMemoryId?: string;
failMemoryId?: string;
graphValue?: MemoryGraphResponse;
onOpenSpy?(memoryId: string): void;
onViewHistory?(): void;
onConfigureModel?(): void;
libraryView?: Parameters<typeof MemoryMap>[0]["libraryView"];
@@ -445,6 +543,7 @@ function Host({
streaming={null}
requestIds={{ current: new Map() }}
onOpenMemory={(id) => {
onOpenSpy?.(id);
if (failMemoryId && id === failMemoryId) {
setDetail(null);
setDetailLoad({ memoryId: id, status: "failed" });
+53 -10
View File
@@ -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,12 +626,18 @@ 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;
}
if (!activeId || filtered.some((memory) => memory.memory_id === activeId))
const listed = active.some((memory) => memory.memory_id === activeId);
if (
!activeId ||
filtered.some((memory) => memory.memory_id === activeId) ||
(!listed && projectedMemoryIds.has(activeId))
)
return;
clear();
setFilterNotice("所选记忆不在当前筛选内,已取消选择。");
@@ -623,6 +650,7 @@ export function MemoryMap(props: Props) {
overlaysSuspended,
library?.loading,
pendingPageOffset,
projectedMemoryIds,
]);
function closeInspector() {
if (!compact) {
@@ -788,6 +816,17 @@ export function MemoryMap(props: Props) {
value={controlledSource}
options={[
{ value: "", label: text("全部应用") },
...(raw &&
!sources.some(
(source) => source.source_id === "one-memory-manual",
)
? [
{
value: "source:one-memory-manual",
label: text("手动添加"),
},
]
: []),
...(library?.source &&
library.source !== library.unattributedSource &&
!sources.some(
@@ -1359,7 +1398,10 @@ export function MemoryMap(props: Props) {
: selectedSource
? text("记忆详情")
: visibleGroupNode
? text("关联节点")
? visibleGroupNode.node_type === "topic" ||
visibleGroupNode.node_type === "schema"
? visibleGroupNode.label
: text("关联节点")
: text("记忆详情")}
</h2>
{(activeId || visibleGroupNode) && (
@@ -1499,6 +1541,7 @@ export function MemoryMap(props: Props) {
onDraft={props.onDraft}
onSave={props.onSave}
onDelete={props.onDelete}
onOpenMemory={pick}
requestIds={props.requestIds}
onClose={closeInspector}
/>
+266 -4
View File
@@ -1,4 +1,5 @@
// @vitest-environment jsdom
import type { SourceResponse } from "@one-memory/contracts";
import {
act,
cleanup,
@@ -7,6 +8,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";
@@ -91,12 +93,14 @@ vi.mock("./GraphWidthPrompt", () => ({ GraphWidthPrompt: () => null }));
vi.mock("./MemoryAddComposer", () => ({
MemoryAddComposer: ({
onPendingChange,
rawOnly,
}: {
onPendingChange?(addition: {
evidenceId: string;
text: string;
state: string;
}): void;
rawOnly?: boolean;
}) => (
<button
type="button"
@@ -108,7 +112,7 @@ vi.mock("./MemoryAddComposer", () => ({
})
}
>
补充记忆
{rawOnly ? "手动添加" : "补充记忆"}
</button>
),
}));
@@ -207,6 +211,34 @@ function setup() {
reads.rawSearch.mockResolvedValue([]);
}
function modeProcessing(mode: "lite" | "pro" | "ultra") {
return {
running: 0,
pending: 0,
blocked_stages: [],
completed: 1,
pipeline: {
mode,
configured_mode: mode,
forced_by_env: false,
evolve: {
mode,
schedule: { enabled: true, time: "03:00" },
users: {},
running: [],
},
last_evolve_at: null,
graph_build: {
state: "idle",
started_at: null,
finished_at: null,
users: {},
},
features: {},
},
};
}
it("shows raw conversations instead of advanced memory in lite", async () => {
setup();
reads.processing.mockResolvedValue({
@@ -262,7 +294,12 @@ it("shows raw conversations instead of advanced memory in lite", async () => {
).toBe(false);
expect(screen.getByTestId("graph-owner")).toBeTruthy();
expect(screen.getByRole("textbox", { name: "搜索原始对话" })).toBeTruthy();
expect(screen.queryByRole("button", { name: "补充记忆" })).toBeNull();
await userEvent.click(screen.getByRole("button", { name: "手动添加" }));
expect(
screen.getByText(/正在保存… 项目周会统一安排在周三上午。/),
).toBeTruthy();
await userEvent.click(screen.getByRole("combobox", { name: "来源" }));
expect(screen.getByRole("option", { name: "手动添加" })).toBeTruthy();
expect(reads.sdkLibrary).not.toHaveBeenCalled();
expect(reads.sdkGraph).not.toHaveBeenCalled();
expect(reads.raw).toHaveBeenCalledWith(
@@ -273,7 +310,228 @@ 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("pages and filters the raw conversation data plane in lite", async () => {
setup();
reads.processing.mockResolvedValue(modeProcessing("lite"));
reads.raw.mockImplementation(async (options = {}) => {
const offset = "offset" in options ? (options.offset ?? 0) : 0;
const source = "source" in options ? options.source : undefined;
const rawId = `${source ?? "all"}-${offset}`;
return {
items: [
{
raw_id: rawId,
content: `原始会话 ${rawId}`,
user_id: "local",
agent_id: "all-apps",
source: source ?? "cursor",
session_id: `session-${rawId}`,
memory_at: null,
gmt_created: 1_761_000_000 - offset,
custom: {},
messages: [{ role: "user", content: `原始会话 ${rawId}` }],
},
],
total: source ? 1 : 21,
limit: 20,
offset,
};
});
const user = userEvent.setup();
render(
<MemorySpace
simplified
sources={
[
{
source_id: "cursor",
display_name: "Cursor",
authorized: true,
},
] as SourceResponse[]
}
/>,
);
expect(await screen.findAllByText("原始会话 all-0")).toHaveLength(2);
await user.click(screen.getByRole("button", { name: "下一页记忆" }));
expect(await screen.findAllByText("原始会话 all-20")).toHaveLength(2);
expect(reads.raw).toHaveBeenLastCalledWith(
expect.objectContaining({ source: undefined, offset: 20, limit: 20 }),
);
await user.click(screen.getByRole("combobox", { name: "来源" }));
await user.click(screen.getByRole("option", { name: "Cursor" }));
expect(await screen.findAllByText("原始会话 cursor-0")).toHaveLength(2);
expect(reads.raw).toHaveBeenLastCalledWith(
expect.objectContaining({ source: "cursor", offset: 0, limit: 20 }),
);
await user.click(screen.getByRole("combobox", { name: "来源" }));
await user.click(screen.getByRole("option", { name: "手动添加" }));
expect(
await screen.findAllByText("原始会话 one-memory-manual-0"),
).toHaveLength(2);
expect(reads.raw).toHaveBeenLastCalledWith(
expect.objectContaining({
source: "one-memory-manual",
offset: 0,
limit: 20,
}),
);
});
it("switches data planes without keeping rows or details from the prior mode", async () => {
setup();
vi.useFakeTimers();
reads.processing.mockResolvedValue(modeProcessing("pro"));
reads.raw.mockResolvedValue({
items: [
{
raw_id: "raw-after-switch",
content: "切换后的原始会话",
user_id: "local",
agent_id: "all-apps",
source: "cursor",
session_id: "session-after-switch",
memory_at: null,
gmt_created: 1_761_000_000,
custom: {},
messages: [{ role: "user", content: "切换后的原始会话" }],
},
],
total: 1,
limit: 20,
offset: 0,
});
render(<MemorySpace simplified />);
await act(async () => {
await Promise.resolve();
await Promise.resolve();
});
expect(reads.sdkLibrary).toHaveBeenCalled();
expect(screen.getAllByText("已保存的记忆").length).toBeGreaterThan(0);
reads.processing.mockResolvedValue(modeProcessing("lite"));
await act(async () => {
await vi.advanceTimersByTimeAsync(3_000);
});
await vi.waitFor(() => expect(reads.raw).toHaveBeenCalled(), {
timeout: 1_000,
});
expect(screen.getAllByText("切换后的原始会话")).toHaveLength(2);
expect(screen.queryByText("已保存的记忆")).toBeNull();
expect(document.querySelector(".memory-mode-label")?.textContent).toBe(
"lite",
);
const libraryCalls = reads.sdkLibrary.mock.calls.length;
reads.processing.mockResolvedValue(modeProcessing("ultra"));
await act(async () => {
await vi.advanceTimersByTimeAsync(3_000);
});
await vi.waitFor(
() =>
expect(reads.sdkLibrary.mock.calls.length).toBeGreaterThan(libraryCalls),
{ timeout: 1_000 },
);
expect(screen.getAllByText("已保存的记忆").length).toBeGreaterThan(0);
expect(screen.queryByText("切换后的原始会话")).toBeNull();
expect(document.querySelector(".memory-mode-label")?.textContent).toBe(
"ultra",
);
});
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(<MemorySpace simplified />);
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 +575,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();
});
@@ -389,7 +389,7 @@ it("hides the previous source graph until the all-applications graph settles", a
expect(screen.getByTestId("map-graph-loading").textContent).toBe("true"),
);
expect(screen.getByTestId("map-nodes").textContent).toBe("none");
// Waiting for the graph is not page staleness: the list stays usable.
expect(screen.getByTestId("map-memories").textContent).toBe("");
expect(screen.getByTestId("map-stale").textContent).toBe("false");
await act(async () => {
@@ -419,6 +419,7 @@ it("hides the previous source graph until the all-applications graph settles", a
expect(screen.getByTestId("map-graph-loading").textContent).toBe("true"),
);
expect(screen.getByTestId("map-nodes").textContent).toBe("none");
expect(screen.getByTestId("map-memories").textContent).toBe("");
expect(api.sdkGraph).toHaveBeenCalledTimes(graphCalls);
await act(async () => releaseSearch(searchPage));
+73 -38
View File
@@ -216,8 +216,13 @@ export function MemorySpace({
processing?.pipeline?.mode ??
(processingResolved && !processingFailed ? "pro" : null);
const lite = pipelineMode === "lite";
const sdkPage =
sdkLibrary.settledMode === pipelineMode ? sdkLibrary.page : null;
const requestedQuery = sdkLibraryQuery.trim();
const sdkPageSettled =
sdkLibrary.settledMode === pipelineMode &&
sdkLibrary.settledSource === sdkLibrarySource &&
sdkLibrary.settledQuery.trim() === requestedQuery &&
sdkLibrary.page?.offset === sdkLibraryOffset;
const sdkPage = sdkPageSettled ? sdkLibrary.page : null;
const sdkRequestedScope = JSON.stringify([
pipelineMode,
sdkLibraryQuery.trim(),
@@ -682,7 +687,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 +696,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 +868,14 @@ export function MemorySpace({
</div>
<header className="memory-instrument-header">
<div>
<h1 id="memory-title">
{simplified ? t("memory:记忆") : t("memory:记忆空间")}
</h1>
<div className="memory-title-line">
<h1 id="memory-title">
{simplified ? t("memory:记忆") : t("memory:记忆空间")}
</h1>
{simplified && pipelineMode ? (
<span className="memory-mode-label">{pipelineMode}</span>
) : null}
</div>
<span className="memory-loaded-count">
{simplified && !management
? pipelineMode === null
@@ -869,22 +911,21 @@ export function MemorySpace({
: null}
</span>
</div>
{!lite ? (
<MemoryAddComposer
suspended={!active}
onPendingChange={setPendingAdd}
onSaved={() => {
// A finished addition is a new memory: re-read the page now rather
// than leave it for the next poll.
bumpSdkMap();
}}
onOpenMemory={(id) => {
if (!simplified) setMode("library");
void open(id);
}}
onConfigureModel={onConfigureModel}
/>
) : null}
<MemoryAddComposer
suspended={!active}
rawOnly={lite}
onPendingChange={setPendingAdd}
onSaved={() => {
// A finished addition is new page content: re-read now rather than
// leave it for the next poll.
bumpSdkMap();
}}
onOpenMemory={(id) => {
if (!simplified) setMode("library");
void open(id);
}}
onConfigureModel={onConfigureModel}
/>
{!simplified && (
<div
className="memory-mode-tabs"
@@ -952,19 +993,6 @@ export function MemorySpace({
</div>
)}
</header>
{simplified && pipelineMode ? (
<p className="memory-mode-notice" role="status">
{lite
? t(
"memory:当前使用基础记忆。新对话会保存为原始记录,不会生成高级记忆;以前生成的高级记忆仍会保留,但当前不会显示或参与记忆召回。",
)
: pipelineMode === "ultra"
? t(
"memory:当前使用深度记忆。新对话会生成高级记忆,并由知识图谱和 evolve 继续整合。",
)
: t("memory:当前使用智能记忆。新对话会由模型整理为高级记忆。")}
</p>
) : null}
{processing && (!simplified || managementOpen) ? (
<details className="memory-processing-details">
<summary>
@@ -1064,13 +1092,20 @@ export function MemorySpace({
/>
</>
) : null}
{simplified && !lite && pendingAdd ? (
{simplified && pendingAdd ? (
<p className="memory-pending-add" role="status">
<span>
{t("memory:正在等待整理:")}
{lite
? t(
pendingAdd.state === "failed"
? "memory:原话已保存,整理未完成"
: "memory:正在保存…",
)
: t("memory:正在等待整理:")}
{lite ? " " : null}
{pendingAdd.text}
</span>
{pendingAdd.state === "waiting_for_model" ? (
{!lite && pendingAdd.state === "waiting_for_model" ? (
<button type="button" onClick={onConfigureModel}>
{t("memory:配置整理模型")}
</button>
@@ -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(
<LanguageProvider>
<SelectedRawDetail
record={{
raw_id: "raw-long",
content: `[user]: ${long}\n[assistant]: 已收到`,
user_id: "local",
agent_id: "all-apps",
session_id: "session-1",
source: "cursor",
memory_at: 1_793_707_200,
gmt_created: 1_793_707_210,
custom: {},
messages: [
{ role: "user", content: long },
{ role: "assistant", content: "已收到" },
],
}}
sources={[]}
/>
</LanguageProvider>,
);
expect(screen.getByText("你")).toBeTruthy();
expect(screen.getByText("助手")).toBeTruthy();
expect(screen.getByText(long)).toHaveProperty("textContent", long);
expect(screen.getByText("已收到")).toBeTruthy();
});
+73 -25
View File
@@ -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<HTMLFormElement>): void;
onDelete(): void;
onOpenMemory?(memoryId: string): void;
requestIds: RefObject<Map<string, string>>;
onClose(): void;
compact?: boolean;
@@ -138,33 +140,79 @@ 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,
);
const compactNames =
compact && (node.node_type === "topic" || node.node_type === "schema");
return (
<article className="wb-detail" aria-label={t("wb.node")}>
<div className="wb-detail-head">
<span className="wb-status">{nodeTypeLabel(node.node_type, t)}</span>
<strong>{node.label}</strong>
</div>
<p className="wb-detail-note">
{t("wb.nodeNote", {
direct: new Intl.NumberFormat(locale).format(direct.length),
indirect: new Intl.NumberFormat(locale).format(
trace.length - direct.length,
),
})}
</p>
<ul className="wb-trace">
{trace.slice(0, 8).map((step) => (
<li key={`${step.edge.edge_id}:${step.hop}`}>
<span className="wb-trace-hop">
{step.hop === 1 ? t("wb.direct") : t("wb.twoHops")}
</span>
{relationLabel(step.edge.relation, t)} · {step.node.label}
<span className="wb-trace-origin">
{edgeOriginLabel(step.edge.origin, t)}
</span>
</li>
))}
</ul>
{compactNames ? null : (
<div className="wb-detail-head">
<span className="wb-status">
{compact && node.node_type === "schema"
? t("memory:关系归纳")
: nodeTypeLabel(node.node_type, t)}
</span>
<strong>{node.label}</strong>
</div>
)}
{compact ? (
relatedMemories.length ? (
<section
className={
compactNames
? "wb-node-related wb-node-related--names"
: "wb-node-related"
}
>
{compactNames ? null : <h3>{t("selectedMemory.related")}</h3>}
<ul>
{relatedMemories.slice(0, 8).map((step) => (
<li key={step.edge.edge_id}>
<button
type="button"
className="map-related"
onClick={() =>
step.node.memory_id &&
onOpenMemory?.(step.node.memory_id)
}
>
<span>{step.node.label}</span>
{compactNames ? null : (
<small>{relationLabel(step.edge.relation, t)}</small>
)}
</button>
</li>
))}
</ul>
</section>
) : null
) : (
<>
<p className="wb-detail-note">
{t("wb.nodeNote", {
direct: new Intl.NumberFormat(locale).format(direct.length),
indirect: new Intl.NumberFormat(locale).format(
trace.length - direct.length,
),
})}
</p>
<ul className="wb-trace">
{trace.slice(0, 8).map((step) => (
<li key={`${step.edge.edge_id}:${step.hop}`}>
<span className="wb-trace-hop">
{step.hop === 1 ? t("wb.direct") : t("wb.twoHops")}
</span>
{relationLabel(step.edge.relation, t)} · {step.node.label}
<span className="wb-trace-origin">
{edgeOriginLabel(step.edge.origin, t)}
</span>
</li>
))}
</ul>
</>
)}
</article>
);
}
+57
View File
@@ -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 },
);
});
+3 -1
View File
@@ -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<SdkRawPage> {
const response = await client.get<SdkRawPage>("/api/v2/raw", {
params: { source, offset, limit },
params: { source, session_id: sessionId, offset, limit },
signal,
});
return response.data;
+64 -10
View File
@@ -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,59 @@
gap: 12px;
flex-wrap: wrap;
}
.r6-memory .wb-node-related {
margin-top: 28px;
}
.r6-memory .wb-node-related--names {
margin-top: 0;
}
.r6-memory .wb-node-related--names li:first-child {
border-top: 0;
}
.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 {
min-height: 0;
padding: 12px 0;
border: 0;
border-radius: 0;
background: transparent;
color: #384a57;
font-weight: 400;
box-shadow: none;
}
.r6-memory .wb-node-related .map-related:hover:not(:disabled),
.r6-memory .wb-node-related .map-related:active:not(:disabled) {
border: 0;
background: transparent;
color: #304757;
transform: none;
}
.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;
white-space: nowrap;
}
.r6-memory .map-inspector .wb-detail-actions button {
min-height: 36px;
border-radius: 8px;
+1
View File
@@ -236,6 +236,7 @@
font-size: 18px;
line-height: 31px;
font-weight: 500;
overflow-wrap: anywhere;
}
.selected-memory-detail {
display: flex;
+2 -1
View File
@@ -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
+1 -1
View File
@@ -1,6 +1,6 @@
# MemHub 安装与发行
MemHub `v1.0.6`(build 38)的发行目标如下:
MemHub `v1.0.6`(build 39)的发行目标如下:
| 平台 | 发行物 | 构建与验收 |
| --- | --- | --- |
@@ -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`.
@@ -45,6 +45,22 @@ it as `forced_by_env: true`, with `configured_mode` showing the saved choice.
**Raw is never deleted.** In every mode MemHub keeps every raw turn the SDK
stored, and the SDK never deletes raw itself. There is no retention window.
### Hand-added content in lite
The lite memory page offers `手动添加`. It uses the existing
`POST /api/v2/memories` receipt flow, but the lite write processor deliberately
calls `client.add`, not `add_extracted`: the text is stored as one raw `user`
turn under source `one-memory-manual`, with no generation-model call. The raw
page and raw recall can therefore read it immediately after the durable write
settles. A completed lite addition has raw ids but no generated memory ids, so
the App presents `no_memories` as “已保存原文”, not as a failed extraction.
This is a minimal compatibility path, not a claim that a hand-written note was
an observed conversation. `input_mode=direct_memory_note` remains on the
record so a future first-class manual-memory lane can migrate it without
guessing. Like every other lite raw turn, switching to pro or ultra does not
backfill it into a generated memory.
## 2. Switching mode: what changes and what does not
The SDK migrates nothing when the mode changes. MemHub applies these rules,
@@ -356,6 +372,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
+1 -1
View File
@@ -2,7 +2,7 @@
"name": "one-memory",
"version": "1.0.6",
"release": {
"build_number": 38,
"build_number": 39,
"macos_min_version": "13.0",
"app_update": "manual",
"runtime_manifest_schema": 1,