Compare commits
13
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
e7d82ee5e8 | ||
|
|
63e009bf6c | ||
|
|
d4cfd1bb74 | ||
|
|
2822cbf368 | ||
|
|
8d23347e1a | ||
|
|
8ae0d1889e | ||
|
|
2479de22eb | ||
|
|
265a22d612 | ||
|
|
93d0c64490 | ||
|
|
1589ae31bc | ||
|
|
38ad2292d1 | ||
|
|
0953c5af35 | ||
|
|
470ea4459a |
No files matched your search
+24
-5
@@ -1864,6 +1864,7 @@ class HyMemoryClient:
|
||||
branch: Optional[str] = None,
|
||||
request_id: Optional[str] = None,
|
||||
layers: Optional[List[Any]] = None,
|
||||
statuses: Optional[List[Any]] = None,
|
||||
embed_override: Optional[Dict[str, Any]] = None,
|
||||
query_embedding: Optional[List[float]] = None,
|
||||
rerank: bool = True,
|
||||
@@ -1956,6 +1957,8 @@ class HyMemoryClient:
|
||||
layers: 可选。pro/ultra 下只召回指定层级(字符串或
|
||||
MemoryLayer),在召回阶段生效。lite 下忽略该参数并
|
||||
强制只召回 L1_RAW。
|
||||
statuses: 仅用于 L1_RAW 定向搜索,可显式包含 active / shadow。
|
||||
非 raw 搜索或 scene="all" 传入时拒绝,避免静默忽略。
|
||||
rerank: 默认 True(原行为)。False 时本次调用跳过全部重排阶段:
|
||||
chat 路的本地 cross-encoder 重排(recall.local_rerank_enabled)
|
||||
与记忆关系图重排(graph_rerank / episode 扩展),working/
|
||||
@@ -1999,7 +2002,7 @@ class HyMemoryClient:
|
||||
intention_limit=intention_limit, created_after=created_after,
|
||||
reader=reader,
|
||||
workspace_id=workspace_id, branch=branch,
|
||||
request_id=request_id, layers=layers,
|
||||
request_id=request_id, layers=layers, statuses=statuses,
|
||||
embed_override=embed_override,
|
||||
query_embedding=query_embedding,
|
||||
rerank=rerank,
|
||||
@@ -3387,12 +3390,14 @@ class HyMemoryClient:
|
||||
request_id: str,
|
||||
t0: float,
|
||||
query_embedding: Optional[List[float]],
|
||||
statuses: Optional[List[Any]],
|
||||
) -> Dict[str, Any]:
|
||||
"""Implement `search(..., layers=['l1_raw'])` without a second API."""
|
||||
from .models.memory import MemoryLayer, MemoryStatus
|
||||
|
||||
embedding = query_embedding or await self._embed_service.embed(query)
|
||||
fetch = min(max(limit * (10 if session_ids else 2), limit), 10_000)
|
||||
status_filter = self._normalize_statuses(statuses) or [MemoryStatus.ACTIVE]
|
||||
hits = await self._vector_store.search(
|
||||
query_embedding=embedding,
|
||||
user_ids=user_ids,
|
||||
@@ -3402,7 +3407,7 @@ class HyMemoryClient:
|
||||
layers=[MemoryLayer.L1_RAW],
|
||||
limit=fetch,
|
||||
score_threshold=min_score,
|
||||
status_filter=[MemoryStatus.ACTIVE],
|
||||
status_filter=status_filter,
|
||||
only_latest=False,
|
||||
created_after=created_after,
|
||||
)
|
||||
@@ -3420,7 +3425,7 @@ class HyMemoryClient:
|
||||
"memory_id": row.pop("raw_id"),
|
||||
**row,
|
||||
"layer": MemoryLayer.L1_RAW.value,
|
||||
"status": MemoryStatus.ACTIVE.value,
|
||||
"status": node.status.value,
|
||||
"score": round(float(hit.get("score", 0.0)), 4),
|
||||
}
|
||||
)
|
||||
@@ -3864,6 +3869,7 @@ class HyMemoryClient:
|
||||
branch: Optional[str] = None,
|
||||
request_id: Optional[str] = None,
|
||||
layers: Optional[List[Any]] = None,
|
||||
statuses: Optional[List[Any]] = None,
|
||||
embed_override: Optional[Dict[str, Any]] = None,
|
||||
query_embedding: Optional[List[float]] = None,
|
||||
rerank: bool = True,
|
||||
@@ -3893,7 +3899,7 @@ class HyMemoryClient:
|
||||
schema_min_score=schema_min_score, schema_limit=schema_limit,
|
||||
intention_limit=intention_limit, created_after=created_after,
|
||||
reader=reader, workspace_id=workspace_id, branch=branch,
|
||||
request_id=request_id, layers=layers,
|
||||
request_id=request_id, layers=layers, statuses=statuses,
|
||||
query_embedding=query_embedding,
|
||||
rerank=rerank,
|
||||
)
|
||||
@@ -3908,7 +3914,7 @@ class HyMemoryClient:
|
||||
schema_min_score=schema_min_score, schema_limit=schema_limit,
|
||||
intention_limit=intention_limit, created_after=created_after,
|
||||
reader=reader, workspace_id=workspace_id, branch=branch,
|
||||
request_id=request_id, layers=layers,
|
||||
request_id=request_id, layers=layers, statuses=statuses,
|
||||
query_embedding=query_embedding,
|
||||
rerank=rerank,
|
||||
)
|
||||
@@ -3938,6 +3944,7 @@ class HyMemoryClient:
|
||||
branch: Optional[str] = None,
|
||||
request_id: Optional[str] = None,
|
||||
layers: Optional[List[Any]] = None,
|
||||
statuses: Optional[List[Any]] = None,
|
||||
query_embedding: Optional[List[float]] = None,
|
||||
rerank: bool = True,
|
||||
) -> Dict[str, Any]:
|
||||
@@ -4023,6 +4030,17 @@ class HyMemoryClient:
|
||||
"Use single agent_id with multiple session_ids, or multiple agent_ids without session_ids."
|
||||
)
|
||||
|
||||
if statuses is not None:
|
||||
from .models.memory import MemoryLayer
|
||||
|
||||
requested_layers = self._normalize_layers(layers)
|
||||
if scene_all or (
|
||||
not lite_mode and requested_layers != [MemoryLayer.L1_RAW]
|
||||
):
|
||||
raise ValueError(
|
||||
"statuses is supported only for explicit L1_RAW search"
|
||||
)
|
||||
|
||||
if scene_all:
|
||||
return await self._search_all_scenes(
|
||||
query, queries=queries, search_mode=search_mode,
|
||||
@@ -4358,6 +4376,7 @@ class HyMemoryClient:
|
||||
request_id=request_id,
|
||||
t0=t0,
|
||||
query_embedding=query_embedding,
|
||||
statuses=statuses,
|
||||
)
|
||||
|
||||
read_req = ReadRequest(
|
||||
|
||||
@@ -270,6 +270,7 @@ from one_memory.onboarding import (
|
||||
OnboardingState,
|
||||
OnboardingStore,
|
||||
)
|
||||
from one_memory.raw_turns import RawDisplayTurn, raw_display_turns
|
||||
from one_memory.recall import (
|
||||
GovernedRecallTarget,
|
||||
RecallReceipt,
|
||||
@@ -604,24 +605,37 @@ class SdkRawItem(HySdkRawRecord):
|
||||
role: str | None = None
|
||||
content_hash: str
|
||||
created: datetime | None = None
|
||||
turn_id: str | None = Field(default=None, min_length=1, max_length=96)
|
||||
turn_index: int | None = Field(default=None, ge=0)
|
||||
turn_count: int | None = Field(default=None, ge=1)
|
||||
|
||||
@classmethod
|
||||
def of(cls, record: HySdkRawRecord) -> "SdkRawItem":
|
||||
def of(
|
||||
cls, record: HySdkRawRecord, turn: RawDisplayTurn | None = None
|
||||
) -> "SdkRawItem":
|
||||
content = turn.content if turn is not None else record.content
|
||||
raw_messages = (
|
||||
turn.messages if turn is not None else raw_turn_messages(record.content)
|
||||
)
|
||||
messages = tuple(
|
||||
SdkRawMessage(role=role, content=text)
|
||||
for role, text in raw_turn_messages(record.content)
|
||||
for role, text in raw_messages
|
||||
)
|
||||
roles = {message.role for message in messages}
|
||||
return cls(
|
||||
**record.model_dump(),
|
||||
**record.model_dump(exclude={"content"}),
|
||||
content=content,
|
||||
messages=messages,
|
||||
role=next(iter(roles)) if len(roles) == 1 and "" not in roles else None,
|
||||
content_hash="sha256:" + hashlib.sha256(record.content.encode("utf-8")).hexdigest(),
|
||||
content_hash="sha256:" + hashlib.sha256(content.encode("utf-8")).hexdigest(),
|
||||
created=(
|
||||
datetime.fromtimestamp(record.gmt_created, tz=UTC)
|
||||
if record.gmt_created is not None
|
||||
else None
|
||||
),
|
||||
turn_id=turn.turn_id if turn is not None else None,
|
||||
turn_index=turn.turn_index if turn is not None else None,
|
||||
turn_count=turn.turn_count if turn is not None else None,
|
||||
)
|
||||
|
||||
|
||||
@@ -634,9 +648,9 @@ class SdkRawPage(BaseModel):
|
||||
total: int = Field(ge=0)
|
||||
offset: int = Field(ge=0)
|
||||
limit: int = Field(ge=1, le=500)
|
||||
#: Set when a time filter was applied to this page in Core (the SDK list
|
||||
#: has none): `items` is the page's rows inside the range, `total` stays
|
||||
#: the SDK's count before that filter.
|
||||
unit: Literal["raw", "turn"] = "raw"
|
||||
#: Set when a time filter was applied in Core. For storage `raw`, `total`
|
||||
#: remains the SDK count; for product `turn`, it is the filtered turn count.
|
||||
time_filtered: bool = False
|
||||
|
||||
|
||||
@@ -4441,6 +4455,16 @@ def create_app(
|
||||
raise _sdk_http_error(error, "hy_memory_delete_failed") from None
|
||||
return Response(status_code=204)
|
||||
|
||||
raw_turn_cache: dict[
|
||||
tuple[object, ...],
|
||||
tuple[
|
||||
int,
|
||||
tuple[tuple[str, str | None], ...],
|
||||
tuple[tuple[HySdkRawRecord, RawDisplayTurn], ...],
|
||||
],
|
||||
] = {}
|
||||
raw_turn_cache_locks: dict[tuple[object, ...], asyncio.Lock] = {}
|
||||
|
||||
@app.get(
|
||||
"/api/v2/raw",
|
||||
response_model=SdkRawPage,
|
||||
@@ -4450,6 +4474,7 @@ def create_app(
|
||||
source: list[str] | None = Query(default=None), # noqa: B008
|
||||
offset: int = Query(default=0, ge=0),
|
||||
limit: int = Query(default=100, ge=1, le=500),
|
||||
unit: Literal["raw", "turn"] = Query(default="raw"),
|
||||
user_id: str = Query(default=DEFAULT_USER_ID, pattern=USER_ID_PATTERN),
|
||||
exclude_sources: list[str] | None = Query(default=None), # noqa: B008
|
||||
session_id: list[str] | None = Query(default=None), # noqa: B008
|
||||
@@ -4463,7 +4488,10 @@ def create_app(
|
||||
The SDK lists raw only when asked for `layers=["l1_raw"]`, and only
|
||||
active rows unless told otherwise. Pro shadows a raw once extraction
|
||||
succeeds, so a raw page reads active and shadow (`statuses` narrows
|
||||
it). `source` and `session_id` (a conversation) may repeat or be comma
|
||||
it). `unit=turn` projects storage batches into stable product turns;
|
||||
the first read builds an append-only in-memory index and later polls
|
||||
only probe the latest SDK page unless it changed. `source` and
|
||||
`session_id` (a conversation) may repeat or be comma
|
||||
separated. `after`/`before` (unix seconds, against the conversation
|
||||
time, else the stored time) have no SDK list filter: they cut this
|
||||
page in Core and mark it `time_filtered`.
|
||||
@@ -4477,24 +4505,145 @@ def create_app(
|
||||
sessions = split_csv(session_id)
|
||||
try:
|
||||
client = await _sdk_client_handle(app).client()
|
||||
value = await asyncio.to_thread(
|
||||
client.list_memories,
|
||||
user_id=user_id,
|
||||
query = {
|
||||
"user_id": user_id,
|
||||
**raw_list_filters(chosen_statuses),
|
||||
include_sources=sources or None,
|
||||
exclude_sources=excluded or None,
|
||||
session_ids=sessions or None,
|
||||
offset=offset,
|
||||
limit=limit,
|
||||
order=order,
|
||||
)
|
||||
page = HySdkRawPage.from_memory_list(value, limit=limit, offset=offset)
|
||||
"include_sources": sources or None,
|
||||
"exclude_sources": excluded or None,
|
||||
"session_ids": sessions or None,
|
||||
"order": order if unit == "raw" else "desc",
|
||||
}
|
||||
if unit == "raw":
|
||||
value = await asyncio.to_thread(
|
||||
client.list_memories,
|
||||
**query,
|
||||
offset=offset,
|
||||
limit=limit,
|
||||
)
|
||||
page = HySdkRawPage.from_memory_list(value, limit=limit, offset=offset)
|
||||
records = page.raw
|
||||
else:
|
||||
cache_key = (
|
||||
user_id,
|
||||
tuple(sorted(chosen_statuses)),
|
||||
tuple(sorted(sources)),
|
||||
tuple(sorted(excluded)),
|
||||
tuple(sorted(sessions)),
|
||||
)
|
||||
if len(raw_turn_cache_locks) > 64:
|
||||
for stale_key, stale_lock in tuple(raw_turn_cache_locks.items()):
|
||||
if (
|
||||
stale_key not in raw_turn_cache
|
||||
and not stale_lock.locked()
|
||||
):
|
||||
raw_turn_cache_locks.pop(stale_key, None)
|
||||
lock = raw_turn_cache_locks.setdefault(cache_key, asyncio.Lock())
|
||||
async with lock:
|
||||
for _attempt in range(3):
|
||||
value = await asyncio.to_thread(
|
||||
client.list_memories,
|
||||
**query,
|
||||
offset=0,
|
||||
limit=500,
|
||||
)
|
||||
first_page = HySdkRawPage.from_memory_list(
|
||||
value, limit=500, offset=0
|
||||
)
|
||||
signature = tuple(
|
||||
(record.raw_id, record.status) for record in first_page.raw
|
||||
)
|
||||
cached = raw_turn_cache.get(cache_key)
|
||||
if (
|
||||
cached is not None
|
||||
and cached[0] == first_page.total
|
||||
and cached[1] == signature
|
||||
):
|
||||
projected_turns = cached[2]
|
||||
break
|
||||
|
||||
records_list = list(first_page.raw)
|
||||
raw_offset = len(first_page.raw)
|
||||
raw_total = first_page.total
|
||||
while first_page.raw and raw_offset < raw_total:
|
||||
value = await asyncio.to_thread(
|
||||
client.list_memories,
|
||||
**query,
|
||||
offset=raw_offset,
|
||||
limit=500,
|
||||
)
|
||||
raw_page = HySdkRawPage.from_memory_list(
|
||||
value, limit=500, offset=raw_offset
|
||||
)
|
||||
records_list.extend(raw_page.raw)
|
||||
raw_offset += len(raw_page.raw)
|
||||
if not raw_page.raw:
|
||||
break
|
||||
|
||||
verify_value = await asyncio.to_thread(
|
||||
client.list_memories,
|
||||
**query,
|
||||
offset=0,
|
||||
limit=500,
|
||||
)
|
||||
verify = HySdkRawPage.from_memory_list(
|
||||
verify_value, limit=500, offset=0
|
||||
)
|
||||
verify_signature = tuple(
|
||||
(record.raw_id, record.status) for record in verify.raw
|
||||
)
|
||||
if (
|
||||
verify.total != first_page.total
|
||||
or verify_signature != signature
|
||||
):
|
||||
continue
|
||||
projected_turns = tuple(
|
||||
(record, turn)
|
||||
for record in records_list
|
||||
for turn in reversed(raw_display_turns(record))
|
||||
)
|
||||
raw_turn_cache[cache_key] = (
|
||||
first_page.total,
|
||||
signature,
|
||||
projected_turns,
|
||||
)
|
||||
while len(raw_turn_cache) > 32:
|
||||
oldest = next(iter(raw_turn_cache))
|
||||
raw_turn_cache.pop(oldest)
|
||||
old_lock = raw_turn_cache_locks.get(oldest)
|
||||
if old_lock is not None and not old_lock.locked():
|
||||
raw_turn_cache_locks.pop(oldest, None)
|
||||
break
|
||||
else:
|
||||
raise RuntimeError("hy_memory_raw_snapshot_busy")
|
||||
if cache_key not in raw_turn_cache and not lock.locked():
|
||||
raw_turn_cache_locks.pop(cache_key, None)
|
||||
except HTTPException:
|
||||
raise
|
||||
except Exception as error:
|
||||
raise _sdk_http_error(error, "hy_memory_raw_list_failed") from None
|
||||
records = page.raw
|
||||
time_filtered = after is not None or before is not None
|
||||
if unit == "turn":
|
||||
turns = projected_turns
|
||||
if time_filtered:
|
||||
turns = tuple(
|
||||
(record, turn)
|
||||
for record, turn in turns
|
||||
if (at := record.memory_at or record.gmt_created) is not None
|
||||
and (after is None or at >= after)
|
||||
and (before is None or at < before)
|
||||
)
|
||||
if order == "asc":
|
||||
turns = tuple(reversed(turns))
|
||||
selected = turns[offset : offset + limit]
|
||||
return SdkRawPage(
|
||||
user_id=user_id,
|
||||
items=tuple(SdkRawItem.of(record, turn) for record, turn in selected),
|
||||
total=len(turns),
|
||||
offset=offset,
|
||||
limit=limit,
|
||||
unit="turn",
|
||||
time_filtered=time_filtered,
|
||||
)
|
||||
if time_filtered:
|
||||
records = tuple(
|
||||
record
|
||||
@@ -4509,6 +4658,7 @@ def create_app(
|
||||
total=page.total,
|
||||
offset=offset,
|
||||
limit=limit,
|
||||
unit="raw",
|
||||
time_filtered=time_filtered,
|
||||
)
|
||||
|
||||
|
||||
@@ -66,6 +66,8 @@ class SdkContextSearchRequest(ContextSearchRequest, SdkSearchOptions):
|
||||
limit: int = Field(default=10, ge=1, le=20, strict=True)
|
||||
schema_limit: int = Field(default=3, ge=0, le=20, strict=True)
|
||||
intention_limit: int = Field(default=1, ge=0, le=20, strict=True)
|
||||
#: Product callers can request one item per turn instead of SDK raw batches.
|
||||
raw_unit: Literal["raw", "turn"] = "raw"
|
||||
#: Whose memories to search. The App never sends it and reads `local`.
|
||||
user_id: UserId = DEFAULT_USER_ID
|
||||
|
||||
@@ -123,6 +125,9 @@ class SdkRawContextItem(BaseModel):
|
||||
memory_at: datetime | None = None
|
||||
score: float = Field(ge=0, le=1)
|
||||
truncated: bool = False
|
||||
turn_id: str | None = Field(default=None, min_length=1, max_length=96)
|
||||
turn_index: int | None = Field(default=None, ge=0)
|
||||
turn_count: int | None = Field(default=None, ge=1)
|
||||
|
||||
|
||||
class SdkMemoryContextItem(BaseModel):
|
||||
|
||||
@@ -30,6 +30,7 @@ from one_memory.hy_sdk_types import (
|
||||
parse_sdk_search,
|
||||
)
|
||||
from one_memory.imports.storage import EvidenceStore
|
||||
from one_memory.raw_turns import raw_display_turns
|
||||
from one_memory.sdk_search_options import SdkSearchFilters, SearchLane
|
||||
from one_memory.semantic_deadline import SemanticMemoryHit
|
||||
from one_memory.user_scope import owns
|
||||
@@ -120,6 +121,9 @@ def sdk_context_router(dependencies: Sequence[Depends]) -> APIRouter:
|
||||
sources = raw_lane_sources(authority.raw_sources, value.include_sources)
|
||||
if sources is None:
|
||||
return ()
|
||||
candidate_limit = (
|
||||
min(value.limit * 10, 200) if value.raw_unit == "turn" else value.limit
|
||||
)
|
||||
raw = await asyncio.to_thread(
|
||||
client.search,
|
||||
value.query,
|
||||
@@ -127,7 +131,7 @@ def sdk_context_router(dependencies: Sequence[Depends]) -> APIRouter:
|
||||
"user_ids": [value.user_id],
|
||||
"agent_ids": [],
|
||||
"include_sources": sources,
|
||||
"limit": value.limit,
|
||||
"limit": candidate_limit,
|
||||
"min_score": 0.0,
|
||||
"schema_min_score": 0.0,
|
||||
"schema_limit": 0,
|
||||
@@ -135,27 +139,59 @@ def sdk_context_router(dependencies: Sequence[Depends]) -> APIRouter:
|
||||
"scene": "chat",
|
||||
"reader": HY_MEMORY_READER,
|
||||
"layers": ["l1_raw"],
|
||||
"statuses": ["active", "shadow"],
|
||||
**raw_lane_overrides(value),
|
||||
},
|
||||
)
|
||||
hits = tuple(
|
||||
hit for hit in parse_sdk_raw_search(raw) if value.passes_floor("raw", hit.score)
|
||||
)
|
||||
return tuple(
|
||||
SdkRawContextItem(
|
||||
raw_id=hit.raw_id,
|
||||
content=hit.content,
|
||||
source=hit.source,
|
||||
session_id=hit.session_id,
|
||||
memory_at=(
|
||||
datetime.fromtimestamp(hit.memory_at, tz=UTC)
|
||||
if hit.memory_at is not None
|
||||
else None
|
||||
),
|
||||
score=hit.score,
|
||||
)
|
||||
for hit in hits
|
||||
)
|
||||
items: list[SdkRawContextItem] = []
|
||||
for hit in hits:
|
||||
turns = raw_display_turns(hit) if value.raw_unit == "turn" else ()
|
||||
if turns:
|
||||
query = value.query.casefold()
|
||||
lexical = tuple(turn for turn in turns if query in turn.content.casefold())
|
||||
# A batch-level embedding cannot identify which contained
|
||||
# turn matched semantically. Return only turns grounded by
|
||||
# the query text instead of assigning every sibling the
|
||||
# batch score and showing unrelated conversations.
|
||||
for turn in lexical:
|
||||
items.append(
|
||||
SdkRawContextItem(
|
||||
raw_id=hit.raw_id,
|
||||
turn_id=turn.turn_id,
|
||||
turn_index=turn.turn_index,
|
||||
turn_count=turn.turn_count,
|
||||
content=turn.content,
|
||||
source=hit.source,
|
||||
session_id=hit.session_id,
|
||||
memory_at=(
|
||||
datetime.fromtimestamp(hit.memory_at, tz=UTC)
|
||||
if hit.memory_at is not None
|
||||
else None
|
||||
),
|
||||
score=hit.score,
|
||||
)
|
||||
)
|
||||
else:
|
||||
items.append(
|
||||
SdkRawContextItem(
|
||||
raw_id=hit.raw_id,
|
||||
content=hit.content,
|
||||
source=hit.source,
|
||||
session_id=hit.session_id,
|
||||
memory_at=(
|
||||
datetime.fromtimestamp(hit.memory_at, tz=UTC)
|
||||
if hit.memory_at is not None
|
||||
else None
|
||||
),
|
||||
score=hit.score,
|
||||
)
|
||||
)
|
||||
if len(items) >= value.limit:
|
||||
break
|
||||
return tuple(items[: value.limit])
|
||||
|
||||
async def memory_lane() -> tuple[SdkMemoryContextItem, ...]:
|
||||
# lite extracts nothing: its search answers raw turns, which the
|
||||
|
||||
@@ -3261,11 +3261,9 @@ class DurableWriteDrain:
|
||||
"durable_write_drain_job_ceiling_exceeded job_id=%s",
|
||||
job_id,
|
||||
)
|
||||
await in_the_background(
|
||||
self.queue.settle_worker_error,
|
||||
await self._settle_worker_error(
|
||||
job_id,
|
||||
error_code="durable_work_ceiling_exceeded",
|
||||
owner=self.owner,
|
||||
)
|
||||
except Exception as error:
|
||||
logger.error(
|
||||
@@ -3274,11 +3272,9 @@ class DurableWriteDrain:
|
||||
type(error).__name__,
|
||||
)
|
||||
self._record_database_error(error)
|
||||
await in_the_background(
|
||||
self.queue.settle_worker_error,
|
||||
await self._settle_worker_error(
|
||||
job_id,
|
||||
error_code="durable_executor_error",
|
||||
owner=self.owner,
|
||||
)
|
||||
finally:
|
||||
execution.cancel()
|
||||
@@ -3287,6 +3283,36 @@ class DurableWriteDrain:
|
||||
with contextlib.suppress(asyncio.CancelledError, Exception):
|
||||
await task
|
||||
|
||||
async def _settle_worker_error(self, job_id: str, *, error_code: str) -> None:
|
||||
"""Best-effort settlement must not kill the long-lived drain.
|
||||
|
||||
The row still has a durable lease when this fallback runs. If the
|
||||
single SQLite writer is busy, waiting and retrying here only extends
|
||||
the same contention that made the executor fail. Leave the row where
|
||||
it is instead: lease recovery later moves a never-invoked job back to
|
||||
pending and a possibly-invoked job to unknown, preserving the
|
||||
no-blind-retry boundary.
|
||||
|
||||
Only transient lock/busy refusals are deferred. A malformed query or
|
||||
other programming error still escapes to the outer per-job fence.
|
||||
"""
|
||||
try:
|
||||
await in_the_background(
|
||||
self.queue.settle_worker_error,
|
||||
job_id,
|
||||
error_code=error_code,
|
||||
owner=self.owner,
|
||||
)
|
||||
except sqlite3.OperationalError as error:
|
||||
if "locked" not in str(error).lower() and "busy" not in str(error).lower():
|
||||
raise
|
||||
self._sample_database_failure("settle_worker_error", error)
|
||||
logger.warning(
|
||||
"durable_write_worker_settlement_deferred job_id=%s error_type=%s",
|
||||
job_id,
|
||||
type(error).__name__,
|
||||
)
|
||||
|
||||
async def _execute_with_ceiling(self, job_id: str) -> None:
|
||||
ceiling = (
|
||||
await in_the_background(self.work_ceiling)
|
||||
@@ -3324,7 +3350,26 @@ class DurableWriteDrain:
|
||||
finished = {task for task in active if task.done()}
|
||||
active.difference_update(finished)
|
||||
for task in finished:
|
||||
await task
|
||||
try:
|
||||
await task
|
||||
except asyncio.CancelledError:
|
||||
raise
|
||||
except _LeaseRenewalFatalError:
|
||||
# Renewal is the ownership fence. If it is broken,
|
||||
# continuing could let another worker reclaim a job
|
||||
# while this process is still executing it.
|
||||
raise
|
||||
except Exception as error:
|
||||
# A job task is a fault-containment boundary. Its row
|
||||
# retains the durable lease and is recovered according
|
||||
# to hy_invoked; killing this only consumer would strand
|
||||
# every unrelated write behind it.
|
||||
self._record_database_error(error)
|
||||
logger.error(
|
||||
"durable_write_drain_job_task_failed task_name=%s error_type=%s",
|
||||
task.get_name(),
|
||||
type(error).__name__,
|
||||
)
|
||||
if self.budget is not None and work_deadline is not None:
|
||||
now = _time.monotonic()
|
||||
# Always admit the first unit of a cycle even when the
|
||||
@@ -3349,7 +3394,12 @@ class DurableWriteDrain:
|
||||
else ()
|
||||
)
|
||||
for job_id in ids:
|
||||
active.add(asyncio.create_task(self._run_claimed_job(job_id)))
|
||||
active.add(
|
||||
asyncio.create_task(
|
||||
self._run_claimed_job(job_id),
|
||||
name=f"durable-write:{job_id}",
|
||||
)
|
||||
)
|
||||
executed = len(ids)
|
||||
self._database_failure_streaks.pop("claim", None)
|
||||
except asyncio.CancelledError:
|
||||
|
||||
@@ -0,0 +1,62 @@
|
||||
"""Turn-level read projection for SDK raw batches.
|
||||
|
||||
HY keeps several complete conversation turns in one L1 raw node so model
|
||||
writes stay batched. Product readers must not expose that storage batch as
|
||||
one conversation item: this module gives every contained turn a stable
|
||||
identity while retaining the parent raw id for traceability.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import hashlib
|
||||
from dataclasses import dataclass
|
||||
|
||||
from one_memory.hy_sdk_types import HySdkRawRecord, raw_turn_messages
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class RawDisplayTurn:
|
||||
turn_id: str
|
||||
turn_index: int
|
||||
turn_count: int
|
||||
content: str
|
||||
messages: tuple[tuple[str, str], ...]
|
||||
|
||||
|
||||
def raw_display_turns(record: HySdkRawRecord) -> tuple[RawDisplayTurn, ...]:
|
||||
"""Split one stored raw batch into user-opened turns.
|
||||
|
||||
A user message opens a new turn; assistant/tool/system messages stay with
|
||||
that user message until the next user message. Legacy content with no user
|
||||
marker remains one turn so it never disappears from the product.
|
||||
"""
|
||||
messages = raw_turn_messages(record.content)
|
||||
grouped: list[list[tuple[str, str]]] = []
|
||||
for message in messages:
|
||||
role, _ = message
|
||||
has_user = bool(grouped) and any(item[0] == "user" for item in grouped[-1])
|
||||
if not grouped or (role == "user" and has_user):
|
||||
grouped.append([message])
|
||||
else:
|
||||
grouped[-1].append(message)
|
||||
if not grouped:
|
||||
grouped = [[("", record.content)]]
|
||||
|
||||
digest = hashlib.sha256(record.raw_id.encode("utf-8")).hexdigest()
|
||||
count = len(grouped)
|
||||
return tuple(
|
||||
RawDisplayTurn(
|
||||
turn_id=f"raw-turn:{digest}:{index}",
|
||||
turn_index=index,
|
||||
turn_count=count,
|
||||
content=_turn_content(turn),
|
||||
messages=tuple(turn),
|
||||
)
|
||||
for index, turn in enumerate(grouped)
|
||||
)
|
||||
|
||||
|
||||
def _turn_content(messages: list[tuple[str, str]]) -> str:
|
||||
return "\n".join(
|
||||
f"[{role}]: {content}" if role else content for role, content in messages
|
||||
)
|
||||
@@ -4138,6 +4138,71 @@ def test_a_failing_renewal_does_not_kill_the_drain(tmp_path) -> None:
|
||||
assert queue.get("idem-1").state == COMPLETED
|
||||
|
||||
|
||||
def test_a_locked_fallback_settlement_does_not_kill_the_drain(tmp_path, monkeypatch) -> None:
|
||||
"""The incident shape: executor and its fallback both lose the writer.
|
||||
|
||||
The first row keeps its lease for normal reclaim; an unrelated row must
|
||||
still run instead of being stranded behind a dead drain task.
|
||||
"""
|
||||
queue = DurableWriteQueue(tmp_path / "one-memory.sqlite3")
|
||||
first = queue.submit(_request(key="first", source="cursor"))
|
||||
second = queue.submit(_request(key="second", source="codex"))
|
||||
executed: list[str] = []
|
||||
|
||||
async def execute(job_id: str) -> None:
|
||||
executed.append(job_id)
|
||||
if job_id == first.job_id:
|
||||
raise RuntimeError("executor failed")
|
||||
queue.settle(job_id, state=COMPLETED)
|
||||
drain.stop()
|
||||
|
||||
drain = DurableWriteDrain(queue, execute=execute, max_concurrent_sources=1)
|
||||
|
||||
def locked_settlement(*_args, **_kwargs):
|
||||
raise sqlite3.OperationalError("database is locked")
|
||||
|
||||
monkeypatch.setattr(queue, "settle_worker_error", locked_settlement)
|
||||
|
||||
asyncio.run(asyncio.wait_for(drain.run_forever(idle_pause=0.005), 1))
|
||||
|
||||
assert executed == [first.job_id, second.job_id]
|
||||
assert queue.get("first").state == RUNNING
|
||||
assert queue.get("second").state == COMPLETED
|
||||
|
||||
|
||||
def test_an_unexpected_job_task_failure_is_contained_by_the_drain(
|
||||
tmp_path, monkeypatch, caplog
|
||||
) -> None:
|
||||
"""A programming error stays visible without taking down the consumer."""
|
||||
queue = DurableWriteQueue(tmp_path / "one-memory.sqlite3")
|
||||
first = queue.submit(_request(key="first", source="cursor"))
|
||||
second = queue.submit(_request(key="second", source="codex"))
|
||||
executed: list[str] = []
|
||||
|
||||
async def run_job(job_id: str) -> None:
|
||||
executed.append(job_id)
|
||||
if job_id == first.job_id:
|
||||
raise RuntimeError("unexpected job task failure")
|
||||
drain.stop()
|
||||
|
||||
drain = DurableWriteDrain(
|
||||
queue,
|
||||
execute=_never_execute,
|
||||
max_concurrent_sources=1,
|
||||
)
|
||||
monkeypatch.setattr(drain, "_run_claimed_job", run_job)
|
||||
|
||||
with caplog.at_level(logging.ERROR, logger="one_memory.memory_server.durable_queue"):
|
||||
asyncio.run(asyncio.wait_for(drain.run_forever(idle_pause=0.005), 1))
|
||||
|
||||
assert executed == [first.job_id, second.job_id]
|
||||
assert any(
|
||||
"durable_write_drain_job_task_failed" in message.getMessage()
|
||||
and "error_type=RuntimeError" in message.getMessage()
|
||||
for message in caplog.records
|
||||
)
|
||||
|
||||
|
||||
@pytest.mark.parametrize("terminal", [COMPLETED, FAILED, DEAD])
|
||||
def test_a_terminal_row_is_sticky_against_a_late_settle(tmp_path, terminal: str) -> None:
|
||||
"""A late settle must not overwrite an outcome that already happened.
|
||||
@@ -4980,6 +5045,27 @@ def test_permanent_renewal_error_stops_the_worker(tmp_path, monkeypatch) -> None
|
||||
asyncio.run(drain.run_one())
|
||||
|
||||
|
||||
def test_permanent_renewal_error_stops_the_long_lived_drain(tmp_path, monkeypatch) -> None:
|
||||
queue = DurableWriteQueue(tmp_path / "one-memory.sqlite3")
|
||||
queue.submit(_request())
|
||||
drain = DurableWriteDrain(
|
||||
queue,
|
||||
execute=lambda _job_id: asyncio.sleep(0.03),
|
||||
lease=timedelta(seconds=1),
|
||||
renew_every=timedelta(milliseconds=10),
|
||||
)
|
||||
monkeypatch.setattr(
|
||||
drain,
|
||||
"renew",
|
||||
lambda *_args, **_kwargs: (_ for _ in ()).throw(
|
||||
sqlite3.OperationalError("database schema is corrupt")
|
||||
),
|
||||
)
|
||||
|
||||
with pytest.raises(RuntimeError):
|
||||
asyncio.run(asyncio.wait_for(drain.run_forever(idle_pause=0.005), 1))
|
||||
|
||||
|
||||
def test_lost_renewal_waits_for_execution_before_releasing_source(tmp_path, monkeypatch) -> None:
|
||||
path = tmp_path / "one-memory.sqlite3"
|
||||
queue = DurableWriteQueue(path)
|
||||
|
||||
@@ -42,7 +42,10 @@ RAW_ROWS = {
|
||||
"local": [
|
||||
{
|
||||
"memory_id": "raw-1",
|
||||
"content": "[user]: 我叫费天翔\n[assistant]: 好的,记住了。",
|
||||
"content": (
|
||||
"[user]: 我叫费天翔\n[assistant]: 好的,记住了。"
|
||||
"\n[user]: 我喜欢绿茶\n[assistant]: 也记住了。"
|
||||
),
|
||||
"status": "shadow",
|
||||
"session_id": "s-1",
|
||||
"source": "hermes",
|
||||
@@ -347,6 +350,33 @@ def test_every_context_search_option_reaches_the_sdk_and_absent_ones_keep_the_ol
|
||||
run_with_sdk(tmp_path, monkeypatch, scenario)
|
||||
|
||||
|
||||
@pytest.mark.fresh_sdk
|
||||
def test_raw_context_search_can_return_turns_instead_of_storage_batches(
|
||||
tmp_path, monkeypatch
|
||||
) -> None:
|
||||
async def scenario(app, sdk, client):
|
||||
response = await client.post(
|
||||
"/api/v2/context/search",
|
||||
json={
|
||||
"query": "绿茶",
|
||||
"kinds": ["raw"],
|
||||
"raw_unit": "turn",
|
||||
"limit": 20,
|
||||
},
|
||||
)
|
||||
assert response.status_code == 200, response.text
|
||||
raw_one = [
|
||||
item for item in response.json()["items"] if item["raw_id"] == "raw-1"
|
||||
]
|
||||
assert len(raw_one) == 1
|
||||
assert raw_one[0]["turn_index"] == 1
|
||||
assert "我喜欢绿茶" in raw_one[0]["content"]
|
||||
assert "我叫费天翔" not in raw_one[0]["content"]
|
||||
assert sdk.last("search")["statuses"] == ["active", "shadow"]
|
||||
|
||||
run_with_sdk(tmp_path, monkeypatch, scenario)
|
||||
|
||||
|
||||
@pytest.mark.fresh_sdk
|
||||
def test_every_recall_and_ask_option_reaches_the_sdk(tmp_path, monkeypatch) -> None:
|
||||
async def scenario(app, sdk, client):
|
||||
@@ -574,7 +604,12 @@ def test_raw_lists_active_and_shadow_turns_with_roles_and_hashes(tmp_path, monke
|
||||
assert sent["layers"] == ["l1_raw"] and sent["statuses"] == ["active", "shadow"]
|
||||
first = next(item for item in body["items"] if item["raw_id"] == "raw-1")
|
||||
assert first["status"] == "shadow"
|
||||
assert [message["role"] for message in first["messages"]] == ["user", "assistant"]
|
||||
assert [message["role"] for message in first["messages"]] == [
|
||||
"user",
|
||||
"assistant",
|
||||
"user",
|
||||
"assistant",
|
||||
]
|
||||
assert first["messages"][0]["content"] == "我叫费天翔"
|
||||
assert first["role"] is None
|
||||
expected = hashlib.sha256(first["content"].encode()).hexdigest()
|
||||
@@ -584,6 +619,27 @@ def test_raw_lists_active_and_shadow_turns_with_roles_and_hashes(tmp_path, monke
|
||||
second = next(item for item in body["items"] if item["raw_id"] == "raw-2")
|
||||
assert second["role"] == "user"
|
||||
|
||||
turns = (await client.get("/api/v2/raw", params={"unit": "turn"})).json()
|
||||
assert turns["unit"] == "turn" and turns["total"] == 3
|
||||
first_turns = [item for item in turns["items"] if item["raw_id"] == "raw-1"]
|
||||
assert [item["turn_index"] for item in first_turns] == [1, 0]
|
||||
assert len({item["turn_id"] for item in first_turns}) == 2
|
||||
assert [[message["content"] for message in item["messages"]] for item in first_turns] == [
|
||||
["我喜欢绿茶", "也记住了。"],
|
||||
["我叫费天翔", "好的,记住了。"],
|
||||
]
|
||||
calls_before_refresh = len(sdk.all("list_memories"))
|
||||
await client.get("/api/v2/raw", params={"unit": "turn"})
|
||||
assert len(sdk.all("list_memories")) == calls_before_refresh + 1
|
||||
|
||||
ascending = (
|
||||
await client.get("/api/v2/raw", params={"unit": "turn", "order": "asc"})
|
||||
).json()
|
||||
ascending_first = [
|
||||
item for item in ascending["items"] if item["raw_id"] == "raw-1"
|
||||
]
|
||||
assert [item["turn_index"] for item in ascending_first] == [0, 1]
|
||||
|
||||
await client.get(
|
||||
"/api/v2/raw",
|
||||
params=[("source", "hermes,codex"), ("session_id", "s-1"), ("order", "asc")],
|
||||
|
||||
@@ -1235,7 +1235,9 @@ it("shows lite raw ingestion as running even when model sedimentation is paused"
|
||||
/>,
|
||||
);
|
||||
|
||||
expect(await screen.findAllByText("正在保存原始对话")).toHaveLength(2);
|
||||
expect(await screen.findAllByText("正在保存")).toHaveLength(2);
|
||||
expect(screen.getByText("暂无更新")).toBeTruthy();
|
||||
expect(screen.getByText("已核对,暂无可显示的原始对话更新")).toBeTruthy();
|
||||
expect(screen.getByText("原始对话持续保存,不需要生成模型")).toBeTruthy();
|
||||
expect(screen.queryByText("已暂停")).toBeNull();
|
||||
});
|
||||
|
||||
@@ -6,7 +6,11 @@ import type {
|
||||
} from "@one-memory/contracts";
|
||||
import { cleanup, render, screen } from "@testing-library/react";
|
||||
import { afterEach, describe, expect, it } from "vitest";
|
||||
import { MemoryGraph3D } from "./MemoryGraph3D";
|
||||
import {
|
||||
graphNodeTooltip,
|
||||
isGraphNodeSelectable,
|
||||
MemoryGraph3D,
|
||||
} from "./MemoryGraph3D";
|
||||
|
||||
afterEach(cleanup);
|
||||
|
||||
@@ -48,6 +52,18 @@ function makeGraph(nodeCount: number): MemoryGraphResponse {
|
||||
}
|
||||
|
||||
describe("MemoryGraph3D", () => {
|
||||
it("keeps topic annotations hover-only", () => {
|
||||
const topic = {
|
||||
...makeGraph(1).nodes[0],
|
||||
node_type: "topic" as const,
|
||||
label: "CI",
|
||||
};
|
||||
expect(isGraphNodeSelectable(topic)).toBe(false);
|
||||
expect(graphNodeTooltip(topic, "主题:")).toBe("主题:CI");
|
||||
expect(graphNodeTooltip(topic, "Topic: ")).toBe("Topic: CI");
|
||||
expect(isGraphNodeSelectable({ ...topic, node_type: "memory" })).toBe(true);
|
||||
});
|
||||
|
||||
it("没有 WebGL 时给出可访问降级提示", () => {
|
||||
render(
|
||||
<MemoryGraph3D
|
||||
|
||||
@@ -27,6 +27,20 @@ const SELECT_DURATION_MS = 180;
|
||||
const SELECTED_COLOR = new THREE.Color(0x294355);
|
||||
const SELECTED_EMISSIVE = new THREE.Color(0x607e97);
|
||||
|
||||
/** Topic nodes explain the graph; unlike memories and sources, they do not own a detail view. */
|
||||
export function isGraphNodeSelectable(node: MemoryGraphNode): boolean {
|
||||
return node.node_type !== "topic";
|
||||
}
|
||||
|
||||
export function graphNodeTooltip(
|
||||
node: MemoryGraphNode,
|
||||
topicPrefix: string,
|
||||
): string {
|
||||
return node.node_type === "topic"
|
||||
? `${topicPrefix}${node.label}`
|
||||
: node.label;
|
||||
}
|
||||
|
||||
export function MemoryGraph3D({
|
||||
graph,
|
||||
selectedNodeId,
|
||||
@@ -36,10 +50,20 @@ export function MemoryGraph3D({
|
||||
selectedNodeId: string | null;
|
||||
onSelectNode(node: MemoryGraphNode): void;
|
||||
}) {
|
||||
const { text } = useMemoryLanguage();
|
||||
const { locale, text } = useMemoryLanguage();
|
||||
const host = useRef<HTMLDivElement>(null);
|
||||
const labelHost = useRef<HTMLDivElement>(null);
|
||||
const selectRef = useRef(onSelectNode);
|
||||
const nodesByIdRef = useRef(
|
||||
new Map(graph.nodes.map((node) => [node.node_id, node])),
|
||||
);
|
||||
nodesByIdRef.current = new Map(
|
||||
graph.nodes.map((node) => [node.node_id, node]),
|
||||
);
|
||||
const topicPrefixRef = useRef("");
|
||||
topicPrefixRef.current = `${text("主题")}${
|
||||
locale.startsWith("zh") || locale.startsWith("ja") ? ":" : ": "
|
||||
}`;
|
||||
// 选中态通过 ref 传入渲染循环,而不是进 useEffect 依赖。
|
||||
// 依赖里带 selectedNodeId 会导致每次点击重建整个场景、重跑布局、重建全部 mesh。
|
||||
const selectedRef = useRef(selectedNodeId);
|
||||
@@ -157,9 +181,7 @@ export function MemoryGraph3D({
|
||||
}
|
||||
}
|
||||
const labels = graph.nodes
|
||||
.filter(
|
||||
(node) => node.node_type === "source" || node.node_type === "topic",
|
||||
)
|
||||
.filter((node) => node.node_type === "source")
|
||||
.slice(0, 4)
|
||||
.map((node) => {
|
||||
const element = document.createElement("span");
|
||||
@@ -194,7 +216,7 @@ export function MemoryGraph3D({
|
||||
}
|
||||
};
|
||||
|
||||
const selectable: THREE.Mesh[] = [];
|
||||
const hittable: THREE.Mesh[] = [];
|
||||
const materials = new Set<THREE.Material>();
|
||||
const geometryByType = {
|
||||
source: new THREE.SphereGeometry(0.24, 18, 12),
|
||||
@@ -237,7 +259,7 @@ export function MemoryGraph3D({
|
||||
mesh.scale.setScalar(1);
|
||||
mesh.visible = returning || extending;
|
||||
scene.add(mesh);
|
||||
selectable.push(mesh);
|
||||
hittable.push(mesh);
|
||||
visuals.push({
|
||||
mesh,
|
||||
material,
|
||||
@@ -298,7 +320,6 @@ export function MemoryGraph3D({
|
||||
});
|
||||
}
|
||||
|
||||
const nodesById = new Map(graph.nodes.map((node) => [node.node_id, node]));
|
||||
const raycaster = new THREE.Raycaster();
|
||||
const pointer = new THREE.Vector2();
|
||||
const hitAt = (event: PointerEvent) => {
|
||||
@@ -307,7 +328,7 @@ export function MemoryGraph3D({
|
||||
pointer.x = ((event.clientX - bounds.left) / bounds.width) * 2 - 1;
|
||||
pointer.y = -((event.clientY - bounds.top) / bounds.height) * 2 + 1;
|
||||
raycaster.setFromCamera(pointer, camera);
|
||||
return raycaster.intersectObjects(selectable, false)[0];
|
||||
return raycaster.intersectObjects(hittable, false)[0];
|
||||
};
|
||||
let pressed: { x: number; y: number; id: number; moved: boolean } | null =
|
||||
null;
|
||||
@@ -333,8 +354,9 @@ export function MemoryGraph3D({
|
||||
)
|
||||
return;
|
||||
const nodeId = hitAt(event)?.object.userData.nodeId;
|
||||
const node = typeof nodeId === "string" ? nodesById.get(nodeId) : null;
|
||||
if (node) selectRef.current(node);
|
||||
const node =
|
||||
typeof nodeId === "string" ? nodesByIdRef.current.get(nodeId) : null;
|
||||
if (node && isGraphNodeSelectable(node)) selectRef.current(node);
|
||||
};
|
||||
const hoverNode = (event: PointerEvent) => {
|
||||
if (pressed) {
|
||||
@@ -344,12 +366,18 @@ export function MemoryGraph3D({
|
||||
return;
|
||||
}
|
||||
const nodeId = hitAt(event)?.object.userData.nodeId;
|
||||
const node = typeof nodeId === "string" ? nodesById.get(nodeId) : null;
|
||||
renderer.domElement.style.cursor = node ? "pointer" : "grab";
|
||||
const node =
|
||||
typeof nodeId === "string" ? nodesByIdRef.current.get(nodeId) : null;
|
||||
renderer.domElement.style.cursor =
|
||||
node && isGraphNodeSelectable(node)
|
||||
? "pointer"
|
||||
: node
|
||||
? "default"
|
||||
: "grab";
|
||||
setHovered(
|
||||
node
|
||||
? {
|
||||
label: node.label,
|
||||
label: graphNodeTooltip(node, topicPrefixRef.current),
|
||||
x: event.clientX - element.getBoundingClientRect().left,
|
||||
y: event.clientY - element.getBoundingClientRect().top,
|
||||
}
|
||||
@@ -629,17 +657,23 @@ export function MemoryGraph3D({
|
||||
{!available ? (
|
||||
<div className="memory-graph-fallback">
|
||||
<p>{text("这台设备打不开 3D 视图,已显示关联列表。")}</p>
|
||||
{graph.nodes.map((node) => (
|
||||
<button
|
||||
type="button"
|
||||
className="secondary"
|
||||
key={node.node_id}
|
||||
aria-pressed={selectedNodeId === node.node_id}
|
||||
onClick={() => onSelectNode(node)}
|
||||
>
|
||||
{node.label}
|
||||
</button>
|
||||
))}
|
||||
{graph.nodes.map((node) =>
|
||||
isGraphNodeSelectable(node) ? (
|
||||
<button
|
||||
type="button"
|
||||
className="secondary"
|
||||
key={node.node_id}
|
||||
aria-pressed={selectedNodeId === node.node_id}
|
||||
onClick={() => onSelectNode(node)}
|
||||
>
|
||||
{node.label}
|
||||
</button>
|
||||
) : (
|
||||
<span className="memory-graph-fallback-topic" key={node.node_id}>
|
||||
{graphNodeTooltip(node, topicPrefixRef.current)}
|
||||
</span>
|
||||
),
|
||||
)}
|
||||
</div>
|
||||
) : null}
|
||||
{available && (
|
||||
|
||||
@@ -149,7 +149,7 @@ 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 () => {
|
||||
it("keeps topic annotations out of the inspector and opens detail-bearing graph nodes", async () => {
|
||||
const onOpen = vi.fn();
|
||||
const topicGraph = {
|
||||
total_memories: 2,
|
||||
@@ -221,13 +221,13 @@ it("replaces a topic detail with an off-page graph memory detail", async () => {
|
||||
/>,
|
||||
);
|
||||
|
||||
await act(async () => {});
|
||||
onOpen.mockClear();
|
||||
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();
|
||||
expect(onOpen).not.toHaveBeenCalled();
|
||||
expect(screen.queryByRole("heading", { name: "git" })).toBeNull();
|
||||
expect(screen.getByRole("heading", { name: "第一条" })).toBeTruthy();
|
||||
expect(screen.getByTestId("canvas").dataset.selected).toBe("memory:m0");
|
||||
await userEvent.click(
|
||||
screen.getByRole("button", { name: "图中选择 先验证,再推送" }),
|
||||
);
|
||||
|
||||
@@ -684,6 +684,9 @@ export function MemoryMap(props: Props) {
|
||||
setGroupKey(key);
|
||||
}
|
||||
function pickNode(node: MemoryGraphNode) {
|
||||
// Topics are graph annotations, not detail-bearing records. They remain
|
||||
// hoverable in the canvas but must not replace the memory inspector.
|
||||
if (node.node_type === "topic") return;
|
||||
if (node.memory_id) {
|
||||
pick(node.memory_id);
|
||||
return;
|
||||
@@ -708,11 +711,6 @@ export function MemoryMap(props: Props) {
|
||||
if (first) pick(first, false);
|
||||
}
|
||||
}
|
||||
if (node.node_type === "topic") {
|
||||
if (props.simplified) return;
|
||||
setAxis("topic");
|
||||
setGroupKey(`topic:${node.label}`);
|
||||
}
|
||||
}
|
||||
const label =
|
||||
group?.label ??
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
// @vitest-environment jsdom
|
||||
|
||||
import { legacyMemoryModePlan } from "@one-memory/operations";
|
||||
import {
|
||||
cleanup,
|
||||
render,
|
||||
@@ -64,31 +65,9 @@ function prepare(mode: "lite" | "pro" | "ultra" = "pro") {
|
||||
running: [],
|
||||
});
|
||||
vi.mocked(getMemoryProcessing).mockResolvedValue({} as never);
|
||||
vi.mocked(planMemoryMode).mockImplementation(async (target) => ({
|
||||
current: mode,
|
||||
target,
|
||||
effective_target: target,
|
||||
capabilities: {
|
||||
content_plane: target === "lite" ? "raw" : "memory",
|
||||
generation_required: target !== "lite",
|
||||
graph_enabled: target === "ultra",
|
||||
evolve_available: target === "ultra",
|
||||
},
|
||||
effects: [
|
||||
...(target === "lite" ? (["higher_mode_memories_hidden"] as const) : []),
|
||||
...(mode === "lite" && target !== "lite"
|
||||
? (["earlier_raw_not_extracted", "lite_search_stops"] as const)
|
||||
: []),
|
||||
...(target === "ultra" && mode !== "ultra"
|
||||
? (["existing_memories_added_to_graph"] as const)
|
||||
: []),
|
||||
...(mode === "ultra" && target !== "ultra"
|
||||
? (["evolve_stopped"] as const)
|
||||
: []),
|
||||
],
|
||||
warnings: [],
|
||||
blockers: [],
|
||||
}));
|
||||
vi.mocked(planMemoryMode).mockImplementation(async (target) =>
|
||||
legacyMemoryModePlan(mode, target, { hasActiveModel: true }),
|
||||
);
|
||||
}
|
||||
|
||||
async function chooseMode(
|
||||
@@ -227,6 +206,20 @@ it("uses the isolated legacy plan only for a route-level old-Core 404", async ()
|
||||
expect(await screen.findByText(/已有记忆继续保留/)).toBeTruthy();
|
||||
});
|
||||
|
||||
it("keeps downgrade effects when an old Core falls back from ultra to pro", async () => {
|
||||
prepare("ultra");
|
||||
vi.mocked(planMemoryMode).mockRejectedValue(new Error("404"));
|
||||
vi.mocked(memoryModePlanUnsupported).mockReturnValue(true);
|
||||
const user = userEvent.setup();
|
||||
render(<MemoryModeSettings hasActiveModel onConfigureModel={vi.fn()} />);
|
||||
|
||||
await chooseMode(user, /智能记忆(pro)/);
|
||||
|
||||
const dialog = screen.getByRole("dialog");
|
||||
expect(within(dialog).getAllByText("已有数据")).toHaveLength(1);
|
||||
expect(within(dialog).getByText("每天自动整合")).toBeTruthy();
|
||||
});
|
||||
|
||||
it("explains model cost, search scope and the next step before switching to ultra", async () => {
|
||||
prepare("lite");
|
||||
const user = userEvent.setup();
|
||||
@@ -238,6 +231,8 @@ it("explains model cost, search scope and the next step before switching to ultr
|
||||
expect(screen.getByText("搜索范围")).toBeTruthy();
|
||||
expect(screen.getByText("切换后")).toBeTruthy();
|
||||
expect(screen.getByText(/已经处理完的旧对话不会补做/)).toBeTruthy();
|
||||
expect(screen.getByText("知识图谱")).toBeTruthy();
|
||||
expect(screen.getAllByText("已有数据")).toHaveLength(1);
|
||||
expect(screen.getByText(/切换本身不会立即整合/)).toBeTruthy();
|
||||
});
|
||||
|
||||
@@ -248,9 +243,27 @@ it("distinguishes regular memories from higher-level Ultra data when switching t
|
||||
|
||||
await chooseMode(user, /智能记忆(pro)/);
|
||||
|
||||
expect(screen.getByText(/普通沉淀记忆仍可使用/)).toBeTruthy();
|
||||
expect(screen.getByText(/更高层记忆和图谱数据继续保留/)).toBeTruthy();
|
||||
expect(screen.getByText(/每天自动整合会停止/)).toBeTruthy();
|
||||
const dialog = screen.getByRole("dialog");
|
||||
expect(within(dialog).getAllByText("已有数据")).toHaveLength(1);
|
||||
expect(within(dialog).getByText(/普通沉淀记忆仍可使用/)).toBeTruthy();
|
||||
expect(within(dialog).getByText(/更高层记忆和图谱数据继续保留/)).toBeTruthy();
|
||||
expect(within(dialog).getByText("每天自动整合")).toBeTruthy();
|
||||
expect(
|
||||
within(dialog).getByText("每天自动整合会停止;已生成的整合结果继续保留。"),
|
||||
).toBeTruthy();
|
||||
});
|
||||
|
||||
it("does not show automatic consolidation when switching from lite to pro", async () => {
|
||||
prepare("lite");
|
||||
const user = userEvent.setup();
|
||||
render(<MemoryModeSettings hasActiveModel onConfigureModel={vi.fn()} />);
|
||||
|
||||
await chooseMode(user, /智能记忆(pro)/);
|
||||
|
||||
const dialog = screen.getByRole("dialog");
|
||||
expect(within(dialog).getAllByText("已有数据")).toHaveLength(1);
|
||||
expect(within(dialog).getByText(/已经处理完的旧对话不会补做/)).toBeTruthy();
|
||||
expect(within(dialog).queryByText("每天自动整合")).toBeNull();
|
||||
});
|
||||
|
||||
it("focuses the Evolve settings after switching to ultra", async () => {
|
||||
|
||||
@@ -85,18 +85,20 @@ function planRows(plan: MemoryModePlan) {
|
||||
if (plan.effects.includes("higher_mode_memories_hidden"))
|
||||
rows.push({
|
||||
label: "已有数据",
|
||||
content: "已有记忆继续保留,但高级记忆在当前模式下不显示、不参与召回。",
|
||||
content:
|
||||
plan.effective_target === "pro"
|
||||
? "普通沉淀记忆仍可使用;深度记忆新增的更高层记忆和图谱数据继续保留,但在智能记忆下不显示、不参与召回。原始对话不会删除。"
|
||||
: "已有记忆继续保留,但在基础记忆下不显示、不参与召回;原始对话不会删除。",
|
||||
});
|
||||
if (plan.effects.includes("existing_memories_added_to_graph"))
|
||||
rows.push({
|
||||
label: "已有数据",
|
||||
label: "知识图谱",
|
||||
content: "已有记忆会在后台加入知识图谱,期间仍可使用。",
|
||||
});
|
||||
if (plan.effects.includes("evolve_stopped"))
|
||||
rows.push({
|
||||
label: "已有数据",
|
||||
content:
|
||||
"普通沉淀记忆仍可使用;更高层记忆和图谱数据继续保留,但不参与当前召回;每天自动整合会停止。",
|
||||
label: "每天自动整合",
|
||||
content: "每天自动整合会停止,已经生成的数据不会删除。",
|
||||
});
|
||||
if (plan.capabilities.evolve_available)
|
||||
rows.push({
|
||||
|
||||
@@ -387,6 +387,67 @@ it("pages and filters the raw conversation data plane in lite", async () => {
|
||||
);
|
||||
});
|
||||
|
||||
it("selects and renders one turn when two turns share a raw storage batch", async () => {
|
||||
setup();
|
||||
reads.processing.mockResolvedValue(modeProcessing("lite"));
|
||||
reads.raw.mockResolvedValue({
|
||||
items: [
|
||||
{
|
||||
raw_id: "raw-batch",
|
||||
turn_id: "raw-turn:batch:0",
|
||||
turn_index: 0,
|
||||
turn_count: 2,
|
||||
content: "[user]: 第一问\n[assistant]: 第一答",
|
||||
user_id: "local",
|
||||
agent_id: "all-apps",
|
||||
source: "cursor",
|
||||
session_id: "session-batch",
|
||||
memory_at: null,
|
||||
gmt_created: 1_761_000_000,
|
||||
custom: {},
|
||||
messages: [
|
||||
{ role: "user", content: "第一问" },
|
||||
{ role: "assistant", content: "第一答" },
|
||||
],
|
||||
},
|
||||
{
|
||||
raw_id: "raw-batch",
|
||||
turn_id: "raw-turn:batch:1",
|
||||
turn_index: 1,
|
||||
turn_count: 2,
|
||||
content: "[user]: 第二问\n[assistant]: 第二答",
|
||||
user_id: "local",
|
||||
agent_id: "all-apps",
|
||||
source: "cursor",
|
||||
session_id: "session-batch",
|
||||
memory_at: null,
|
||||
gmt_created: 1_761_000_000,
|
||||
custom: {},
|
||||
messages: [
|
||||
{ role: "user", content: "第二问" },
|
||||
{ role: "assistant", content: "第二答" },
|
||||
],
|
||||
},
|
||||
],
|
||||
total: 2,
|
||||
limit: 20,
|
||||
offset: 0,
|
||||
unit: "turn",
|
||||
});
|
||||
const user = userEvent.setup();
|
||||
render(<MemorySpace simplified />);
|
||||
|
||||
await user.click(await screen.findByRole("button", { name: /第二问/ }));
|
||||
|
||||
await waitFor(() => {
|
||||
const detail = document.querySelector(".map-dialogue-reading");
|
||||
expect(detail?.textContent).toContain("第二问");
|
||||
expect(detail?.textContent).toContain("第二答");
|
||||
expect(detail?.textContent).not.toContain("第一问");
|
||||
expect(detail?.textContent).not.toContain("第一答");
|
||||
});
|
||||
});
|
||||
|
||||
it("switches data planes without keeping rows or details from the prior mode", async () => {
|
||||
setup();
|
||||
vi.useFakeTimers();
|
||||
@@ -482,6 +543,27 @@ it("opens a search hit with the complete role transcript", async () => {
|
||||
items: [
|
||||
{
|
||||
raw_id: "raw-search",
|
||||
turn_id: "raw-turn:search:0",
|
||||
turn_index: 0,
|
||||
turn_count: 2,
|
||||
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: "第一轮回答" },
|
||||
],
|
||||
},
|
||||
{
|
||||
raw_id: "raw-search",
|
||||
turn_id: "raw-turn:search:1",
|
||||
turn_index: 1,
|
||||
turn_count: 2,
|
||||
content: "[user]: 完整问题\n[assistant]: 完整回答",
|
||||
user_id: "local",
|
||||
agent_id: "all-apps",
|
||||
@@ -496,7 +578,7 @@ it("opens a search hit with the complete role transcript", async () => {
|
||||
],
|
||||
},
|
||||
],
|
||||
total: 1,
|
||||
total: 2,
|
||||
limit: 500,
|
||||
offset: 0,
|
||||
}
|
||||
@@ -506,6 +588,9 @@ it("opens a search hit with the complete role transcript", async () => {
|
||||
{
|
||||
kind: "raw",
|
||||
raw_id: "raw-search",
|
||||
turn_id: "raw-turn:search:1",
|
||||
turn_index: 1,
|
||||
turn_count: 2,
|
||||
content: "被截断的搜索摘要",
|
||||
source: "cursor",
|
||||
session_id: "session-search",
|
||||
@@ -525,6 +610,9 @@ it("opens a search hit with the complete role transcript", async () => {
|
||||
|
||||
expect(await screen.findByText("完整问题")).toBeTruthy();
|
||||
expect(screen.getByText("完整回答")).toBeTruthy();
|
||||
expect(
|
||||
document.querySelector(".map-dialogue-reading")?.textContent,
|
||||
).not.toContain("第一轮问题");
|
||||
expect(reads.raw).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
source: "cursor",
|
||||
|
||||
@@ -61,6 +61,7 @@ import {
|
||||
type RawMapRecord,
|
||||
rawDetail,
|
||||
rawLibraryPage,
|
||||
rawRecordId,
|
||||
rawSearchPage,
|
||||
} from "./sdk-raw-map";
|
||||
import { sourceNameOr, UNATTRIBUTED_SOURCE_ID } from "./source-label";
|
||||
@@ -277,8 +278,11 @@ export function MemorySpace({
|
||||
const rememberRaw = (items: RawMapRecord[]) => {
|
||||
const next = new Map<string, RawMapRecord>();
|
||||
if (selectedRawRef.current)
|
||||
next.set(selectedRawRef.current.raw_id, selectedRawRef.current);
|
||||
for (const item of items) next.set(item.raw_id, item);
|
||||
next.set(
|
||||
rawRecordId(selectedRawRef.current),
|
||||
selectedRawRef.current,
|
||||
);
|
||||
for (const item of items) next.set(rawRecordId(item), item);
|
||||
rawRecords.current = next;
|
||||
};
|
||||
const [page, graph] = lite
|
||||
@@ -717,7 +721,9 @@ export function MemorySpace({
|
||||
offset,
|
||||
limit: 500,
|
||||
});
|
||||
const full = page.items.find((item) => item.raw_id === memoryId);
|
||||
const full = page.items.find(
|
||||
(item) => rawRecordId(item) === memoryId,
|
||||
);
|
||||
if (full) {
|
||||
record = full;
|
||||
rawRecords.current.set(memoryId, full);
|
||||
|
||||
@@ -0,0 +1,22 @@
|
||||
import { readFileSync } from "node:fs";
|
||||
import { expect, it } from "vitest";
|
||||
|
||||
function declaration(file: string, selector: string): string {
|
||||
const css = readFileSync(new URL(`./${file}`, import.meta.url), "utf8");
|
||||
const escaped = selector.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
|
||||
return css.match(new RegExp(`${escaped}\\s*\\{([^}]*)\\}`))?.[1] ?? "";
|
||||
}
|
||||
|
||||
it("fills settings controls without widening shared fallback controls", () => {
|
||||
for (const selector of [
|
||||
".r6-memory-mode-settings .memory-mode-choice-list",
|
||||
".r6-settings .connection-fallback .custom-harness-name",
|
||||
]) {
|
||||
const rule = declaration("r6-settings.css", selector);
|
||||
expect(rule, selector).toMatch(/(?:^|[\s;])width:\s*100%\s*;/);
|
||||
expect(rule, selector).toMatch(/(?:^|[\s;])max-width:\s*none\s*;/);
|
||||
}
|
||||
expect(declaration("core-visual.css", ".custom-harness-name")).toMatch(
|
||||
/(?:^|[\s;])max-width:\s*420px\s*;/,
|
||||
);
|
||||
});
|
||||
@@ -3,6 +3,7 @@ import { type FormEvent, useEffect, useRef, useState } from "react";
|
||||
import { getSdkRaw, searchSdkRaw } from "./api";
|
||||
import { useGuiLanguage } from "./gui-language";
|
||||
import { subscribePoll } from "./poll-scheduler";
|
||||
import { rawRecordId } from "./sdk-raw-map";
|
||||
|
||||
export function SdkRawHistory({ active = true }: { active?: boolean }) {
|
||||
const { locale, t, tn } = useGuiLanguage();
|
||||
@@ -121,7 +122,7 @@ export function SdkRawHistory({ active = true }: { active?: boolean }) {
|
||||
) : null}
|
||||
<ol>
|
||||
{items.map((item) => (
|
||||
<li key={item.raw_id}>
|
||||
<li key={rawRecordId(item)}>
|
||||
<p>{item.content}</p>
|
||||
<small>
|
||||
{item.source} · {item.session_id}
|
||||
@@ -140,6 +141,9 @@ function asContextItem(item: SdkRawRecord): SdkRawContextItem {
|
||||
return {
|
||||
kind: "raw",
|
||||
raw_id: item.raw_id,
|
||||
turn_id: item.turn_id,
|
||||
turn_index: item.turn_index,
|
||||
turn_count: item.turn_count,
|
||||
content: item.content,
|
||||
source: item.source,
|
||||
session_id: item.session_id,
|
||||
|
||||
@@ -5,7 +5,7 @@ import type {
|
||||
} from "@one-memory/contracts";
|
||||
import { clockStamp } from "./clock-stamp";
|
||||
import { useGuiLanguage } from "./gui-language";
|
||||
import { rawRecordTime } from "./sdk-raw-map";
|
||||
import { rawRecordId, rawRecordTime } from "./sdk-raw-map";
|
||||
import { sourceNameOr, UNATTRIBUTED_SOURCE_ID } from "./source-label";
|
||||
|
||||
type RawRecord = SdkRawRecord | SdkRawContextItem;
|
||||
@@ -50,7 +50,7 @@ export function SelectedRawDetail({
|
||||
<p className="selected-memory-status">{t("memory:原始对话")}</p>
|
||||
<ul className="map-transcript">
|
||||
{messages.map((message) => (
|
||||
<li key={`${record.raw_id}:${message.role}:${message.content}`}>
|
||||
<li key={`${rawRecordId(record)}:${message.role}:${message.content}`}>
|
||||
<small>{authorLabel(message.role, t)}</small>
|
||||
<p>{message.content}</p>
|
||||
</li>
|
||||
|
||||
@@ -32,6 +32,7 @@ it("lists raw conversations for the selected application", async () => {
|
||||
session_id: undefined,
|
||||
offset: 20,
|
||||
limit: 20,
|
||||
unit: "turn",
|
||||
},
|
||||
signal,
|
||||
});
|
||||
@@ -49,6 +50,7 @@ it("keeps the application filter when searching raw conversations", async () =>
|
||||
query: "release plan",
|
||||
kinds: ["raw"],
|
||||
include_sources: ["claude-code"],
|
||||
raw_unit: "turn",
|
||||
limit: 20,
|
||||
character_budget: 12_000,
|
||||
},
|
||||
|
||||
@@ -986,16 +986,18 @@ export async function getSdkRaw({
|
||||
sessionId,
|
||||
offset = 0,
|
||||
limit = 100,
|
||||
unit = "turn",
|
||||
signal,
|
||||
}: {
|
||||
source?: string;
|
||||
sessionId?: string;
|
||||
offset?: number;
|
||||
limit?: number;
|
||||
unit?: "raw" | "turn";
|
||||
signal?: AbortSignal;
|
||||
} = {}): Promise<SdkRawPage> {
|
||||
const response = await client.get<SdkRawPage>("/api/v2/raw", {
|
||||
params: { source, session_id: sessionId, offset, limit },
|
||||
params: { source, session_id: sessionId, offset, limit, unit },
|
||||
signal,
|
||||
});
|
||||
return response.data;
|
||||
@@ -1011,6 +1013,7 @@ export async function searchSdkRaw(
|
||||
query,
|
||||
kinds: ["raw"],
|
||||
...(options.source ? { include_sources: [options.source] } : {}),
|
||||
raw_unit: "turn",
|
||||
limit: 20,
|
||||
character_budget: 12_000,
|
||||
},
|
||||
|
||||
@@ -986,6 +986,14 @@ button.map-tool {
|
||||
background: #fff;
|
||||
box-shadow: var(--shadow-pop);
|
||||
}
|
||||
.memory-graph-fallback-topic {
|
||||
display: block;
|
||||
width: 100%;
|
||||
margin-block: 6px;
|
||||
padding: 8px 14px;
|
||||
color: var(--tx2);
|
||||
overflow-wrap: anywhere;
|
||||
}
|
||||
.graph-labels {
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
|
||||
@@ -38,7 +38,7 @@ const base = {
|
||||
|
||||
describe("home view model", () => {
|
||||
it.each([
|
||||
["lite", "raw", "apps.rawConversations", "apps.rawSaving"],
|
||||
["lite", "raw", "apps.rawConversations", "apps.rawSavingCompact"],
|
||||
["pro", "memory", "apps.memories", "apps.running"],
|
||||
["ultra", "memory", "apps.memories", "apps.running"],
|
||||
] as const)(
|
||||
@@ -109,7 +109,7 @@ describe("home view model", () => {
|
||||
} as never,
|
||||
});
|
||||
expect(result.recentMetric).toEqual({
|
||||
valueKey: "apps.rawCheckedEmpty",
|
||||
valueKey: "apps.recentEmptyCompact",
|
||||
detailKey: "apps.checkedRecent",
|
||||
});
|
||||
expect(result.activity).toMatchObject({
|
||||
@@ -158,9 +158,10 @@ describe("home view model", () => {
|
||||
projectionState: "ready",
|
||||
});
|
||||
expect(result.recentMetric).toEqual({
|
||||
valueKey: "apps.memoryCheckedEmpty",
|
||||
valueKey: "apps.recentEmptyCompact",
|
||||
detailKey: "apps.checkedRecent",
|
||||
});
|
||||
expect(result.activity.titleKey).toBe("apps.memoryCheckedEmpty");
|
||||
expect(result.activity.kind).toBe("empty");
|
||||
});
|
||||
|
||||
|
||||
@@ -40,11 +40,7 @@ export interface HomeViewModel {
|
||||
emptyExcerpt: boolean;
|
||||
} | null;
|
||||
recentMetric: {
|
||||
valueKey:
|
||||
| "apps.checkingRecent"
|
||||
| "apps.rawCheckedEmpty"
|
||||
| "apps.memoryCheckedEmpty"
|
||||
| null;
|
||||
valueKey: "apps.checkingRecent" | "apps.recentEmptyCompact" | null;
|
||||
detailKey:
|
||||
| "apps.rawSaved"
|
||||
| "apps.saved"
|
||||
@@ -120,7 +116,7 @@ export function buildHomeViewModel(input: {
|
||||
const sedimenting = input.sedimentation?.enabled;
|
||||
const processingLabel =
|
||||
!generationRequired || mode === "lite"
|
||||
? "apps.rawSaving"
|
||||
? "apps.rawSavingCompact"
|
||||
: sedimenting === true
|
||||
? "apps.running"
|
||||
: sedimenting === false
|
||||
@@ -179,10 +175,7 @@ export function buildHomeViewModel(input: {
|
||||
}
|
||||
: state === "ready" || state === "stale"
|
||||
? {
|
||||
valueKey:
|
||||
plane === "raw"
|
||||
? ("apps.rawCheckedEmpty" as const)
|
||||
: ("apps.memoryCheckedEmpty" as const),
|
||||
valueKey: "apps.recentEmptyCompact" as const,
|
||||
detailKey: "apps.checkedRecent" as const,
|
||||
}
|
||||
: {
|
||||
|
||||
@@ -228,7 +228,8 @@
|
||||
line-height: 22px;
|
||||
}
|
||||
.r6-memory-mode-settings .memory-mode-choice-list {
|
||||
max-width: 760px;
|
||||
width: 100%;
|
||||
max-width: none;
|
||||
}
|
||||
.r6-settings-note {
|
||||
margin: 14px 0 0;
|
||||
@@ -481,7 +482,8 @@
|
||||
text-wrap: pretty;
|
||||
}
|
||||
.r6-settings .connection-fallback .custom-harness-name {
|
||||
max-width: 560px;
|
||||
width: 100%;
|
||||
max-width: none;
|
||||
gap: 8px;
|
||||
margin: 0 0 14px;
|
||||
color: #4f6373;
|
||||
|
||||
@@ -76,4 +76,28 @@ describe("raw map projection", () => {
|
||||
history: [],
|
||||
});
|
||||
});
|
||||
|
||||
it("uses a turn identity while retaining the parent raw id", () => {
|
||||
const turn = {
|
||||
...record,
|
||||
raw_id: "raw-batch",
|
||||
turn_id: "raw-turn:batch:1",
|
||||
turn_index: 1,
|
||||
turn_count: 2,
|
||||
content: "[user]: second question\n[assistant]: second answer",
|
||||
messages: [
|
||||
{ role: "user", content: "second question" },
|
||||
{ role: "assistant", content: "second answer" },
|
||||
],
|
||||
};
|
||||
|
||||
expect(
|
||||
rawLibraryPage({ items: [turn], total: 2, offset: 0, limit: 20 }),
|
||||
).toMatchObject({
|
||||
items: [{ memory_id: "raw-turn:batch:1", content: turn.content }],
|
||||
source_ids_by_memory: { "raw-turn:batch:1": ["cursor"] },
|
||||
});
|
||||
expect(rawDetail(turn).memory.memory_id).toBe("raw-turn:batch:1");
|
||||
expect(turn.raw_id).toBe("raw-batch");
|
||||
});
|
||||
});
|
||||
@@ -9,6 +9,10 @@ import type {
|
||||
|
||||
export type RawMapRecord = SdkRawRecord | SdkRawContextItem;
|
||||
|
||||
export function rawRecordId(record: RawMapRecord): string {
|
||||
return record.turn_id || record.raw_id;
|
||||
}
|
||||
|
||||
const EARLIEST_PLAUSIBLE = Date.UTC(2000, 0, 1);
|
||||
const LATEST_PLAUSIBLE = () => Date.now() + 86_400_000;
|
||||
|
||||
@@ -43,7 +47,7 @@ function sourceOf(record: RawMapRecord): string {
|
||||
export function rawMapMemory(record: RawMapRecord): GovernedMemory {
|
||||
const created = rawRecordTime(record) ?? "";
|
||||
return {
|
||||
memory_id: record.raw_id,
|
||||
memory_id: rawRecordId(record),
|
||||
user_id: "user_id" in record ? record.user_id : "local",
|
||||
kind: "chat",
|
||||
status: "confirmed",
|
||||
@@ -75,17 +79,18 @@ function pageFor(
|
||||
{ at: string; basis: "conversation" | "import" }
|
||||
> = {};
|
||||
for (const record of records) {
|
||||
sourceIds[record.raw_id] = [sourceOf(record)];
|
||||
const recordId = rawRecordId(record);
|
||||
sourceIds[recordId] = [sourceOf(record)];
|
||||
const conversationAt = isoTime(record.memory_at);
|
||||
const importedAt =
|
||||
"gmt_created" in record ? isoTime(record.gmt_created) : "";
|
||||
if (conversationAt)
|
||||
sourceTime[record.raw_id] = {
|
||||
sourceTime[recordId] = {
|
||||
at: conversationAt,
|
||||
basis: "conversation",
|
||||
};
|
||||
else if (importedAt)
|
||||
sourceTime[record.raw_id] = { at: importedAt, basis: "import" };
|
||||
sourceTime[recordId] = { at: importedAt, basis: "import" };
|
||||
}
|
||||
return {
|
||||
items: records.map(rawMapMemory),
|
||||
|
||||
@@ -12,7 +12,7 @@ memory route. `user_id` is optional (default `local`) and must match
|
||||
|
||||
| Mode | SDK client | What happens to a conversation | What search reads |
|
||||
|---|---|---|---|
|
||||
| `lite` | `mode="lite"` | Stored as one raw turn with one embedding. No model call; no generation model needs to be configured. | Raw turns only (the SDK forces `L1_RAW`). |
|
||||
| `lite` | `mode="lite"` | Stored in embedded L1 raw batches. No model call; no generation model needs to be configured. The product page projects each batch back into individual conversation turns. | Raw turns only (the SDK forces `L1_RAW`). |
|
||||
| `pro` (default) | `mode="pro"` | Stored as raw, then a model extracts memories (System 1). The raw becomes SHADOW once extraction succeeds. | Extracted memories (facts, profile, intentions). |
|
||||
| `ultra` | `mode="ultra"` | Same as pro, and new facts are also projected into the knowledge graph. Every night, evolve runs System 2 for every user. | Same as pro, plus the graph-backed lanes below. |
|
||||
|
||||
|
||||
@@ -87,7 +87,12 @@ export interface ContextSearchResponse {
|
||||
|
||||
export interface SdkRawContextItem {
|
||||
kind: "raw";
|
||||
/** Parent SDK L1 raw storage batch. */
|
||||
raw_id: string;
|
||||
/** Stable product identity of one turn inside `raw_id`, when requested. */
|
||||
turn_id?: string | null;
|
||||
turn_index?: number | null;
|
||||
turn_count?: number | null;
|
||||
content: string;
|
||||
source: string;
|
||||
session_id: string;
|
||||
|
||||
@@ -675,7 +675,12 @@ export interface SdkUserProfileResponse {
|
||||
}
|
||||
|
||||
export interface SdkRawRecord {
|
||||
/** Parent SDK L1 raw storage batch. */
|
||||
raw_id: string;
|
||||
/** Stable product identity of one turn inside `raw_id`, when unit=turn. */
|
||||
turn_id?: string | null;
|
||||
turn_index?: number | null;
|
||||
turn_count?: number | null;
|
||||
content: string;
|
||||
user_id: string;
|
||||
agent_id: string;
|
||||
@@ -703,6 +708,7 @@ export interface SdkRawPage {
|
||||
total: number;
|
||||
limit: number;
|
||||
offset: number;
|
||||
unit?: "raw" | "turn";
|
||||
/** A time range was applied to this page in Core (the SDK list has none). */
|
||||
time_filtered?: boolean;
|
||||
}
|
||||
|
||||
@@ -9,6 +9,7 @@ export const applicationsChineseMessages = {
|
||||
"apps.memories": "已沉淀记忆",
|
||||
"apps.rawConversations": "原始对话",
|
||||
"apps.rawSaving": "正在保存原始对话",
|
||||
"apps.rawSavingCompact": "正在保存",
|
||||
"apps.rawSavingStatus": "保存状态",
|
||||
"apps.rawSavingDetail": "原始对话持续保存,不需要生成模型",
|
||||
"apps.rawNoExcerpt": "这条对话没有可显示的用户原文",
|
||||
@@ -17,6 +18,7 @@ export const applicationsChineseMessages = {
|
||||
"apps.rawEmpty": "还没有保存的原始对话",
|
||||
"apps.rawCheckedEmpty": "已核对,暂无可显示的原始对话更新",
|
||||
"apps.memoryCheckedEmpty": "已核对,暂无可显示的记忆更新",
|
||||
"apps.recentEmptyCompact": "暂无更新",
|
||||
"apps.rawWaiting": "原始对话保存后会显示在这里",
|
||||
"apps.checkedRecent": "已核对当前内容",
|
||||
"apps.currentMode": "当前模式",
|
||||
@@ -149,6 +151,7 @@ export const applicationsSpanishMessages: Record<
|
||||
"apps.memories": "Memorias guardadas",
|
||||
"apps.rawConversations": "Conversaciones originales",
|
||||
"apps.rawSaving": "Guardando",
|
||||
"apps.rawSavingCompact": "Guardando",
|
||||
"apps.rawSavingStatus": "Estado de guardado",
|
||||
"apps.rawSavingDetail":
|
||||
"Las conversaciones originales se guardan sin un modelo generativo",
|
||||
@@ -160,6 +163,7 @@ export const applicationsSpanishMessages: Record<
|
||||
"Comprobado: no hay actualizaciones de conversaciones para mostrar",
|
||||
"apps.memoryCheckedEmpty":
|
||||
"Comprobado: no hay actualizaciones de memorias para mostrar",
|
||||
"apps.recentEmptyCompact": "Sin novedades",
|
||||
"apps.rawWaiting":
|
||||
"Las conversaciones originales aparecerán aquí cuando se guarden",
|
||||
"apps.checkedRecent": "Contenido actual comprobado",
|
||||
|
||||
@@ -275,9 +275,10 @@ describe("Mode catalog", () => {
|
||||
"搜索沉淀记忆,并用知识图谱补充关联。",
|
||||
"搜索沉淀后的记忆。",
|
||||
"已经处理完的旧对话不会补做;原始对话全部保留。",
|
||||
"已有记忆继续保留,但高级记忆在当前模式下不显示、不参与召回。",
|
||||
"知识图谱",
|
||||
"普通沉淀记忆仍可使用;深度记忆新增的更高层记忆和图谱数据继续保留,但在智能记忆下不显示、不参与召回。原始对话不会删除。",
|
||||
"已有记忆会在后台加入知识图谱,期间仍可使用。",
|
||||
"普通沉淀记忆仍可使用;更高层记忆和图谱数据继续保留,但不参与当前召回;每天自动整合会停止。",
|
||||
"每天自动整合会停止,已经生成的数据不会删除。",
|
||||
"记忆维护正在进行,完成后才能切换模式。",
|
||||
"模式切换影响暂时无法读取,请重试。",
|
||||
] as const;
|
||||
|
||||
@@ -1345,6 +1345,7 @@ const baseEnglishMessages = {
|
||||
"apps.memories": "Saved memories",
|
||||
"apps.rawConversations": "Original conversations",
|
||||
"apps.rawSaving": "Saving",
|
||||
"apps.rawSavingCompact": "Saving",
|
||||
"apps.rawSavingStatus": "Saving status",
|
||||
"apps.rawSavingDetail":
|
||||
"Original conversations are saved without a generation model",
|
||||
@@ -1355,6 +1356,7 @@ const baseEnglishMessages = {
|
||||
"apps.rawCheckedEmpty":
|
||||
"Checked: no original-conversation updates to display",
|
||||
"apps.memoryCheckedEmpty": "Checked: no memory updates to display",
|
||||
"apps.recentEmptyCompact": "No updates",
|
||||
"apps.rawWaiting": "Original conversations will appear here when saved",
|
||||
"apps.checkedRecent": "Current content checked",
|
||||
"apps.currentMode": "Current mode",
|
||||
|
||||
@@ -24,6 +24,8 @@ const ENGLISH_EQUIVALENT_ALLOWED = new Set<MessageKey>([
|
||||
|
||||
const keys = Object.keys(chineseMessages) as MessageKey[];
|
||||
const rawSavingStatusKey = "apps.rawSavingStatus" satisfies MessageKey;
|
||||
const rawSavingCompactKey = "apps.rawSavingCompact" satisfies MessageKey;
|
||||
const recentEmptyCompactKey = "apps.recentEmptyCompact" satisfies MessageKey;
|
||||
|
||||
const rawSavingStatusTranslations = {
|
||||
en: "Saving status",
|
||||
@@ -39,6 +41,23 @@ const rawSavingStatusTranslations = {
|
||||
id: "Status penyimpanan",
|
||||
} as const satisfies Record<(typeof SUPPORTED_LOCALES)[number], string>;
|
||||
|
||||
const compactHomeTranslations = {
|
||||
en: ["Saving", "No updates"],
|
||||
"zh-Hans": ["正在保存", "暂无更新"],
|
||||
"zh-Hant": ["儲存中", "暫無更新"],
|
||||
ja: ["保存中", "更新なし"],
|
||||
es: ["Guardando", "Sin novedades"],
|
||||
de: ["Wird gespeichert", "Keine Updates"],
|
||||
pt: ["Salvando", "Sem atualizações"],
|
||||
fr: ["Enregistrement", "Aucune mise à jour"],
|
||||
ko: ["저장 중", "업데이트 없음"],
|
||||
ru: ["Сохранение", "Нет обновлений"],
|
||||
id: ["Menyimpan", "Belum ada pembaruan"],
|
||||
} as const satisfies Record<
|
||||
(typeof SUPPORTED_LOCALES)[number],
|
||||
readonly [string, string]
|
||||
>;
|
||||
|
||||
describe.each(SUPPORTED_LOCALES)("%s catalog", (locale) => {
|
||||
const catalog = guiMessages[locale];
|
||||
|
||||
@@ -100,6 +119,14 @@ describe("applications catalog", () => {
|
||||
rawSavingStatusTranslations[locale],
|
||||
);
|
||||
});
|
||||
|
||||
it("keeps large home metric copy compact in all 11 locales", () => {
|
||||
for (const locale of SUPPORTED_LOCALES) {
|
||||
const [saving, empty] = compactHomeTranslations[locale];
|
||||
expect(guiMessages[locale][rawSavingCompactKey]).toBe(saving);
|
||||
expect(guiMessages[locale][recentEmptyCompactKey]).toBe(empty);
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
describe("embedding switch catalog", () => {
|
||||
|
||||
@@ -47,7 +47,7 @@ const modeChineseMessages = {
|
||||
"以前生成的高级记忆会继续保留,但在 lite 下不会显示或参与记忆召回。":
|
||||
"高级记忆会保留,但在 lite 下不显示、不参与召回。",
|
||||
"每天自动整合会停止,已经生成的数据不会删除。":
|
||||
"自动整合会停止,已有数据保留。",
|
||||
"每天自动整合会停止;已生成的整合结果继续保留。",
|
||||
"已有高级记忆会在后台加入知识图谱,期间可以继续使用 MemHub。":
|
||||
"已有高级记忆将在后台加入知识图谱。",
|
||||
"知识图谱仍在补建,不会重复启动。": "知识图谱仍在补建,不会重复启动。",
|
||||
@@ -97,6 +97,7 @@ const modeChineseMessages = {
|
||||
模型调用与费用: "模型调用与费用",
|
||||
搜索范围: "搜索范围",
|
||||
已有数据: "已有数据",
|
||||
知识图谱: "知识图谱",
|
||||
切换后: "切换后",
|
||||
"之后只保存原始对话,不再生成新记忆。":
|
||||
"之后只保存原始对话,不再生成新记忆。",
|
||||
@@ -295,6 +296,7 @@ export const modeEnglishMessages: ModeMessages = {
|
||||
模型调用与费用: "Model calls and cost",
|
||||
搜索范围: "Search scope",
|
||||
已有数据: "Existing data",
|
||||
知识图谱: "Knowledge graph",
|
||||
切换后: "After switching",
|
||||
"之后只保存原始对话,不再生成新记忆。":
|
||||
"New conversations are saved as original text only; no new memories are created.",
|
||||
@@ -498,6 +500,7 @@ export const modeSpanishMessages: ModeMessages = {
|
||||
模型调用与费用: "Llamadas al modelo y coste",
|
||||
搜索范围: "Ámbito de búsqueda",
|
||||
已有数据: "Datos existentes",
|
||||
知识图谱: "Grafo de conocimiento",
|
||||
切换后: "Después del cambio",
|
||||
"之后只保存原始对话,不再生成新记忆。":
|
||||
"Las conversaciones nuevas se guardan solo como texto original; no se crean memorias nuevas.",
|
||||
@@ -693,6 +696,7 @@ export const modeTraditionalChineseMessages: ModeMessages = {
|
||||
模型调用与费用: "模型呼叫與費用",
|
||||
搜索范围: "搜尋範圍",
|
||||
已有数据: "現有資料",
|
||||
知识图谱: "知識圖譜",
|
||||
切换后: "切換後",
|
||||
"之后只保存原始对话,不再生成新记忆。":
|
||||
"之後只儲存原始對話,不再產生新記憶。",
|
||||
@@ -843,6 +847,7 @@ export const modeJapaneseMessages: ModeMessages = {
|
||||
模型调用与费用: "モデル呼び出しと料金",
|
||||
搜索范围: "検索範囲",
|
||||
已有数据: "既存データ",
|
||||
知识图谱: "ナレッジグラフ",
|
||||
切换后: "切り替え後",
|
||||
"之后只保存原始对话,不再生成新记忆。":
|
||||
"以降は元の会話のみを保存し、新しい記憶は生成しません。",
|
||||
@@ -1043,6 +1048,7 @@ export const modeGermanMessages: ModeMessages = {
|
||||
模型调用与费用: "Modellaufrufe und Kosten",
|
||||
搜索范围: "Suchbereich",
|
||||
已有数据: "Vorhandene Daten",
|
||||
知识图谱: "Wissensgraph",
|
||||
切换后: "Nach dem Wechsel",
|
||||
"之后只保存原始对话,不再生成新记忆。":
|
||||
"Neue Unterhaltungen werden nur noch im Original gespeichert; es werden keine neuen Erinnerungen erstellt.",
|
||||
@@ -1246,6 +1252,7 @@ export const modePortugueseMessages: ModeMessages = {
|
||||
模型调用与费用: "Chamadas ao modelo e custos",
|
||||
搜索范围: "Âmbito da pesquisa",
|
||||
已有数据: "Dados existentes",
|
||||
知识图谱: "Grafo de conhecimento",
|
||||
切换后: "Após a mudança",
|
||||
"之后只保存原始对话,不再生成新记忆。":
|
||||
"As novas conversas passam a ser guardadas apenas como texto original; não são criadas novas memórias.",
|
||||
@@ -1451,6 +1458,7 @@ export const modeFrenchMessages: ModeMessages = {
|
||||
模型调用与费用: "Appels au modèle et coût",
|
||||
搜索范围: "Périmètre de recherche",
|
||||
已有数据: "Données existantes",
|
||||
知识图谱: "Graphe de connaissances",
|
||||
切换后: "Après le changement",
|
||||
"之后只保存原始对话,不再生成新记忆。":
|
||||
"Les nouvelles conversations sont uniquement enregistrées dans leur forme originale ; aucune nouvelle mémoire n’est créée.",
|
||||
@@ -1651,6 +1659,7 @@ export const modeKoreanMessages: ModeMessages = {
|
||||
模型调用与费用: "모델 호출 및 비용",
|
||||
搜索范围: "검색 범위",
|
||||
已有数据: "기존 데이터",
|
||||
知识图谱: "지식 그래프",
|
||||
切换后: "전환 후",
|
||||
"之后只保存原始对话,不再生成新记忆。":
|
||||
"이후 새 대화는 원본만 저장되며 새 메모리는 생성되지 않습니다.",
|
||||
@@ -1850,6 +1859,7 @@ export const modeRussianMessages: ModeMessages = {
|
||||
模型调用与费用: "Вызовы модели и расходы",
|
||||
搜索范围: "Область поиска",
|
||||
已有数据: "Существующие данные",
|
||||
知识图谱: "Граф знаний",
|
||||
切换后: "После переключения",
|
||||
"之后只保存原始对话,不再生成新记忆。":
|
||||
"Новые диалоги будут сохраняться только в исходном виде; новые воспоминания создаваться не будут.",
|
||||
@@ -2052,6 +2062,7 @@ export const modeIndonesianMessages: ModeMessages = {
|
||||
模型调用与费用: "Panggilan model dan biaya",
|
||||
搜索范围: "Cakupan pencarian",
|
||||
已有数据: "Data yang ada",
|
||||
知识图谱: "Graf pengetahuan",
|
||||
切换后: "Setelah beralih",
|
||||
"之后只保存原始对话,不再生成新记忆。":
|
||||
"Percakapan baru hanya disimpan sebagai teks asli; tidak ada memori baru yang dibuat.",
|
||||
|
||||
@@ -1544,6 +1544,7 @@ export const germanMessages: Record<MessageKey, string> = {
|
||||
"apps.memories": "Gespeicherte Erinnerungen",
|
||||
"apps.rawConversations": "Originalunterhaltungen",
|
||||
"apps.rawSaving": "Wird gespeichert",
|
||||
"apps.rawSavingCompact": "Wird gespeichert",
|
||||
"apps.rawSavingStatus": "Speicherstatus",
|
||||
"apps.rawSavingDetail":
|
||||
"Originalunterhaltungen werden ohne generatives Modell gespeichert",
|
||||
@@ -1555,6 +1556,7 @@ export const germanMessages: Record<MessageKey, string> = {
|
||||
"Geprüft: keine anzeigbaren Aktualisierungen der Originalunterhaltungen",
|
||||
"apps.memoryCheckedEmpty":
|
||||
"Geprüft: keine anzeigbaren Erinnerungsaktualisierungen",
|
||||
"apps.recentEmptyCompact": "Keine Updates",
|
||||
"apps.rawWaiting": "Gespeicherte Originalunterhaltungen erscheinen hier",
|
||||
"apps.checkedRecent": "Aktueller Inhalt geprüft",
|
||||
"apps.currentMode": "Aktueller Modus",
|
||||
|
||||
@@ -1565,6 +1565,7 @@ export const frenchMessages: Record<MessageKey, string> = {
|
||||
"apps.memories": "Mémoires enregistrées",
|
||||
"apps.rawConversations": "Conversations d’origine",
|
||||
"apps.rawSaving": "Enregistrement",
|
||||
"apps.rawSavingCompact": "Enregistrement",
|
||||
"apps.rawSavingStatus": "État de l’enregistrement",
|
||||
"apps.rawSavingDetail":
|
||||
"Les conversations d’origine sont enregistrées sans modèle génératif",
|
||||
@@ -1576,6 +1577,7 @@ export const frenchMessages: Record<MessageKey, string> = {
|
||||
"Vérifié : aucune mise à jour de conversation à afficher",
|
||||
"apps.memoryCheckedEmpty":
|
||||
"Vérifié : aucune mise à jour de mémoire à afficher",
|
||||
"apps.recentEmptyCompact": "Aucune mise à jour",
|
||||
"apps.rawWaiting":
|
||||
"Les conversations d’origine enregistrées apparaîtront ici",
|
||||
"apps.checkedRecent": "Contenu actuel vérifié",
|
||||
|
||||
@@ -1519,6 +1519,7 @@ export const indonesianMessages: Record<MessageKey, string> = {
|
||||
"apps.memories": "Memori tersimpan",
|
||||
"apps.rawConversations": "Percakapan asli",
|
||||
"apps.rawSaving": "Menyimpan",
|
||||
"apps.rawSavingCompact": "Menyimpan",
|
||||
"apps.rawSavingStatus": "Status penyimpanan",
|
||||
"apps.rawSavingDetail": "Percakapan asli disimpan tanpa model generatif",
|
||||
"apps.rawNoExcerpt": "Tidak ada kutipan pengguna yang dapat ditampilkan",
|
||||
@@ -1529,6 +1530,7 @@ export const indonesianMessages: Record<MessageKey, string> = {
|
||||
"Diperiksa: tidak ada pembaruan percakapan untuk ditampilkan",
|
||||
"apps.memoryCheckedEmpty":
|
||||
"Diperiksa: tidak ada pembaruan memori untuk ditampilkan",
|
||||
"apps.recentEmptyCompact": "Belum ada pembaruan",
|
||||
"apps.rawWaiting": "Percakapan asli yang disimpan akan muncul di sini",
|
||||
"apps.checkedRecent": "Konten saat ini telah diperiksa",
|
||||
"apps.currentMode": "Mode saat ini",
|
||||
|
||||
@@ -1516,6 +1516,7 @@ export const japaneseMessages: Record<MessageKey, string> = {
|
||||
"apps.memories": "保存済みの記憶",
|
||||
"apps.rawConversations": "元の会話",
|
||||
"apps.rawSaving": "保存中",
|
||||
"apps.rawSavingCompact": "保存中",
|
||||
"apps.rawSavingStatus": "保存状態",
|
||||
"apps.rawSavingDetail": "生成モデルを使わず元の会話を保存します",
|
||||
"apps.rawNoExcerpt": "表示できるユーザーの原文がありません",
|
||||
@@ -1524,6 +1525,7 @@ export const japaneseMessages: Record<MessageKey, string> = {
|
||||
"apps.rawEmpty": "保存済みの元の会話はまだありません",
|
||||
"apps.rawCheckedEmpty": "確認済み:表示できる元の会話の更新はありません",
|
||||
"apps.memoryCheckedEmpty": "確認済み:表示できる記憶の更新はありません",
|
||||
"apps.recentEmptyCompact": "更新なし",
|
||||
"apps.rawWaiting": "保存された元の会話がここに表示されます",
|
||||
"apps.checkedRecent": "現在の内容を確認済み",
|
||||
"apps.currentMode": "現在のモード",
|
||||
|
||||
@@ -1480,6 +1480,7 @@ export const koreanMessages: Record<MessageKey, string> = {
|
||||
"apps.memories": "저장된 기억",
|
||||
"apps.rawConversations": "원본 대화",
|
||||
"apps.rawSaving": "저장 중",
|
||||
"apps.rawSavingCompact": "저장 중",
|
||||
"apps.rawSavingStatus": "저장 상태",
|
||||
"apps.rawSavingDetail": "생성 모델 없이 원본 대화를 저장합니다",
|
||||
"apps.rawNoExcerpt": "표시할 수 있는 사용자 원문이 없습니다",
|
||||
@@ -1488,6 +1489,7 @@ export const koreanMessages: Record<MessageKey, string> = {
|
||||
"apps.rawEmpty": "저장된 원본 대화가 아직 없습니다",
|
||||
"apps.rawCheckedEmpty": "확인됨: 표시할 원본 대화 업데이트가 없습니다",
|
||||
"apps.memoryCheckedEmpty": "확인됨: 표시할 메모리 업데이트가 없습니다",
|
||||
"apps.recentEmptyCompact": "업데이트 없음",
|
||||
"apps.rawWaiting": "저장된 원본 대화가 여기에 표시됩니다",
|
||||
"apps.checkedRecent": "현재 내용 확인됨",
|
||||
"apps.currentMode": "현재 모드",
|
||||
|
||||
@@ -1541,6 +1541,7 @@ export const portugueseMessages: Record<MessageKey, string> = {
|
||||
"apps.memories": "Memórias salvas",
|
||||
"apps.rawConversations": "Conversas originais",
|
||||
"apps.rawSaving": "Salvando",
|
||||
"apps.rawSavingCompact": "Salvando",
|
||||
"apps.rawSavingStatus": "Status de salvamento",
|
||||
"apps.rawSavingDetail":
|
||||
"As conversas originais são salvas sem um modelo generativo",
|
||||
@@ -1552,6 +1553,7 @@ export const portugueseMessages: Record<MessageKey, string> = {
|
||||
"Verificado: não há atualizações de conversas para mostrar",
|
||||
"apps.memoryCheckedEmpty":
|
||||
"Verificado: não há atualizações de memórias para mostrar",
|
||||
"apps.recentEmptyCompact": "Sem atualizações",
|
||||
"apps.rawWaiting": "As conversas originais salvas aparecerão aqui",
|
||||
"apps.checkedRecent": "Conteúdo atual verificado",
|
||||
"apps.currentMode": "Modo atual",
|
||||
|
||||
@@ -1524,6 +1524,7 @@ export const russianMessages: Record<MessageKey, string> = {
|
||||
"apps.memories": "Сохранённые записи памяти",
|
||||
"apps.rawConversations": "Исходные разговоры",
|
||||
"apps.rawSaving": "Сохранение",
|
||||
"apps.rawSavingCompact": "Сохранение",
|
||||
"apps.rawSavingStatus": "Статус сохранения",
|
||||
"apps.rawSavingDetail":
|
||||
"Исходные разговоры сохраняются без генеративной модели",
|
||||
@@ -1534,6 +1535,7 @@ export const russianMessages: Record<MessageKey, string> = {
|
||||
"apps.rawCheckedEmpty":
|
||||
"Проверено: нет обновлений исходных диалогов для показа",
|
||||
"apps.memoryCheckedEmpty": "Проверено: нет обновлений памяти для показа",
|
||||
"apps.recentEmptyCompact": "Нет обновлений",
|
||||
"apps.rawWaiting": "Сохранённые исходные диалоги появятся здесь",
|
||||
"apps.checkedRecent": "Текущее содержимое проверено",
|
||||
"apps.currentMode": "Текущий режим",
|
||||
|
||||
@@ -1388,6 +1388,7 @@ export const traditionalChineseMessages: Record<MessageKey, string> = {
|
||||
"apps.memories": "已建立的記憶",
|
||||
"apps.rawConversations": "原始對話",
|
||||
"apps.rawSaving": "儲存中",
|
||||
"apps.rawSavingCompact": "儲存中",
|
||||
"apps.rawSavingStatus": "儲存狀態",
|
||||
"apps.rawSavingDetail": "原始對話會持續儲存,不需要生成模型",
|
||||
"apps.rawNoExcerpt": "這段對話沒有可顯示的使用者原文",
|
||||
@@ -1396,6 +1397,7 @@ export const traditionalChineseMessages: Record<MessageKey, string> = {
|
||||
"apps.rawEmpty": "還沒有儲存的原始對話",
|
||||
"apps.rawCheckedEmpty": "已核對,暫無可顯示的原始對話更新",
|
||||
"apps.memoryCheckedEmpty": "已核對,暫無可顯示的記憶更新",
|
||||
"apps.recentEmptyCompact": "暫無更新",
|
||||
"apps.rawWaiting": "原始對話儲存後會顯示在這裡",
|
||||
"apps.checkedRecent": "已核對目前內容",
|
||||
"apps.currentMode": "目前模式",
|
||||
|
||||
@@ -32,7 +32,31 @@ describe("memory mode plan", () => {
|
||||
expect(parseMemoryModePlan({ ...valid, blockers: ["unknown"] })).toBeNull();
|
||||
});
|
||||
|
||||
it("isolates the old-Core matrix in one helper", () => {
|
||||
it.each([
|
||||
["lite", "pro", ["earlier_raw_not_extracted", "lite_search_stops"]],
|
||||
[
|
||||
"lite",
|
||||
"ultra",
|
||||
[
|
||||
"earlier_raw_not_extracted",
|
||||
"lite_search_stops",
|
||||
"existing_memories_added_to_graph",
|
||||
],
|
||||
],
|
||||
["pro", "lite", ["higher_mode_memories_hidden"]],
|
||||
["pro", "ultra", ["existing_memories_added_to_graph"]],
|
||||
["ultra", "lite", ["higher_mode_memories_hidden", "evolve_stopped"]],
|
||||
["ultra", "pro", ["higher_mode_memories_hidden", "evolve_stopped"]],
|
||||
] as const)(
|
||||
"keeps the old-Core %s to %s effects aligned with the plan route",
|
||||
(current, target, effects) => {
|
||||
expect(
|
||||
legacyMemoryModePlan(current, target, { hasActiveModel: true }).effects,
|
||||
).toEqual(effects);
|
||||
},
|
||||
);
|
||||
|
||||
it("keeps the old-Core model blocker and target capabilities", () => {
|
||||
expect(
|
||||
legacyMemoryModePlan("lite", "ultra", { hasActiveModel: false }),
|
||||
).toMatchObject({
|
||||
@@ -42,10 +66,6 @@ describe("memory mode plan", () => {
|
||||
graph_enabled: true,
|
||||
},
|
||||
blockers: ["active_model_required"],
|
||||
effects: expect.arrayContaining([
|
||||
"earlier_raw_not_extracted",
|
||||
"existing_memories_added_to_graph",
|
||||
]),
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -106,12 +106,15 @@ export function legacyMemoryModePlan(
|
||||
options: { hasActiveModel: boolean },
|
||||
): MemoryModePlan {
|
||||
const effects: MemoryModeChange["effects"] = [];
|
||||
if (target === "lite") {
|
||||
effects.push("higher_mode_memories_hidden");
|
||||
if (current === "ultra") effects.push("evolve_stopped");
|
||||
} else if (current === "lite") {
|
||||
const rank: Record<MemoryPipelineMode, number> = {
|
||||
lite: 0,
|
||||
pro: 1,
|
||||
ultra: 2,
|
||||
};
|
||||
if (current !== target && current === "lite")
|
||||
effects.push("earlier_raw_not_extracted", "lite_search_stops");
|
||||
}
|
||||
if (rank[target] < rank[current]) effects.push("higher_mode_memories_hidden");
|
||||
if (current !== target && current === "ultra") effects.push("evolve_stopped");
|
||||
if (target === "ultra" && current !== "ultra")
|
||||
effects.push("existing_memories_added_to_graph");
|
||||
return {
|
||||
|
||||
@@ -27,6 +27,7 @@ class RawStore:
|
||||
exclude_sources,
|
||||
layers,
|
||||
limit,
|
||||
status_filter,
|
||||
**_kwargs,
|
||||
):
|
||||
rows = []
|
||||
@@ -41,11 +42,18 @@ class RawStore:
|
||||
continue
|
||||
if node.layer not in layers:
|
||||
continue
|
||||
if node.status not in status_filter:
|
||||
continue
|
||||
rows.append({"node": node, "score": 0.9})
|
||||
return rows[:limit]
|
||||
|
||||
|
||||
def raw_node(raw_id: str, session_id: str, source: str) -> MemoryNode:
|
||||
def raw_node(
|
||||
raw_id: str,
|
||||
session_id: str,
|
||||
source: str,
|
||||
status: MemoryStatus = MemoryStatus.ACTIVE,
|
||||
) -> MemoryNode:
|
||||
return MemoryNode(
|
||||
node_id=raw_id,
|
||||
user_id="u1",
|
||||
@@ -55,7 +63,7 @@ def raw_node(raw_id: str, session_id: str, source: str) -> MemoryNode:
|
||||
layer=MemoryLayer.L1_RAW,
|
||||
content=f"content {raw_id}",
|
||||
source_type=SourceType.EXPLICIT,
|
||||
status=MemoryStatus.ACTIVE,
|
||||
status=status,
|
||||
is_latest=True,
|
||||
embedding=[0.1, 0.2],
|
||||
)
|
||||
@@ -102,6 +110,7 @@ async def test_existing_search_contract_can_explicitly_search_raw():
|
||||
request_id="search-1",
|
||||
t0=0.0,
|
||||
query_embedding=[0.1, 0.2],
|
||||
statuses=None,
|
||||
)
|
||||
|
||||
assert [item["memory_id"] for item in result["memories"]["normal"]] == [
|
||||
@@ -109,3 +118,33 @@ async def test_existing_search_contract_can_explicitly_search_raw():
|
||||
]
|
||||
assert result["memories"]["normal"][0]["layer"] == "l1_raw"
|
||||
assert result["memories"]["normal"][0]["source"] == "claude"
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_explicit_raw_search_can_include_shadow_rows():
|
||||
client = HyMemoryClient.__new__(HyMemoryClient)
|
||||
client._vector_store = RawStore(
|
||||
[raw_node("raw-shadow", "s1", "cursor", MemoryStatus.SHADOW)]
|
||||
)
|
||||
client._embed_service = AsyncMock()
|
||||
client._embed_service.embed.return_value = [0.1, 0.2]
|
||||
|
||||
result = await HyMemoryClient._search_explicit_raw_layer.__wrapped__(
|
||||
client,
|
||||
"content",
|
||||
user_ids=["u1"],
|
||||
agent_ids=["a1"],
|
||||
session_ids=[],
|
||||
include_sources=[],
|
||||
exclude_sources=[],
|
||||
limit=5,
|
||||
min_score=0.0,
|
||||
created_after=None,
|
||||
request_id="search-shadow",
|
||||
t0=0.0,
|
||||
query_embedding=[0.1, 0.2],
|
||||
statuses=["active", "shadow"],
|
||||
)
|
||||
|
||||
assert result["memories"]["normal"][0]["memory_id"] == "raw-shadow"
|
||||
assert result["memories"]["normal"][0]["status"] == "shadow"
|
||||
Reference in new issue
Block a user