Compare commits
7
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
4e7d752980 | ||
|
|
fb13a6fda2 | ||
|
|
6ff8b3a3b4 | ||
|
|
5271bddce3 | ||
|
|
d938f1d5b2 | ||
|
|
8cee64c813 | ||
|
|
1ffcc9eb57 |
No files matched your search
+1
-1
@@ -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
@@ -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)。
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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")
|
||||
|
||||
@@ -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"))
|
||||
|
||||
@@ -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) ?? "")}
|
||||
|
||||
@@ -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" });
|
||||
|
||||
@@ -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}
|
||||
/>
|
||||
|
||||
@@ -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));
|
||||
|
||||
@@ -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();
|
||||
});
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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 },
|
||||
);
|
||||
});
|
||||
@@ -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;
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -236,6 +236,7 @@
|
||||
font-size: 18px;
|
||||
line-height: 31px;
|
||||
font-weight: 500;
|
||||
overflow-wrap: anywhere;
|
||||
}
|
||||
.selected-memory-detail {
|
||||
display: flex;
|
||||
|
||||
@@ -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,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
@@ -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,
|
||||
|
||||
Reference in new issue
Block a user