fix(memhub): preserve raw conversations in lite mode
Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
1 parent
d938f1d5b2
commit
5271bddce3
25 files changed
+727
-128
No files matched your search
@@ -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")
|
||||
|
||||
@@ -149,6 +149,77 @@ it("OBS-22: the graph inspector has no editing entry and never says changes cann
|
||||
expect(screen.queryByText(/暂不能保存更改/)).toBeNull();
|
||||
});
|
||||
|
||||
it("replaces a topic detail with an off-page graph memory detail", async () => {
|
||||
const onOpen = vi.fn();
|
||||
const topicGraph = {
|
||||
total_memories: 2,
|
||||
projected_memories: 2,
|
||||
hy_graph_available: true,
|
||||
nodes: [
|
||||
{
|
||||
node_id: "memory:m0",
|
||||
node_type: "memory",
|
||||
memory_id: "m0",
|
||||
label: "第一条",
|
||||
},
|
||||
{
|
||||
node_id: "memory:outside",
|
||||
node_type: "memory",
|
||||
memory_id: "outside",
|
||||
label: "图中但不在当前页的记忆",
|
||||
},
|
||||
{
|
||||
node_id: "topic:git",
|
||||
node_type: "topic",
|
||||
memory_id: null,
|
||||
label: "git",
|
||||
},
|
||||
],
|
||||
edges: [
|
||||
{
|
||||
edge_id: "topic-m0",
|
||||
source_node_id: "topic:git",
|
||||
target_node_id: "memory:m0",
|
||||
relation: "tagged_with",
|
||||
origin: "shared_tag",
|
||||
},
|
||||
{
|
||||
edge_id: "topic-outside",
|
||||
source_node_id: "topic:git",
|
||||
target_node_id: "memory:outside",
|
||||
relation: "tagged_with",
|
||||
origin: "shared_tag",
|
||||
},
|
||||
],
|
||||
} as MemoryGraphResponse;
|
||||
render(
|
||||
<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.getByText("对应记忆")).toBeTruthy();
|
||||
expect(screen.queryByText("两跳")).toBeNull();
|
||||
await userEvent.click(
|
||||
screen.getByRole("button", {
|
||||
name: "图中选择 图中但不在当前页的记忆",
|
||||
}),
|
||||
);
|
||||
|
||||
expect(onOpen).toHaveBeenLastCalledWith("outside");
|
||||
});
|
||||
|
||||
it("OBS-19: Enter in the memory filter submits it; submitting it again re-reads", async () => {
|
||||
const onRetry = vi.fn();
|
||||
const onQuery = vi.fn();
|
||||
@@ -391,6 +462,7 @@ function Host({
|
||||
deferMemoryId,
|
||||
failMemoryId,
|
||||
graphValue = graph,
|
||||
onOpenSpy,
|
||||
onViewHistory,
|
||||
onConfigureModel = () => {},
|
||||
libraryView,
|
||||
@@ -408,6 +480,7 @@ function Host({
|
||||
deferMemoryId?: string;
|
||||
failMemoryId?: string;
|
||||
graphValue?: MemoryGraphResponse;
|
||||
onOpenSpy?(memoryId: string): void;
|
||||
onViewHistory?(): void;
|
||||
onConfigureModel?(): void;
|
||||
libraryView?: Parameters<typeof MemoryMap>[0]["libraryView"];
|
||||
@@ -445,6 +518,7 @@ function Host({
|
||||
streaming={null}
|
||||
requestIds={{ current: new Map() }}
|
||||
onOpenMemory={(id) => {
|
||||
onOpenSpy?.(id);
|
||||
if (failMemoryId && id === failMemoryId) {
|
||||
setDetail(null);
|
||||
setDetailLoad({ memoryId: id, status: "failed" });
|
||||
|
||||
@@ -419,6 +419,15 @@ export function MemoryMap(props: Props) {
|
||||
)),
|
||||
) ?? null)
|
||||
: null;
|
||||
const projectedMemoryIds = useMemo(
|
||||
() =>
|
||||
new Set(
|
||||
projection.nodes.flatMap((node) =>
|
||||
node.memory_id ? [node.memory_id] : [],
|
||||
),
|
||||
),
|
||||
[projection.nodes],
|
||||
);
|
||||
const selectedSource =
|
||||
visibleGroupNode?.node_type === "source"
|
||||
? sources.find(
|
||||
@@ -436,9 +445,12 @@ export function MemoryMap(props: Props) {
|
||||
}
|
||||
}, [groupNode, visibleGroupNode]);
|
||||
const requestedId = selectedId ?? detail?.memory.memory_id ?? null;
|
||||
const activeId = active.some((memory) => memory.memory_id === requestedId)
|
||||
? requestedId
|
||||
: null;
|
||||
const activeId =
|
||||
requestedId &&
|
||||
(active.some((memory) => memory.memory_id === requestedId) ||
|
||||
projectedMemoryIds.has(requestedId))
|
||||
? requestedId
|
||||
: null;
|
||||
const inspectorIdentity = activeId
|
||||
? `memory:${activeId}`
|
||||
: selectedSource
|
||||
@@ -448,7 +460,8 @@ export function MemoryMap(props: Props) {
|
||||
const visibleDetail =
|
||||
detail &&
|
||||
(!activeId || activeId === detail.memory.memory_id) &&
|
||||
filtered.some((m) => m.memory_id === detail.memory.memory_id)
|
||||
(filtered.some((m) => m.memory_id === detail.memory.memory_id) ||
|
||||
projectedMemoryIds.has(detail.memory.memory_id))
|
||||
? detail
|
||||
: null;
|
||||
const lastDetail = useRef(visibleDetail);
|
||||
@@ -466,7 +479,8 @@ export function MemoryMap(props: Props) {
|
||||
props.detailLoad.status === "failed";
|
||||
const pick = useCallback(
|
||||
(memoryId: string, scroll = true) => {
|
||||
if (!active.some((memory) => memory.memory_id === memoryId)) return;
|
||||
const listed = active.some((memory) => memory.memory_id === memoryId);
|
||||
if (!listed && !projectedMemoryIds.has(memoryId)) return;
|
||||
if (pendingPageOffset !== null && library?.failed && library.page)
|
||||
library.onPage(library.page.offset);
|
||||
setPendingSourceSelection(null);
|
||||
@@ -475,7 +489,7 @@ export function MemoryMap(props: Props) {
|
||||
pageRetrySawLoading.current = false;
|
||||
setDetailResetRevision((current) => current + 1);
|
||||
scrollSelection.current = scroll;
|
||||
if (!filtered.some((memory) => memory.memory_id === memoryId)) {
|
||||
if (listed && !filtered.some((memory) => memory.memory_id === memoryId)) {
|
||||
setGroupKey("");
|
||||
setSearch("");
|
||||
setFilterNotice("已清除筛选,定位到关联记忆。");
|
||||
@@ -485,7 +499,14 @@ export function MemoryMap(props: Props) {
|
||||
setInspectorOpen(true);
|
||||
onOpenMemory(memoryId);
|
||||
},
|
||||
[active, filtered, library, onOpenMemory, pendingPageOffset],
|
||||
[
|
||||
active,
|
||||
filtered,
|
||||
library,
|
||||
onOpenMemory,
|
||||
pendingPageOffset,
|
||||
projectedMemoryIds,
|
||||
],
|
||||
);
|
||||
useEffect(() => {
|
||||
if (
|
||||
@@ -605,7 +626,8 @@ export function MemoryMap(props: Props) {
|
||||
return;
|
||||
if (
|
||||
requestedId &&
|
||||
!active.some((memory) => memory.memory_id === requestedId)
|
||||
!active.some((memory) => memory.memory_id === requestedId) &&
|
||||
!projectedMemoryIds.has(requestedId)
|
||||
) {
|
||||
clear();
|
||||
return;
|
||||
@@ -623,6 +645,7 @@ export function MemoryMap(props: Props) {
|
||||
overlaysSuspended,
|
||||
library?.loading,
|
||||
pendingPageOffset,
|
||||
projectedMemoryIds,
|
||||
]);
|
||||
function closeInspector() {
|
||||
if (!compact) {
|
||||
@@ -1359,7 +1382,13 @@ export function MemoryMap(props: Props) {
|
||||
: selectedSource
|
||||
? text("记忆详情")
|
||||
: visibleGroupNode
|
||||
? text("关联节点")
|
||||
? text(
|
||||
visibleGroupNode.node_type === "topic"
|
||||
? "主题"
|
||||
: visibleGroupNode.node_type === "schema"
|
||||
? "关系归纳"
|
||||
: "关联节点",
|
||||
)
|
||||
: text("记忆详情")}
|
||||
</h2>
|
||||
{(activeId || visibleGroupNode) && (
|
||||
@@ -1499,6 +1528,7 @@ export function MemoryMap(props: Props) {
|
||||
onDraft={props.onDraft}
|
||||
onSave={props.onSave}
|
||||
onDelete={props.onDelete}
|
||||
onOpenMemory={pick}
|
||||
requestIds={props.requestIds}
|
||||
onClose={closeInspector}
|
||||
/>
|
||||
|
||||
@@ -7,6 +7,7 @@ import {
|
||||
screen,
|
||||
waitFor,
|
||||
} from "@testing-library/react";
|
||||
import userEvent from "@testing-library/user-event";
|
||||
import { afterEach, expect, it, vi } from "vitest";
|
||||
import { LANGUAGE_STORAGE_KEY, LanguageProvider } from "./gui-language";
|
||||
import { MemorySpace } from "./MemorySpace";
|
||||
@@ -273,7 +274,95 @@ it("shows raw conversations instead of advanced memory in lite", async () => {
|
||||
signal: expect.any(AbortSignal),
|
||||
}),
|
||||
);
|
||||
expect(screen.getByText(/当前为 lite/)).toBeTruthy();
|
||||
expect(document.querySelector(".memory-mode-label")?.textContent).toBe(
|
||||
"lite",
|
||||
);
|
||||
expect(document.querySelector(".memory-mode-notice")).toBeNull();
|
||||
});
|
||||
|
||||
it("opens a search hit with the complete role transcript", async () => {
|
||||
setup();
|
||||
reads.processing.mockResolvedValue({
|
||||
running: 0,
|
||||
pending: 0,
|
||||
blocked_stages: [],
|
||||
completed: 1,
|
||||
pipeline: {
|
||||
mode: "lite",
|
||||
configured_mode: "lite",
|
||||
forced_by_env: false,
|
||||
evolve: {
|
||||
mode: "lite",
|
||||
schedule: { enabled: true, time: "03:00" },
|
||||
users: {},
|
||||
running: [],
|
||||
},
|
||||
last_evolve_at: null,
|
||||
graph_build: {
|
||||
state: "idle",
|
||||
started_at: null,
|
||||
finished_at: null,
|
||||
users: {},
|
||||
},
|
||||
features: {},
|
||||
},
|
||||
});
|
||||
reads.raw.mockImplementation(async (options = {}) =>
|
||||
"sessionId" in options && options.sessionId
|
||||
? {
|
||||
items: [
|
||||
{
|
||||
raw_id: "raw-search",
|
||||
content: "[user]: 完整问题\n[assistant]: 完整回答",
|
||||
user_id: "local",
|
||||
agent_id: "all-apps",
|
||||
source: "cursor",
|
||||
session_id: "session-search",
|
||||
memory_at: null,
|
||||
gmt_created: 1_761_000_000,
|
||||
custom: {},
|
||||
messages: [
|
||||
{ role: "user", content: "完整问题" },
|
||||
{ role: "assistant", content: "完整回答" },
|
||||
],
|
||||
},
|
||||
],
|
||||
total: 1,
|
||||
limit: 500,
|
||||
offset: 0,
|
||||
}
|
||||
: { items: [], total: 0, limit: 20, offset: 0 },
|
||||
);
|
||||
reads.rawSearch.mockResolvedValue([
|
||||
{
|
||||
kind: "raw",
|
||||
raw_id: "raw-search",
|
||||
content: "被截断的搜索摘要",
|
||||
source: "cursor",
|
||||
session_id: "session-search",
|
||||
memory_at: null,
|
||||
score: 0.9,
|
||||
truncated: true,
|
||||
},
|
||||
]);
|
||||
const user = userEvent.setup();
|
||||
render(<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 +406,11 @@ it("does not request advanced memories before the pipeline mode is known", async
|
||||
},
|
||||
});
|
||||
});
|
||||
await screen.findByText(/当前为 lite/);
|
||||
await waitFor(() =>
|
||||
expect(document.querySelector(".memory-mode-label")?.textContent).toBe(
|
||||
"lite",
|
||||
),
|
||||
);
|
||||
expect(reads.sdkLibrary).not.toHaveBeenCalled();
|
||||
expect(reads.sdkGraph).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
@@ -682,7 +682,7 @@ export function MemorySpace({
|
||||
setDetail(null);
|
||||
setSelectedRaw(null);
|
||||
if (lite) {
|
||||
const record = rawRecords.current.get(memoryId);
|
||||
let record = rawRecords.current.get(memoryId);
|
||||
if (!record) {
|
||||
setDetailLoad({
|
||||
memoryId,
|
||||
@@ -691,6 +691,38 @@ export function MemorySpace({
|
||||
});
|
||||
return;
|
||||
}
|
||||
if ("kind" in record) {
|
||||
setDetailLoad({ memoryId, status: "loading" });
|
||||
try {
|
||||
let offset = 0;
|
||||
while (true) {
|
||||
const page = await getSdkRaw({
|
||||
source: record.source || undefined,
|
||||
sessionId: record.session_id || undefined,
|
||||
offset,
|
||||
limit: 500,
|
||||
});
|
||||
const full = page.items.find((item) => item.raw_id === memoryId);
|
||||
if (full) {
|
||||
record = full;
|
||||
rawRecords.current.set(memoryId, full);
|
||||
break;
|
||||
}
|
||||
offset += page.items.length;
|
||||
if (!page.items.length || offset >= page.total)
|
||||
throw new Error("raw_not_found");
|
||||
}
|
||||
} catch {
|
||||
if (epoch === detailEpoch.current)
|
||||
setDetailLoad({
|
||||
memoryId,
|
||||
status: "failed",
|
||||
error: t("sdk.raw.loadFailed"),
|
||||
});
|
||||
return;
|
||||
}
|
||||
if (epoch !== detailEpoch.current) return;
|
||||
}
|
||||
setSelectedRaw(record);
|
||||
setDetail(rawDetail(record));
|
||||
setDetailLoad(null);
|
||||
@@ -831,9 +863,14 @@ export function MemorySpace({
|
||||
</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
|
||||
@@ -952,19 +989,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>
|
||||
|
||||
@@ -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,67 @@ export function NodeDetail({
|
||||
if (node) {
|
||||
const trace = graph ? traceFrom(graph, node.node_id, 2) : [];
|
||||
const direct = trace.filter((step) => step.hop === 1);
|
||||
const relatedMemories = direct.filter(
|
||||
(step) => step.node.memory_id !== null,
|
||||
);
|
||||
return (
|
||||
<article className="wb-detail" aria-label={t("wb.node")}>
|
||||
<div className="wb-detail-head">
|
||||
<span className="wb-status">{nodeTypeLabel(node.node_type, t)}</span>
|
||||
<span className="wb-status">
|
||||
{compact && node.node_type === "schema"
|
||||
? t("memory:关系归纳")
|
||||
: 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>
|
||||
{compact ? (
|
||||
relatedMemories.length ? (
|
||||
<section className="wb-node-related">
|
||||
<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>
|
||||
<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,39 @@
|
||||
gap: 12px;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
.r6-memory .wb-node-related {
|
||||
margin-top: 28px;
|
||||
}
|
||||
.r6-memory .wb-node-related h3 {
|
||||
margin: 0 0 10px;
|
||||
color: #5e6e7a;
|
||||
font-size: 12px;
|
||||
font-weight: 500;
|
||||
line-height: 21px;
|
||||
}
|
||||
.r6-memory .wb-node-related ul {
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
list-style: none;
|
||||
}
|
||||
.r6-memory .wb-node-related li {
|
||||
border-top: 1px solid #d8e2ea;
|
||||
}
|
||||
.r6-memory .wb-node-related .map-related {
|
||||
padding: 12px 0;
|
||||
color: #384a57;
|
||||
}
|
||||
.r6-memory .wb-node-related .map-related span {
|
||||
display: -webkit-box;
|
||||
overflow: hidden;
|
||||
-webkit-box-orient: vertical;
|
||||
-webkit-line-clamp: 3;
|
||||
line-height: 1.7;
|
||||
}
|
||||
.r6-memory .wb-node-related .map-related small {
|
||||
flex: none;
|
||||
color: #637786;
|
||||
}
|
||||
.r6-memory .map-inspector .wb-detail-actions button {
|
||||
min-height: 36px;
|
||||
border-radius: 8px;
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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`.
|
||||
|
||||
@@ -356,6 +356,9 @@ The App's product rules are:
|
||||
- A new installation explicitly chooses lite or pro. Choosing lite persists
|
||||
the mode and completes onboarding without a generation model. Choosing pro
|
||||
opens model setup and completes onboarding only after activation succeeds.
|
||||
- Lite always ingests authorized imported and live conversations into L1 raw,
|
||||
even when model sedimentation is paused. That pause controls generation-model
|
||||
work in pro/ultra; it never turns raw conversation storage off.
|
||||
- Existing installations keep the default pro behaviour; an absent
|
||||
`memory_mode` is never inferred from model availability.
|
||||
- Ultra is enabled only from Settings. Its nightly schedule is on by default
|
||||
|
||||
Reference in new issue
Block a user