Compare commits
16
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
3064515b43 | ||
|
|
e5c8cc3819 | ||
|
|
3c5451db24 | ||
|
|
099f5ae7c0 | ||
|
|
e7625032d8 | ||
|
|
3aa3b5957d | ||
|
|
409ab9a9b4 | ||
|
|
2735d8693a | ||
|
|
47bbfb4b27 | ||
|
|
3f169c9951 | ||
|
|
24f699a726 | ||
|
|
cb97aa5822 | ||
|
|
38a4da6849 | ||
|
|
48a5d70026 | ||
|
|
b21c929728 | ||
|
|
b9b3c587ff |
No files matched your search
@@ -4,6 +4,17 @@ Hy Memory and MemHub are versioned and released independently.
|
||||
|
||||
## Unreleased
|
||||
|
||||
- SDK: `list_memories(scene="chat" | "coding" | "working")` lists one kind of
|
||||
memory. `coding` and `working` list that scene's whole store, newest update
|
||||
first (`workspace_id` still narrows it), and `total` is now the real count
|
||||
rather than the page size. The new `memory_stats()` returns the count and the
|
||||
latest created/updated time for each scene and their total, without reading
|
||||
any memory text. Previously coding and working memories could not be listed
|
||||
or counted without a workspace, so a library of 45 memories listed 22.
|
||||
- The SDK migration no longer fails its first run on a home with legacy
|
||||
vectors: reattaching them left a Chroma instance open with its own settings,
|
||||
and the readback that followed could not open the same store
|
||||
("An instance of Chroma already exists ... with different settings").
|
||||
- MemHub "添加记忆" (add a memory) no longer waits behind unprocessed history.
|
||||
Notes are claimed ahead of the event-time window that keeps history strictly
|
||||
old to new; they still run one at a time and in order within their session.
|
||||
|
||||
+177
-13
@@ -1980,6 +1980,7 @@ class HyMemoryClient:
|
||||
layers: Optional[List[Any]] = None,
|
||||
statuses: Optional[List[Any]] = None,
|
||||
with_evolution: bool = False,
|
||||
scene: Optional[str] = None,
|
||||
) -> Dict[str, Any]:
|
||||
"""
|
||||
列出用户的记忆(同步)。
|
||||
@@ -2008,6 +2009,9 @@ class HyMemoryClient:
|
||||
保持兼容,只返回 ACTIVE。可显式读取 SUPERSEDED / SHADOW /
|
||||
NEGATED / CONFLICTED / ARCHIVED;物理删除的节点不会返回。
|
||||
with_evolution: 可选,为当前页条目附加 evolution_chain(默认关闭)。
|
||||
scene: 可选,列哪一类记忆:"chat" | "coding" | "working"。
|
||||
"coding"/"working" 列该类全部经验(workspace_id 可再收窄),
|
||||
按 updated_at 倒序,total 为真实总数。不传时保持原行为。
|
||||
|
||||
Returns:
|
||||
chat 路径: {"vdb": {...}, "graph": {...} | 省略, "elapsed_ms": ...}
|
||||
@@ -2023,9 +2027,36 @@ class HyMemoryClient:
|
||||
limit=limit, offset=offset, order=order,
|
||||
workspace_id=workspace_id, branch=branch,
|
||||
layers=layers, statuses=statuses, with_evolution=with_evolution,
|
||||
scene=scene,
|
||||
)
|
||||
)
|
||||
|
||||
def memory_stats(
|
||||
self,
|
||||
*,
|
||||
user_id: str = "",
|
||||
statuses: Optional[List[Any]] = None,
|
||||
) -> Dict[str, Any]:
|
||||
"""
|
||||
每类记忆的数量与最新时间(同步)。纯聚合,不返回正文。
|
||||
|
||||
Returns:
|
||||
{
|
||||
"total": chat + coding + working 的条数,
|
||||
"scenes": {
|
||||
"chat": {"count", "latest_created_at", "latest_updated_at"},
|
||||
"coding": {"count", "latest_created_at", "latest_updated_at", "facts"},
|
||||
"working": {同 coding},
|
||||
},
|
||||
"request_id", "elapsed_ms",
|
||||
}
|
||||
时间均为秒级 epoch,没有记忆时为 None。chat 不含 L1_RAW;
|
||||
coding/working 的 count 是经验条数,facts 是 active 事实数(不计入 total)。
|
||||
"""
|
||||
return self._loop_thread.run(
|
||||
self.async_memory_stats(user_id=user_id, statuses=statuses)
|
||||
)
|
||||
|
||||
def get_user_basic_info(
|
||||
self,
|
||||
*,
|
||||
@@ -4917,14 +4948,11 @@ class HyMemoryClient:
|
||||
nodes.sort(key=_sort_key, reverse=reverse)
|
||||
return nodes
|
||||
|
||||
async def _list_vdb_bucket(
|
||||
async def _list_vdb_nodes(
|
||||
self,
|
||||
*,
|
||||
user_id: str,
|
||||
agent_id: Optional[str],
|
||||
limit: int,
|
||||
offset: int,
|
||||
order: str,
|
||||
user_ids: Optional[List[str]] = None,
|
||||
agent_ids: Optional[List[str]] = None,
|
||||
session_ids: Optional[List[str]] = None,
|
||||
@@ -4932,8 +4960,8 @@ class HyMemoryClient:
|
||||
exclude_sources: Optional[List[str]] = None,
|
||||
layers: Optional[List] = None,
|
||||
statuses: Optional[List] = None,
|
||||
with_evolution: bool = False,
|
||||
) -> Dict[str, Any]:
|
||||
) -> List:
|
||||
"""chat 路径的全部节点(已过滤、未排序)。list 与 stats 共用同一套口径。"""
|
||||
from .models.memory import MemoryLayer, MemoryStatus
|
||||
from .pipelines._retrieval.user_level_l0 import collapse_user_level_l0_hits
|
||||
|
||||
@@ -4995,6 +5023,33 @@ class HyMemoryClient:
|
||||
node for node in all_nodes if node.source not in blocked_sources
|
||||
]
|
||||
|
||||
return all_nodes
|
||||
|
||||
async def _list_vdb_bucket(
|
||||
self,
|
||||
*,
|
||||
user_id: str,
|
||||
agent_id: Optional[str],
|
||||
limit: int,
|
||||
offset: int,
|
||||
order: str,
|
||||
user_ids: Optional[List[str]] = None,
|
||||
agent_ids: Optional[List[str]] = None,
|
||||
session_ids: Optional[List[str]] = None,
|
||||
include_sources: Optional[List[str]] = None,
|
||||
exclude_sources: Optional[List[str]] = None,
|
||||
layers: Optional[List] = None,
|
||||
statuses: Optional[List] = None,
|
||||
with_evolution: bool = False,
|
||||
) -> Dict[str, Any]:
|
||||
all_nodes = await self._list_vdb_nodes(
|
||||
user_id=user_id, agent_id=agent_id,
|
||||
user_ids=user_ids, agent_ids=agent_ids,
|
||||
session_ids=session_ids,
|
||||
include_sources=include_sources,
|
||||
exclude_sources=exclude_sources,
|
||||
layers=layers, statuses=statuses,
|
||||
)
|
||||
all_nodes = self._sort_memory_nodes(all_nodes, order=order)
|
||||
total = len(all_nodes)
|
||||
page_nodes = all_nodes[offset: offset + limit]
|
||||
@@ -5154,11 +5209,15 @@ class HyMemoryClient:
|
||||
request_id: str,
|
||||
t0: float,
|
||||
statuses: Optional[List] = None,
|
||||
variant: Optional[str] = None,
|
||||
) -> Dict[str, Any]:
|
||||
"""按 workspace_id(+可选 branch)纯过滤列出经验层完整详情。
|
||||
|
||||
枚举而非召回,所以只有经验层、不带事实层。无 query、无语义排序,按
|
||||
updated_at 倒序。fail-safe:失败返回空列表。
|
||||
|
||||
variant 给定时读该变体自己的库(没写过的变体不建空库,直接返回空),
|
||||
workspace_id 为空表示该 user 的全部经验。未给定时沿用"最近一次用的那份"。
|
||||
"""
|
||||
empty = {
|
||||
"request_id": request_id,
|
||||
@@ -5169,20 +5228,31 @@ class HyMemoryClient:
|
||||
"offset": offset,
|
||||
"elapsed_ms": round((time.perf_counter() - t0) * 1000, 2),
|
||||
}
|
||||
if variant is not None:
|
||||
empty["variant"] = variant
|
||||
from .models.memory import MemoryStatus
|
||||
if statuses and MemoryStatus.ACTIVE not in statuses:
|
||||
return empty
|
||||
try:
|
||||
await self._get_coding_writer() # 副作用:lazy init store
|
||||
if variant is None:
|
||||
await self._get_coding_writer() # 副作用:lazy init store
|
||||
store = self._coding_store
|
||||
else:
|
||||
store = await self._coding_store_for_exact_get(variant)
|
||||
except Exception as e:
|
||||
logger.warning(f"[list-coding] init failed: {e}")
|
||||
return empty
|
||||
if store is None:
|
||||
return empty
|
||||
|
||||
try:
|
||||
mems = await self._coding_store.list_by_workspace(
|
||||
user_id=user_id, workspace_id=workspace_id, branch=branch,
|
||||
mems = await store.list_by_workspace(
|
||||
user_id=user_id, workspace_id=workspace_id or "", branch=branch,
|
||||
limit=limit, offset=offset,
|
||||
)
|
||||
total = (await store.stats(
|
||||
user_id, workspace_id=workspace_id or None, branch=branch,
|
||||
))["count"]
|
||||
except Exception as e:
|
||||
logger.warning(f"[list-coding] list_by_workspace failed: {e}")
|
||||
return empty
|
||||
@@ -5201,6 +5271,7 @@ class HyMemoryClient:
|
||||
"source_fact_ids": list(d.source_fact_ids),
|
||||
"confidence": d.confidence,
|
||||
"status": MemoryStatus.ACTIVE.value,
|
||||
"created_at": d.created_at.isoformat() if d.created_at else None,
|
||||
"updated_at": d.updated_at.isoformat() if d.updated_at else None,
|
||||
"source": d.source,
|
||||
}
|
||||
@@ -5211,15 +5282,99 @@ class HyMemoryClient:
|
||||
f"[list-coding] user={user_id} workspace_id={workspace_id!r} "
|
||||
f"branch={branch!r} returned={len(out_memories)} elapsed={elapsed}ms"
|
||||
)
|
||||
return {
|
||||
out: Dict[str, Any] = {
|
||||
"request_id": request_id,
|
||||
"scene": "productivity",
|
||||
"experiences": out_memories,
|
||||
"total": len(out_memories),
|
||||
# 真实总数,不是本页条数:分页靠它判断是否还有下一页
|
||||
"total": total,
|
||||
"limit": limit,
|
||||
"offset": offset,
|
||||
"elapsed_ms": elapsed,
|
||||
}
|
||||
if variant is not None:
|
||||
out["variant"] = variant
|
||||
return out
|
||||
|
||||
@staticmethod
|
||||
def _list_scene(scene: Optional[str]) -> Optional[str]:
|
||||
"""list/stats 的 scene:None(原有路由)| chat | coding | working。"""
|
||||
if scene is None:
|
||||
return None
|
||||
value = str(scene).strip().lower()
|
||||
if value not in ("chat", "coding", "working"):
|
||||
raise ValueError(f"scene must be chat, coding or working, got {scene!r}")
|
||||
return value
|
||||
|
||||
@_ensure_internal_loop
|
||||
async def async_memory_stats(
|
||||
self,
|
||||
*,
|
||||
user_id: str = "",
|
||||
statuses: Optional[List[Any]] = None,
|
||||
) -> Dict[str, Any]:
|
||||
"""每类记忆的数量与最新时间(异步)。字段见同步版 memory_stats。"""
|
||||
from .models.memory import MemoryStatus
|
||||
|
||||
request_id = str(uuid.uuid4())
|
||||
set_request_id(request_id)
|
||||
t0 = time.perf_counter()
|
||||
effective_statuses = self._normalize_statuses(statuses)
|
||||
|
||||
def _epoch(value: Optional[datetime]) -> Optional[int]:
|
||||
return int(value.timestamp()) if isinstance(value, datetime) else None
|
||||
|
||||
nodes = await self._list_vdb_nodes(
|
||||
user_id=user_id, agent_id=None, statuses=effective_statuses,
|
||||
)
|
||||
created = [
|
||||
stamp for stamp in (
|
||||
_epoch(n.gmt_created) or _epoch(getattr(n, "valid_from", None))
|
||||
for n in nodes
|
||||
) if stamp is not None
|
||||
]
|
||||
updated = [
|
||||
stamp for stamp in (_epoch(getattr(n, "gmt_modified", None)) for n in nodes)
|
||||
if stamp is not None
|
||||
]
|
||||
scenes: Dict[str, Dict[str, Any]] = {
|
||||
"chat": {
|
||||
"count": len(nodes),
|
||||
"latest_created_at": max(created) if created else None,
|
||||
"latest_updated_at": max(updated or created) if (updated or created) else None,
|
||||
}
|
||||
}
|
||||
# 经验层没有生命周期字段,只算 ACTIVE;没写过的变体不建空库。
|
||||
include_productivity = (
|
||||
not effective_statuses or MemoryStatus.ACTIVE in effective_statuses
|
||||
)
|
||||
for variant in ("coding", "working"):
|
||||
stats = {"count": 0, "latest_created_at": None, "latest_updated_at": None, "facts": 0}
|
||||
if include_productivity:
|
||||
store = await self._coding_store_for_exact_get(variant)
|
||||
if store is not None:
|
||||
raw = await store.stats(user_id)
|
||||
stats = {
|
||||
"count": raw["count"],
|
||||
"latest_created_at": _epoch(raw["latest_created_at"]),
|
||||
"latest_updated_at": _epoch(raw["latest_updated_at"]),
|
||||
"facts": raw["facts"],
|
||||
}
|
||||
scenes[variant] = stats
|
||||
|
||||
elapsed = round((time.perf_counter() - t0) * 1000, 2)
|
||||
total = sum(int(item["count"]) for item in scenes.values())
|
||||
logger.info(
|
||||
f"[stats] user={user_id} total={total} "
|
||||
+ " ".join(f"{name}={item['count']}" for name, item in scenes.items())
|
||||
+ f" elapsed={elapsed}ms"
|
||||
)
|
||||
return {
|
||||
"total": total,
|
||||
"scenes": scenes,
|
||||
"request_id": request_id,
|
||||
"elapsed_ms": elapsed,
|
||||
}
|
||||
|
||||
@_ensure_internal_loop
|
||||
async def async_list_memories(
|
||||
@@ -5240,6 +5395,7 @@ class HyMemoryClient:
|
||||
layers: Optional[List[Any]] = None,
|
||||
statuses: Optional[List[Any]] = None,
|
||||
with_evolution: bool = False,
|
||||
scene: Optional[str] = None,
|
||||
) -> Dict[str, Any]:
|
||||
"""
|
||||
列出用户的记忆(异步)。
|
||||
@@ -5299,9 +5455,17 @@ class HyMemoryClient:
|
||||
set_request_id(request_id)
|
||||
t0 = time.perf_counter()
|
||||
effective_statuses = self._normalize_statuses(statuses)
|
||||
list_scene = self._list_scene(scene)
|
||||
|
||||
# workspace_id 非空 → 走 coding memory 列表路径
|
||||
if workspace_id:
|
||||
# scene 显式指定 coding/working → 列该类全部经验(workspace_id 可再收窄)
|
||||
if list_scene in ("coding", "working"):
|
||||
return await self._list_coding(
|
||||
user_id=user_id, workspace_id=workspace_id, branch=branch,
|
||||
limit=limit, offset=offset, request_id=request_id, t0=t0,
|
||||
statuses=effective_statuses, variant=list_scene,
|
||||
)
|
||||
# workspace_id 非空且未指定 scene → 走 coding memory 列表路径(原有行为)
|
||||
if workspace_id and list_scene is None:
|
||||
return await self._list_coding(
|
||||
user_id=user_id, workspace_id=workspace_id, branch=branch,
|
||||
limit=limit, offset=offset, request_id=request_id, t0=t0,
|
||||
|
||||
@@ -710,6 +710,42 @@ class CodingMemoryStore:
|
||||
cur = self._conn.execute(sql, params)
|
||||
return [_row_to_memory(r) for r in cur.fetchall()]
|
||||
|
||||
async def stats(
|
||||
self,
|
||||
user_id: str,
|
||||
workspace_id: Optional[str] = None,
|
||||
branch: Optional[str] = None,
|
||||
) -> Dict[str, Any]:
|
||||
"""经验数、最新创建/修改时间、active 事实数。纯聚合,不读正文。
|
||||
|
||||
作用域与 list_by_workspace 相同:workspace_id 为空表示该 user 全部。
|
||||
"""
|
||||
return await _run_in_pool(self._stats_sync, user_id, workspace_id, branch)
|
||||
|
||||
def _stats_sync(
|
||||
self, user_id: str, workspace_id: Optional[str], branch: Optional[str],
|
||||
) -> Dict[str, Any]:
|
||||
scope_sql, scope_params = _scope_where(workspace_id, branch)
|
||||
with self._lock:
|
||||
assert self._conn is not None
|
||||
row = self._conn.execute(
|
||||
f"SELECT COUNT(*) AS n, MAX(created_at) AS created, "
|
||||
f"MAX(updated_at) AS updated FROM coding_memory_meta "
|
||||
f"WHERE user_id = ? AND {scope_sql}",
|
||||
[user_id] + scope_params,
|
||||
).fetchone()
|
||||
facts = self._conn.execute(
|
||||
f"SELECT COUNT(*) AS n FROM working_fact "
|
||||
f"WHERE user_id = ? AND status = 'active' AND {scope_sql}",
|
||||
[user_id] + scope_params,
|
||||
).fetchone()
|
||||
return {
|
||||
"count": int(row["n"] or 0),
|
||||
"latest_created_at": _parse_dt(row["created"]) if row["created"] else None,
|
||||
"latest_updated_at": _parse_dt(row["updated"]) if row["updated"] else None,
|
||||
"facts": int(facts["n"] or 0),
|
||||
}
|
||||
|
||||
# ------------------------------------------------------------
|
||||
# 召回
|
||||
# ------------------------------------------------------------
|
||||
|
||||
@@ -340,7 +340,13 @@ def sdk_migrate(options: Sequence[str], stream: TextIO) -> int:
|
||||
_write_restore(stream, as_json, document, "预览通过;加 --yes 后执行迁移。")
|
||||
return 4
|
||||
|
||||
from one_memory.hy_runtime_config import HY_MEMORY_NAMESPACE, HY_MEMORY_VERSION
|
||||
from one_memory.hy_runtime_config import (
|
||||
HY_MEMORY_NAMESPACE,
|
||||
HY_MEMORY_VERSION,
|
||||
configure_hy_runtime_environment,
|
||||
)
|
||||
|
||||
configure_hy_runtime_environment(settings.data_dir)
|
||||
from one_memory.sdk_migration_runner import run_startup_migration
|
||||
|
||||
step_names = {
|
||||
@@ -650,6 +656,7 @@ def serve(*, maintenance: bool = False) -> None:
|
||||
from one_memory.diagnostic_log import configure_diagnostics, diagnostic_event
|
||||
from one_memory.hy_runtime_config import (
|
||||
apply_diagnostic_log_level,
|
||||
configure_hy_runtime_environment,
|
||||
diagnostic_log_level,
|
||||
)
|
||||
from one_memory.log_file import start_logging_to_file
|
||||
@@ -657,10 +664,15 @@ def serve(*, maintenance: bool = False) -> None:
|
||||
from one_memory.runtime import CoreRuntime, parent_pid
|
||||
|
||||
settings = load_settings()
|
||||
from one_memory.api import create_app
|
||||
|
||||
if not maintenance:
|
||||
_refuse_unmigrated_home(settings.data_dir)
|
||||
# HY resolves some trace paths at import time and its pipeline path when
|
||||
# SharedRuntime starts. Bind both to the MemHub root before importing the
|
||||
# application graph; otherwise HY falls back to ~/.hy_memory while the
|
||||
# desktop Debug panel watches the managed data directory.
|
||||
configure_hy_runtime_environment(settings.data_dir)
|
||||
from one_memory.api import create_app
|
||||
|
||||
start_logging_to_file(settings.data_dir)
|
||||
apply_diagnostic_log_level(diagnostic_log_level(settings.data_dir))
|
||||
configure_diagnostics(
|
||||
|
||||
@@ -326,13 +326,9 @@ def _repair_vector(
|
||||
directory = data_dir / "hy-memory" / HY_MEMORY_NAMESPACE / "vector"
|
||||
if not (directory / "chroma.sqlite3").is_file():
|
||||
return 0
|
||||
import chromadb
|
||||
from chromadb.config import Settings
|
||||
from one_memory.sdk_vector_reattach import open_offline_chroma
|
||||
|
||||
client = chromadb.PersistentClient(
|
||||
path=str(directory),
|
||||
settings=Settings(anonymized_telemetry=False),
|
||||
)
|
||||
client = open_offline_chroma(directory)
|
||||
# The SDK client is not open yet (startup has not created its handle), so
|
||||
# this is the only writer; release the store before the SDK takes it.
|
||||
try:
|
||||
|
||||
@@ -23,6 +23,21 @@ from one_memory.sdk_data_plane_migration import DataPlaneMigrationError
|
||||
_BATCH = 500
|
||||
|
||||
|
||||
def open_offline_chroma(directory: Path) -> Any:
|
||||
"""A Chroma client on the SDK's vector store, configured as the SDK opens it.
|
||||
|
||||
Chroma refuses a second client on a directory whose shared instance was
|
||||
created with different settings, so offline code must not pass its own.
|
||||
Telemetry is turned off the way the Runtime turns it off, by environment.
|
||||
"""
|
||||
import os
|
||||
|
||||
import chromadb
|
||||
|
||||
os.environ["ANONYMIZED_TELEMETRY"] = "False"
|
||||
return chromadb.PersistentClient(path=str(directory))
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class ReattachedCollection:
|
||||
legacy: str
|
||||
@@ -97,45 +112,46 @@ def reattach_legacy_vectors(
|
||||
pairs = legacy_vector_collections(data_dir, profile)
|
||||
if not pairs:
|
||||
return ()
|
||||
import chromadb
|
||||
from chromadb.config import Settings
|
||||
|
||||
client = chromadb.PersistentClient(
|
||||
path=str(_vector_directory(data_dir)),
|
||||
settings=Settings(anonymized_telemetry=False),
|
||||
)
|
||||
existing = set(_stored_names(data_dir))
|
||||
done: list[ReattachedCollection] = []
|
||||
for legacy_name, current_name in pairs:
|
||||
legacy = client.get_collection(legacy_name)
|
||||
legacy_count = legacy.count()
|
||||
merged = 0
|
||||
if current_name in existing:
|
||||
current = client.get_collection(current_name)
|
||||
legacy_ids = set(_ids(legacy))
|
||||
current_ids: set[str] = set()
|
||||
for batch in _records(current):
|
||||
legacy.upsert(**batch)
|
||||
current_ids.update(batch["ids"])
|
||||
merged = len(current_ids)
|
||||
if legacy.count() != len(legacy_ids | current_ids):
|
||||
raise DataPlaneMigrationError("sdk_migration_vector_merge_incomplete")
|
||||
client.delete_collection(current_name)
|
||||
legacy.modify(name=current_name)
|
||||
final = client.get_collection(current_name).count()
|
||||
if final < legacy_count:
|
||||
raise DataPlaneMigrationError("sdk_migration_vector_reattach_incomplete")
|
||||
existing.discard(legacy_name)
|
||||
existing.add(current_name)
|
||||
done.append(
|
||||
ReattachedCollection(
|
||||
legacy=legacy_name,
|
||||
current=current_name,
|
||||
legacy_count=legacy_count,
|
||||
merged_count=merged,
|
||||
final_count=final,
|
||||
client = open_offline_chroma(_vector_directory(data_dir))
|
||||
# Closed, not left to the process: Chroma keeps one shared instance per
|
||||
# directory, and the readback that follows opens the same directory
|
||||
# through the SDK. A leaked instance failed that open on every 1.0.4 home
|
||||
# with legacy vectors, so the first launch after upgrade always failed.
|
||||
try:
|
||||
existing = set(_stored_names(data_dir))
|
||||
done: list[ReattachedCollection] = []
|
||||
for legacy_name, current_name in pairs:
|
||||
legacy = client.get_collection(legacy_name)
|
||||
legacy_count = legacy.count()
|
||||
merged = 0
|
||||
if current_name in existing:
|
||||
current = client.get_collection(current_name)
|
||||
legacy_ids = set(_ids(legacy))
|
||||
current_ids: set[str] = set()
|
||||
for batch in _records(current):
|
||||
legacy.upsert(**batch)
|
||||
current_ids.update(batch["ids"])
|
||||
merged = len(current_ids)
|
||||
if legacy.count() != len(legacy_ids | current_ids):
|
||||
raise DataPlaneMigrationError("sdk_migration_vector_merge_incomplete")
|
||||
client.delete_collection(current_name)
|
||||
legacy.modify(name=current_name)
|
||||
final = client.get_collection(current_name).count()
|
||||
if final < legacy_count:
|
||||
raise DataPlaneMigrationError("sdk_migration_vector_reattach_incomplete")
|
||||
existing.discard(legacy_name)
|
||||
existing.add(current_name)
|
||||
done.append(
|
||||
ReattachedCollection(
|
||||
legacy=legacy_name,
|
||||
current=current_name,
|
||||
legacy_count=legacy_count,
|
||||
merged_count=merged,
|
||||
final_count=final,
|
||||
)
|
||||
)
|
||||
)
|
||||
finally:
|
||||
client.close()
|
||||
return tuple(done)
|
||||
|
||||
|
||||
|
||||
@@ -110,11 +110,24 @@ def test_log_level_document_migrates_the_old_boolean_shape(tmp_path: Path) -> No
|
||||
assert json.loads(target.read_text()) == {"schema_version": 2, "level": "error"}
|
||||
|
||||
|
||||
def test_configure_environment_passes_debug_content_settings_to_sdk(tmp_path: Path) -> None:
|
||||
def test_configure_environment_passes_debug_content_settings_to_sdk(
|
||||
tmp_path: Path, monkeypatch
|
||||
) -> None:
|
||||
for name in (
|
||||
"MEMORY_DATA_DIR",
|
||||
"MEMORY_LOG_DIR",
|
||||
"MEMORY_LOG_LEVEL",
|
||||
"MEMORY_PIPELINE_TRACE_ENABLED",
|
||||
"MEMORY_LOG_LLM_CONTENT",
|
||||
):
|
||||
monkeypatch.delenv(name, raising=False)
|
||||
set_diagnostic_log_level(tmp_path, "debug")
|
||||
configure_hy_runtime_environment(tmp_path)
|
||||
|
||||
environment = __import__("os").environ
|
||||
memory_root = tmp_path / "hy-memory" / "governed-v2"
|
||||
assert environment["MEMORY_DATA_DIR"] == str(memory_root)
|
||||
assert environment["MEMORY_LOG_DIR"] == str(memory_root / "logs")
|
||||
assert environment["MEMORY_LOG_LEVEL"] == "DEBUG"
|
||||
assert environment["MEMORY_PIPELINE_TRACE_ENABLED"] == "true"
|
||||
assert environment["MEMORY_LOG_LLM_CONTENT"] == "true"
|
||||
@@ -1,18 +1,24 @@
|
||||
import inspect
|
||||
from types import SimpleNamespace
|
||||
|
||||
from one_memory import api, cli, log_file, runtime
|
||||
from one_memory import api, cli, hy_runtime_config, log_file, runtime
|
||||
|
||||
|
||||
def test_production_serve_passes_only_supported_app_options(monkeypatch, tmp_path):
|
||||
import uvicorn
|
||||
|
||||
serve_source = inspect.getsource(cli.serve)
|
||||
assert serve_source.index("configure_hy_runtime_environment(settings.data_dir)") < (
|
||||
serve_source.index("from one_memory.api import create_app")
|
||||
)
|
||||
settings = SimpleNamespace(data_dir=tmp_path, host="127.0.0.1", port=12345)
|
||||
captured = {}
|
||||
startup_order = []
|
||||
marker = object()
|
||||
create_app_signature = inspect.signature(api.create_app)
|
||||
|
||||
def factory(*args, **kwargs):
|
||||
startup_order.append("create_app")
|
||||
create_app_signature.bind(*args, **kwargs)
|
||||
captured.update(kwargs)
|
||||
return marker
|
||||
@@ -27,12 +33,18 @@ def test_production_serve_passes_only_supported_app_options(monkeypatch, tmp_pat
|
||||
}
|
||||
|
||||
monkeypatch.setattr(cli, "load_settings", lambda: settings)
|
||||
monkeypatch.setattr(
|
||||
hy_runtime_config,
|
||||
"configure_hy_runtime_environment",
|
||||
lambda data_dir: startup_order.append(f"configure_hy:{data_dir}"),
|
||||
)
|
||||
monkeypatch.setattr(log_file, "start_logging_to_file", lambda _: None)
|
||||
monkeypatch.setattr(runtime, "parent_pid", lambda: None)
|
||||
monkeypatch.setattr(api, "create_app", factory)
|
||||
monkeypatch.setattr(uvicorn, "run", run)
|
||||
cli.serve()
|
||||
assert captured == {"manage_runtime": True}
|
||||
assert startup_order == [f"configure_hy:{tmp_path}", "create_app"]
|
||||
|
||||
|
||||
def test_maintenance_serve_does_not_own_the_runtime(monkeypatch, tmp_path):
|
||||
@@ -59,6 +71,11 @@ def test_maintenance_serve_does_not_own_the_runtime(monkeypatch, tmp_path):
|
||||
}
|
||||
|
||||
monkeypatch.setattr(cli, "load_settings", lambda: settings)
|
||||
monkeypatch.setattr(
|
||||
hy_runtime_config,
|
||||
"configure_hy_runtime_environment",
|
||||
lambda _data_dir: None,
|
||||
)
|
||||
monkeypatch.setattr(log_file, "start_logging_to_file", lambda _: None)
|
||||
monkeypatch.setattr(runtime, "parent_pid", lambda: None)
|
||||
monkeypatch.setattr(api, "create_app", factory)
|
||||
|
||||
@@ -119,14 +119,10 @@ def test_post_cutover_metadata_repair_restores_source_and_original_times(store):
|
||||
),
|
||||
)
|
||||
|
||||
import chromadb
|
||||
from chromadb.config import Settings as ChromaSettings
|
||||
from one_memory.sdk_vector_reattach import open_offline_chroma
|
||||
|
||||
vector = data_dir / "hy-memory" / "governed-v2" / "vector"
|
||||
collection = chromadb.PersistentClient(
|
||||
path=str(vector),
|
||||
settings=ChromaSettings(anonymized_telemetry=False),
|
||||
).get_or_create_collection("memory")
|
||||
collection = open_offline_chroma(vector).get_or_create_collection("memory")
|
||||
collection.add(
|
||||
ids=["sdk-1"],
|
||||
documents=["remember"],
|
||||
@@ -230,13 +226,10 @@ def test_metadata_repair_reaches_memories_the_cutover_backfilled_under_legacy_id
|
||||
"INSERT INTO evidence VALUES ('e7','workbuddy','2026-09-01T00:00:00+00:00',NULL)"
|
||||
)
|
||||
|
||||
import chromadb
|
||||
from chromadb.config import Settings as ChromaSettings
|
||||
from one_memory.sdk_vector_reattach import open_offline_chroma
|
||||
|
||||
vector = data_dir / "hy-memory" / "governed-v2" / "vector"
|
||||
client = chromadb.PersistentClient(
|
||||
path=str(vector), settings=ChromaSettings(anonymized_telemetry=False)
|
||||
)
|
||||
client = open_offline_chroma(vector)
|
||||
collection = client.get_or_create_collection("memory")
|
||||
imported = int(datetime(2026, 10, 20, tzinfo=UTC).timestamp())
|
||||
collection.add(
|
||||
@@ -256,9 +249,7 @@ def test_metadata_repair_reaches_memories_the_cutover_backfilled_under_legacy_id
|
||||
now=datetime(2026, 10, 25, tzinfo=UTC),
|
||||
)
|
||||
|
||||
client = chromadb.PersistentClient(
|
||||
path=str(vector), settings=ChromaSettings(anonymized_telemetry=False)
|
||||
)
|
||||
client = open_offline_chroma(vector)
|
||||
rows = client.get_collection("memory").get(ids=["legacy-7", "unlinked"], include=["metadatas"])
|
||||
client.close()
|
||||
metadata = dict(zip(rows["ids"], rows["metadatas"], strict=True))
|
||||
@@ -546,13 +537,9 @@ def test_finished_jobs_without_input_do_not_stop_the_migration(store):
|
||||
|
||||
|
||||
def _chroma(home: Path):
|
||||
import chromadb
|
||||
from chromadb.config import Settings as ChromaSettings
|
||||
from one_memory.sdk_vector_reattach import open_offline_chroma
|
||||
|
||||
return chromadb.PersistentClient(
|
||||
path=str(home / "hy-memory" / "governed-v2" / "vector"),
|
||||
settings=ChromaSettings(anonymized_telemetry=False),
|
||||
)
|
||||
return open_offline_chroma(home / "hy-memory" / "governed-v2" / "vector")
|
||||
|
||||
|
||||
def _fill(client, name: str, ids: list[str], *, text: str = "old") -> None:
|
||||
@@ -599,6 +586,38 @@ def test_legacy_vectors_are_renamed_and_newer_writes_are_kept(tmp_path) -> None:
|
||||
assert reattach_legacy_vectors(tmp_path, profile) == ()
|
||||
|
||||
|
||||
def test_the_readback_can_open_the_store_right_after_reattach(tmp_path, monkeypatch) -> None:
|
||||
"""The 1.0.4 upgrade failed its first launch at `verify.readback`.
|
||||
|
||||
Reattach opened Chroma with `anonymized_telemetry=False` and never closed
|
||||
it. The SDK then opened the same directory in the same process with
|
||||
default settings, and Chroma refused: "An instance of Chroma already
|
||||
exists ... with different settings". The second launch only passed because
|
||||
nothing was left to reattach.
|
||||
"""
|
||||
import chromadb
|
||||
|
||||
from one_memory.hy_sdk_runtime import builtin_retrieval_snapshot, vector_collection_bases
|
||||
from one_memory.sdk_vector_reattach import reattach_legacy_vectors
|
||||
|
||||
profile = builtin_retrieval_snapshot()
|
||||
current, legacy = vector_collection_bases(profile)
|
||||
seeded = _chroma(tmp_path)
|
||||
_fill(seeded, f"{legacy[0]}_4", ["a", "b"])
|
||||
seeded.close()
|
||||
# The migration process has not configured the SDK environment yet.
|
||||
monkeypatch.delenv("ANONYMIZED_TELEMETRY", raising=False)
|
||||
|
||||
assert len(reattach_legacy_vectors(tmp_path, profile)) == 1
|
||||
|
||||
# What the SDK does next, in the same process: no settings of its own.
|
||||
sdk = chromadb.PersistentClient(path=str(tmp_path / "hy-memory" / "governed-v2" / "vector"))
|
||||
try:
|
||||
assert sdk.get_collection(f"{current}_4").count() == 2
|
||||
finally:
|
||||
sdk.close()
|
||||
|
||||
|
||||
def _confirmed(store, *memory_ids: str, kind: str = "chat") -> None:
|
||||
with main_database(store.database_path).write() as database, database:
|
||||
for memory_id in memory_ids:
|
||||
|
||||
@@ -143,14 +143,24 @@ def test_a_finished_migration_records_the_result(home, monkeypatch):
|
||||
legacy_home(home)
|
||||
from one_memory.sdk_migration_runner import StartupMigrationResult
|
||||
|
||||
startup_order = []
|
||||
|
||||
def run_startup_migration(**options):
|
||||
startup_order.append("run_migration")
|
||||
options["progress"]("cutover")
|
||||
return StartupMigrationResult(state="cutover", migration_id="sdk-authority-v2-abc")
|
||||
|
||||
import one_memory.hy_runtime_config as hy_runtime_config
|
||||
import one_memory.sdk_migration_runner as runner
|
||||
|
||||
monkeypatch.setattr(
|
||||
hy_runtime_config,
|
||||
"configure_hy_runtime_environment",
|
||||
lambda data_dir: startup_order.append(f"configure_hy:{data_dir}"),
|
||||
)
|
||||
monkeypatch.setattr(runner, "run_startup_migration", run_startup_migration)
|
||||
assert cli.run(MIGRATE, output=StringIO()) == 0
|
||||
assert startup_order == [f"configure_hy:{home}", "run_migration"]
|
||||
finished = lines(home)[-1]
|
||||
assert finished["event"] == "migrate.finished"
|
||||
assert finished["migration_id"] == "sdk-authority-v2-abc"
|
||||
|
||||
@@ -246,21 +246,23 @@ it("shows HY pipeline metrics without exposing model content", async () => {
|
||||
const target = path.join(pipelineDir, "2026-10-14.log");
|
||||
await writeFile(target, "");
|
||||
const cursor = await captureDebugLogCursor(dataDir);
|
||||
await appendFile(
|
||||
target,
|
||||
`${JSON.stringify({
|
||||
request_id: "request-safe-1",
|
||||
user_id: "private-user",
|
||||
agent_id: "private-agent",
|
||||
session_id: "private-session",
|
||||
step: "EXTRACT",
|
||||
prompt: "private prompt",
|
||||
content: "private response",
|
||||
parsed: "private parsed output",
|
||||
tokens: 42,
|
||||
elapsed_ms: 123.5,
|
||||
timestamp: "2026-10-14T10:01:00.000",
|
||||
})}\n`,
|
||||
const pipelineEntry = {
|
||||
request_id: "request-safe-1",
|
||||
user_id: "private-user",
|
||||
agent_id: "private-agent",
|
||||
session_id: "private-session",
|
||||
step: "EXTRACT",
|
||||
prompt: "private prompt",
|
||||
content: "private response",
|
||||
parsed: "private parsed output",
|
||||
tokens: 42,
|
||||
elapsed_ms: 123.5,
|
||||
timestamp: "2026-10-14T10:01:00.000",
|
||||
};
|
||||
await appendFile(target, `${JSON.stringify(pipelineEntry)}\n`);
|
||||
await writeFile(
|
||||
path.join(dataDir, "hy-memory", "governed-v2", "logs", "hy_memory.log"),
|
||||
`${JSON.stringify({ ...pipelineEntry, request_id: "must-not-appear" })}\n`,
|
||||
);
|
||||
|
||||
const snapshot = await readDebugLogSnapshot(
|
||||
|
||||
@@ -18,6 +18,7 @@ const UI_TRACE = "one-memory:log-ui-trace";
|
||||
const DEBUG_LOGS = "one-memory:get-debug-logs";
|
||||
const DEBUG_LOG_NAME =
|
||||
/^diagnostic-(runtime|core|host-bridge|desktop|cli)\.jsonl(?:\.\d+)?$/;
|
||||
const PIPELINE_LOG_NAME = /^\d{4}-\d{2}-\d{2}\.log$/;
|
||||
const SERVICE_LOG_NAMES = [
|
||||
"service.log",
|
||||
"service-error.log",
|
||||
@@ -554,7 +555,11 @@ async function pipelineLogFiles(root: string): Promise<PipelineLogCursor[]> {
|
||||
await visit(target);
|
||||
continue;
|
||||
}
|
||||
if (!entry.isFile() || !entry.name.endsWith(".log")) continue;
|
||||
// The same root also contains HY's human-readable hy_memory.log.
|
||||
// Pipeline files are date-named JSONL; accepting every .log file makes
|
||||
// the Debug poller repeatedly parse unrelated text and still display no
|
||||
// HY entries.
|
||||
if (!entry.isFile() || !PIPELINE_LOG_NAME.test(entry.name)) continue;
|
||||
const info = await safeRegularFile(target);
|
||||
if (!info) continue;
|
||||
files.push({
|
||||
|
||||
@@ -199,7 +199,7 @@ it("localizes failure and empty states without leaking errors", async () => {
|
||||
).toBeTruthy();
|
||||
expect(document.body.textContent).not.toContain("synthetic_private_error");
|
||||
});
|
||||
it("shows latest recorded call separately from newly generated memory and opens its history", async () => {
|
||||
it("shows latest recorded call separately without exposing recent-update detail entries", async () => {
|
||||
history.mockResolvedValue({
|
||||
available: true,
|
||||
recording_error: false,
|
||||
@@ -233,12 +233,11 @@ it("shows latest recorded call separately from newly generated memory and opens
|
||||
await screen.findByText("Codex 查到 3 条相关内容");
|
||||
expect(document.querySelector(".r6-home-call-direction")).toBeTruthy();
|
||||
expect(screen.queryByText("实际治理记忆")).toBeNull();
|
||||
expect(screen.getByText(/今天 .+ · 查询成功/)).toBeTruthy();
|
||||
expect(screen.getByText(/Codex · 今天 .+ · 查询成功/)).toBeTruthy();
|
||||
fireEvent.click(screen.getByRole("tab", { name: "最近更新" }));
|
||||
expect(screen.getByText("实际治理记忆")).toBeTruthy();
|
||||
const memoryActions = screen.getAllByRole("button", { name: "查看记忆" });
|
||||
fireEvent.click(memoryActions[memoryActions.length - 1]);
|
||||
expect(onMemories).toHaveBeenCalledWith("m1");
|
||||
expect(screen.queryByRole("button", { name: "查看记忆" })).toBeNull();
|
||||
expect(onMemories).not.toHaveBeenCalled();
|
||||
fireEvent.click(screen.getByRole("tab", { name: "最近调用" }));
|
||||
expect(history).toHaveBeenCalledWith({ limit: 20, offset: 0 });
|
||||
if (CALL_HISTORY_ENTRY_VISIBLE) {
|
||||
@@ -247,11 +246,56 @@ it("shows latest recorded call separately from newly generated memory and opens
|
||||
} else {
|
||||
// D96: the recent call still shows on the home page; only the way into
|
||||
// the call-history page is hidden.
|
||||
expect(screen.getByText(/今天 .+ · 查询成功/)).toBeTruthy();
|
||||
expect(screen.getByText(/Codex · 今天 .+ · 查询成功/)).toBeTruthy();
|
||||
expect(screen.queryByRole("button", { name: "查看记录" })).toBeNull();
|
||||
expect(onCalls).not.toHaveBeenCalled();
|
||||
}
|
||||
});
|
||||
it("skips direct diagnostics and shows the latest attributed application call", async () => {
|
||||
history.mockResolvedValue({
|
||||
available: true,
|
||||
recording_error: false,
|
||||
items: [
|
||||
{
|
||||
event_id: "direct-diagnostic",
|
||||
host_id: null,
|
||||
state: "grounded",
|
||||
occurred_at: new Date().toISOString(),
|
||||
memory_count: 1,
|
||||
raw_count: 0,
|
||||
references: [],
|
||||
},
|
||||
{
|
||||
event_id: "workbuddy-call",
|
||||
host_id: "workbuddy",
|
||||
state: "grounded",
|
||||
occurred_at: new Date(Date.now() - 60_000).toISOString(),
|
||||
memory_count: 5,
|
||||
raw_count: 0,
|
||||
references: [],
|
||||
},
|
||||
],
|
||||
});
|
||||
render(
|
||||
<ApplicationsHome
|
||||
imports={imports}
|
||||
onManage={() => {}}
|
||||
onMemories={() => {}}
|
||||
onSettings={() => {}}
|
||||
onCalls={() => {}}
|
||||
/>,
|
||||
);
|
||||
|
||||
fireEvent.click(await screen.findByRole("tab", { name: "最近调用" }));
|
||||
expect(await screen.findByText("WorkBuddy 查到 5 条相关内容")).toBeTruthy();
|
||||
expect(screen.queryByText(/直接调用/)).toBeNull();
|
||||
expect(screen.getByText(/WorkBuddy · 今天 .+ · 查询成功/)).toBeTruthy();
|
||||
const direction = document.querySelector(".r6-home-call-direction");
|
||||
expect(direction?.querySelector("img")?.getAttribute("src")).toContain(
|
||||
"/r6/workbuddy.png",
|
||||
);
|
||||
expect(direction?.querySelector(".r6-app-initial")).toBeNull();
|
||||
});
|
||||
it("shows only the host for memory hits whose references have no source", async () => {
|
||||
history.mockResolvedValue({
|
||||
available: true,
|
||||
@@ -834,10 +878,38 @@ it.each([null, "", "not-a-date"])(
|
||||
/>,
|
||||
);
|
||||
await screen.findByText("实际治理记忆");
|
||||
expect(screen.getByText(/时间未知.*版本 2/)).toBeTruthy();
|
||||
expect(screen.getByText("时间未知 · App 0")).toBeTruthy();
|
||||
},
|
||||
);
|
||||
|
||||
it("shows only time and source for an SDK recent update", async () => {
|
||||
summary.mockResolvedValue({
|
||||
memory_count: 1,
|
||||
latest: {
|
||||
memory_id: "sdk-memory-1",
|
||||
content: "SDK 原生记忆",
|
||||
layer: "l2_fact",
|
||||
source: "app-0",
|
||||
gmt_created: 1_761_000_000,
|
||||
},
|
||||
observed_at: "2026-09-20T09:00:00Z",
|
||||
});
|
||||
|
||||
render(
|
||||
<ApplicationsHome
|
||||
imports={imports}
|
||||
onManage={() => {}}
|
||||
onMemories={() => {}}
|
||||
onSettings={() => {}}
|
||||
/>,
|
||||
);
|
||||
|
||||
await screen.findByText("SDK 原生记忆");
|
||||
expect(screen.getByText(/^\d{2}:\d{2} · App 0$/)).toBeTruthy();
|
||||
expect(document.body.textContent).not.toContain("SDK l2_fact");
|
||||
expect(screen.queryByRole("button", { name: "查看记忆" })).toBeNull();
|
||||
});
|
||||
|
||||
it("does not treat an available adapter as an installed application", async () => {
|
||||
render(
|
||||
<ApplicationsHome
|
||||
|
||||
@@ -83,13 +83,6 @@ function stamped(value: string, locale: GuiLocale, t: Translate, suffix = "") {
|
||||
return `${parts.today ? t("apps.today") : ""}${parts.clock}${suffix}`;
|
||||
}
|
||||
|
||||
function memoryAction(action: string | undefined, t: Translate) {
|
||||
if (action === "create") return t("apps.newMemory");
|
||||
if (action === "correct" || action === "restore")
|
||||
return t("apps.updatedMemory");
|
||||
return t("apps.saved");
|
||||
}
|
||||
|
||||
type ActivityChoice = "call" | "update";
|
||||
|
||||
function sourceMatchesHost(sourceId: string, hostId: string | null) {
|
||||
@@ -101,6 +94,10 @@ function successfulCall(item: CallEvent) {
|
||||
return item.state === "grounded" || item.state === "empty";
|
||||
}
|
||||
|
||||
function attributedSuccessfulCall(item: CallEvent) {
|
||||
return item.host_id !== null && successfulCall(item);
|
||||
}
|
||||
|
||||
function callAttention(events: CallEvent[]) {
|
||||
const hosts = new Set<string>();
|
||||
for (const event of events) {
|
||||
@@ -219,7 +216,10 @@ export function ApplicationsHome({
|
||||
const result = await getCallHistory({ limit: 20, offset: 0 });
|
||||
if (cancelled) return;
|
||||
setCallEvents(result.items);
|
||||
setRecentCall(result.items.find(successfulCall) ?? null);
|
||||
// The home card promises an application identity. Direct diagnostic
|
||||
// calls have no host and remain available in call history, but cannot
|
||||
// truthfully supply the icon and name required by this surface.
|
||||
setRecentCall(result.items.find(attributedSuccessfulCall) ?? null);
|
||||
setCallState(
|
||||
!result.available
|
||||
? "disabled"
|
||||
@@ -604,6 +604,9 @@ export function ApplicationsHome({
|
||||
const recentCallHost = imports.sources.find((source) =>
|
||||
sourceMatchesHost(source.source_id, recentCall?.host_id ?? null),
|
||||
);
|
||||
const recentCallHostName = recentCall
|
||||
? (recentCallHost?.display_name ?? callHostName(recentCall.host_id, locale))
|
||||
: null;
|
||||
const attention = callAttention(callEvents);
|
||||
const attentionHost = attention ? callHostName(attention.host, locale) : null;
|
||||
const activity = summaryUnavailable
|
||||
@@ -665,6 +668,11 @@ export function ApplicationsHome({
|
||||
: latest.source
|
||||
? [latest.source]
|
||||
: [];
|
||||
const latestSourceNames = latestSourceIds.map(
|
||||
(id) =>
|
||||
imports.sources.find((source) => source.source_id === id)?.display_name ??
|
||||
id,
|
||||
);
|
||||
const time = latestClock?.clock ?? (latest ? t("apps.unknownTime") : null);
|
||||
const checked = visible ? clockParts(visible.observed_at, locale) : null;
|
||||
// A failed read leaves the last health in place on purpose: clearing it would
|
||||
@@ -936,8 +944,7 @@ export function ApplicationsHome({
|
||||
: activity === "call" && recentCall
|
||||
? callDetailHeadline(
|
||||
recentCall,
|
||||
recentCallHost?.display_name ??
|
||||
callHostName(recentCall.host_id, locale),
|
||||
recentCallHostName ?? undefined,
|
||||
locale,
|
||||
)
|
||||
: activity === "memory" && latest
|
||||
@@ -958,19 +965,18 @@ export function ApplicationsHome({
|
||||
? t("apps.callReadFailure")
|
||||
: t("apps.memoryReadFailure")
|
||||
: activity === "call" && recentCall
|
||||
? `${stamped(recentCall.occurred_at, locale, t)} · ${callRowDetail(recentCall, locale)}${callState === "incomplete" ? t("apps.incompleteCalls") : ""}`
|
||||
? `${recentCallHostName} · ${stamped(recentCall.occurred_at, locale, t)} · ${callRowDetail(recentCall, locale)}${callState === "incomplete" ? t("apps.incompleteCalls") : ""}`
|
||||
: activity === "memory" && latest
|
||||
? "revision" in latest
|
||||
? `${time} · ${memoryAction(latest.action, t)} · ${latestSourceIds.map((id) => imports.sources.find((s) => s.source_id === id)?.display_name ?? id).join(", ")} · ${t("apps.revision", { count: new Intl.NumberFormat(locale).format(latest.revision) })}`
|
||||
: `${time} · SDK ${latest.layer} · ${latestSourceIds.map((id) => imports.sources.find((s) => s.source_id === id)?.display_name ?? id).join("、")}`
|
||||
? [time, latestSourceNames.join(", ")]
|
||||
.filter(Boolean)
|
||||
.join(" · ")
|
||||
: activity === "importing"
|
||||
? t("apps.savingConversations")
|
||||
: t("apps.waitingMemories")}
|
||||
</small>
|
||||
</section>
|
||||
{(activity === "unavailable" ||
|
||||
(activity === "call" && CALL_HISTORY_ENTRY_VISIBLE) ||
|
||||
activity === "memory") && (
|
||||
(activity === "call" && CALL_HISTORY_ENTRY_VISIBLE)) && (
|
||||
<button
|
||||
type="button"
|
||||
className="r6-home-activity-link"
|
||||
|
||||
@@ -58,6 +58,40 @@ it.each(["zh-CN", "en", "es"] as const)(
|
||||
},
|
||||
);
|
||||
|
||||
it.each(["system", "zh-CN", "en", "es"] as const)(
|
||||
"keeps one stable settings row for the %s preference",
|
||||
(preference) => {
|
||||
localStorage.setItem(LANGUAGE_STORAGE_KEY, preference);
|
||||
render(
|
||||
<LanguageProvider>
|
||||
<LanguageSelector />
|
||||
</LanguageProvider>,
|
||||
);
|
||||
const selector = screen.getByRole("combobox") as HTMLSelectElement;
|
||||
const row = selector.closest(".r6-language-setting-row");
|
||||
expect(selector.value).toBe(preference);
|
||||
expect(row).toBeTruthy();
|
||||
expect(row?.querySelector("label")?.hidden).toBe(false);
|
||||
expect(
|
||||
screen
|
||||
.getByRole("option", {
|
||||
name: /简体中文|Simplified Chinese|Chino simplificado/,
|
||||
})
|
||||
.getAttribute("lang"),
|
||||
).toBe("zh-CN");
|
||||
expect(
|
||||
screen
|
||||
.getByRole("option", { name: /英语|English|Inglés/ })
|
||||
.getAttribute("lang"),
|
||||
).toBe("en");
|
||||
expect(
|
||||
screen
|
||||
.getByRole("option", { name: /西班牙语|Spanish|Español/ })
|
||||
.getAttribute("lang"),
|
||||
).toBe("es");
|
||||
},
|
||||
);
|
||||
|
||||
it("offers an accessible selector, persists and updates its label", async () => {
|
||||
localStorage.setItem(LANGUAGE_STORAGE_KEY, "zh-CN");
|
||||
render(
|
||||
|
||||
@@ -13,8 +13,16 @@ export function LanguageSelector({ compact = false }: { compact?: boolean }) {
|
||||
compact ? "r6-language-selector--compact" : "r6-language-setting"
|
||||
}
|
||||
>
|
||||
{!compact && <h2 id={`${id}-title`}>{t("languageTitle")}</h2>}
|
||||
<div className={compact ? "r6-language-selector-row" : "r6-setting-row"}>
|
||||
{!compact && (
|
||||
<h2 id={`${id}-title`} className="r6-language-setting-title">
|
||||
{t("languageTitle")}
|
||||
</h2>
|
||||
)}
|
||||
<div
|
||||
className={
|
||||
compact ? "r6-language-selector-row" : "r6-language-setting-row"
|
||||
}
|
||||
>
|
||||
<label id={`${id}-label`} htmlFor={`${id}-select`} hidden={compact}>
|
||||
{t(compact ? "languageTitle" : "languageLabel")}
|
||||
</label>
|
||||
@@ -36,18 +44,22 @@ export function LanguageSelector({ compact = false }: { compact?: boolean }) {
|
||||
}}
|
||||
>
|
||||
<option value="system">{t("languageSystem")}</option>
|
||||
<option value="zh-CN" lang={compact ? "zh-CN" : undefined}>
|
||||
<option value="zh-CN" lang="zh-CN">
|
||||
{compact ? "简体中文" : t("languageChinese")}
|
||||
</option>
|
||||
<option value="en" lang={compact ? "en" : undefined}>
|
||||
<option value="en" lang="en">
|
||||
{compact ? "English" : t("languageEnglish")}
|
||||
</option>
|
||||
<option value="es" lang={compact ? "es" : undefined}>
|
||||
<option value="es" lang="es">
|
||||
{compact ? "Español" : t("languageSpanish")}
|
||||
</option>
|
||||
</select>
|
||||
</div>
|
||||
{!compact && <p id={`${id}-description`}>{t("languageDescription")}</p>}
|
||||
{!compact && (
|
||||
<p id={`${id}-description`} className="r6-language-setting-description">
|
||||
{t("languageDescription")}
|
||||
</p>
|
||||
)}
|
||||
{error && (
|
||||
<p role="alert" className="r6-settings-error">
|
||||
{t(error)}
|
||||
|
||||
@@ -82,3 +82,38 @@ it("SYS-01: reduced motion skips animation and does not require animationend", (
|
||||
);
|
||||
expect(screen.queryByRole("dialog")).toBeNull();
|
||||
});
|
||||
|
||||
it("starts a new or resumed detail at the top without resetting the current detail", () => {
|
||||
vi.stubGlobal("matchMedia", () => ({
|
||||
matches: false,
|
||||
addEventListener() {},
|
||||
removeEventListener() {},
|
||||
}));
|
||||
const content = (scrollResetKey: string, suspended = false) => (
|
||||
<MemoryEvidencePanel
|
||||
open
|
||||
suspended={suspended}
|
||||
scrollResetKey={scrollResetKey}
|
||||
onClose={() => {}}
|
||||
>
|
||||
<p>{scrollResetKey}</p>
|
||||
</MemoryEvidencePanel>
|
||||
);
|
||||
const view = render(content("memory-a"));
|
||||
const panel = view.container.querySelector<HTMLElement>(".map-inspector");
|
||||
expect(panel).toBeTruthy();
|
||||
if (!panel) return;
|
||||
|
||||
panel.scrollTop = 180;
|
||||
view.rerender(content("memory-a"));
|
||||
expect(panel.scrollTop).toBe(180);
|
||||
|
||||
view.rerender(content("memory-b"));
|
||||
expect(panel.scrollTop).toBe(0);
|
||||
|
||||
panel.scrollTop = 120;
|
||||
view.rerender(content("memory-b", true));
|
||||
expect(panel.scrollTop).toBe(120);
|
||||
view.rerender(content("memory-b"));
|
||||
expect(panel.scrollTop).toBe(0);
|
||||
});
|
||||
@@ -15,11 +15,14 @@ export function MemoryEvidencePanel({
|
||||
onClose,
|
||||
children,
|
||||
suspended = false,
|
||||
scrollResetKey,
|
||||
}: {
|
||||
open: boolean;
|
||||
onClose(): void;
|
||||
children: ReactNode;
|
||||
suspended?: boolean;
|
||||
/** A different detail starts at the top instead of inheriting old scroll. */
|
||||
scrollResetKey?: string | null;
|
||||
}) {
|
||||
const { text } = useMemoryLanguage();
|
||||
const compact = useMediaPreference("(max-width: 1100px)");
|
||||
@@ -37,9 +40,21 @@ export function MemoryEvidencePanel({
|
||||
reduceMotion,
|
||||
reduceTransparency,
|
||||
});
|
||||
const priorScrollOwner = useRef({ key: scrollResetKey, suspended });
|
||||
const present = !compact || open || phase !== "closed";
|
||||
const modal = compact && present && !suspended;
|
||||
|
||||
useLayoutEffect(() => {
|
||||
const previous = priorScrollOwner.current;
|
||||
priorScrollOwner.current = { key: scrollResetKey, suspended };
|
||||
if (
|
||||
!suspended &&
|
||||
(previous.key !== scrollResetKey || previous.suspended) &&
|
||||
panel.current
|
||||
)
|
||||
panel.current.scrollTop = 0;
|
||||
}, [scrollResetKey, suspended]);
|
||||
|
||||
useLayoutEffect(() => {
|
||||
const element = panel.current;
|
||||
if (!element) return;
|
||||
|
||||
@@ -186,18 +186,17 @@ it("OBS-19: Enter in the memory filter submits it; submitting it again re-reads"
|
||||
/>,
|
||||
);
|
||||
await act(async () => {});
|
||||
await userEvent.click(screen.getByRole("button", { name: "刷新地图记忆" }));
|
||||
expect(onRetry).toHaveBeenCalledTimes(1);
|
||||
expect(screen.queryByRole("button", { name: "刷新地图记忆" })).toBeNull();
|
||||
// Typing alone does not search; Enter submits the words.
|
||||
const input = screen.getByRole("textbox", { name: "筛选地图记忆" });
|
||||
await userEvent.type(input, "第一");
|
||||
expect(onQuery).not.toHaveBeenCalled();
|
||||
await userEvent.type(input, "{Enter}");
|
||||
expect(onQuery).toHaveBeenCalledWith("第一");
|
||||
// Submitting the words already applied re-reads, like the refresh button.
|
||||
// Submitting the currently applied query re-reads without a separate row.
|
||||
await userEvent.clear(input);
|
||||
await userEvent.type(input, "{Enter}");
|
||||
expect(onRetry).toHaveBeenCalledTimes(2);
|
||||
expect(onRetry).toHaveBeenCalledOnce();
|
||||
});
|
||||
|
||||
it("searches only when 搜索 is pressed, not on every keystroke", async () => {
|
||||
@@ -223,6 +222,35 @@ it("searches only when 搜索 is pressed, not on every keystroke", async () => {
|
||||
expect(resetPage).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("clearing a submitted keyword immediately shows every memory from the selected source", async () => {
|
||||
const onQuery = vi.fn();
|
||||
const onSource = vi.fn();
|
||||
const resetPage = vi.fn();
|
||||
render(
|
||||
<Host
|
||||
simplified
|
||||
libraryView={{
|
||||
...pagedView({ items: [], total: 0, offset: 0, limit: 100 }),
|
||||
query: "github",
|
||||
source: "workbuddy",
|
||||
onQuery,
|
||||
onSource,
|
||||
resetPage,
|
||||
}}
|
||||
/>,
|
||||
);
|
||||
|
||||
const input = screen.getByRole<HTMLInputElement>("textbox", {
|
||||
name: "筛选地图记忆",
|
||||
});
|
||||
expect(input.value).toBe("github");
|
||||
await userEvent.clear(input);
|
||||
|
||||
expect(onQuery).toHaveBeenCalledExactlyOnceWith("");
|
||||
expect(resetPage).toHaveBeenCalledOnce();
|
||||
expect(onSource).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("OBS-15: switching memory shows a read in progress instead of the previous memory", async () => {
|
||||
const user = userEvent.setup();
|
||||
// The read is deliberately never answered: what matters is what is on
|
||||
@@ -510,6 +538,12 @@ it("D96: lists a memory by when its conversation happened, not when it was gener
|
||||
/共 519 条,第 2 \/ 6 页/,
|
||||
),
|
||||
).toBeTruthy();
|
||||
expect(screen.getAllByText(/共 519 条/)).toHaveLength(1);
|
||||
expect(
|
||||
screen
|
||||
.getByRole("navigation", { name: "地图记忆分页" })
|
||||
.classList.contains("map-pagination"),
|
||||
).toBe(true);
|
||||
});
|
||||
|
||||
it("names the SDK's no-source bucket 未识别来源, apart from a source under review", () => {
|
||||
|
||||
@@ -16,7 +16,6 @@ import {
|
||||
ChevronRight,
|
||||
FileText,
|
||||
Network,
|
||||
RefreshCw,
|
||||
Search,
|
||||
X,
|
||||
} from "lucide-react";
|
||||
@@ -679,7 +678,18 @@ export function MemoryMap(props: Props) {
|
||||
}}
|
||||
onChange={(e) => {
|
||||
if (library) {
|
||||
setSearchDraft(e.target.value);
|
||||
const next = e.target.value;
|
||||
setSearchDraft(next);
|
||||
// An empty search box means "no keyword filter". Keeping the
|
||||
// last submitted query active after the reader removed its
|
||||
// text made the source picker look empty even when that app
|
||||
// had memories.
|
||||
if (!next && library.query.trim()) {
|
||||
setFilterNotice("");
|
||||
clear();
|
||||
library.onQuery("");
|
||||
library.resetPage();
|
||||
}
|
||||
return;
|
||||
}
|
||||
setFilterNotice("");
|
||||
@@ -696,43 +706,17 @@ export function MemoryMap(props: Props) {
|
||||
</button>
|
||||
) : null}
|
||||
</form>
|
||||
{library && (
|
||||
<div className="map-list-heading" aria-live="polite">
|
||||
<span>
|
||||
{library.stale
|
||||
? library.page && library.page.total > 0
|
||||
? text(
|
||||
"共 {count} 条 · 正在核对来源…",
|
||||
{ count: plain(library.page.total) },
|
||||
library.page.total,
|
||||
)
|
||||
: text("正在核对来源…")
|
||||
: library.failed
|
||||
? text("读取失败")
|
||||
: library.loading && library.page?.total === 0
|
||||
? text("正在读取…")
|
||||
: library.page
|
||||
? `${text(
|
||||
"共 {count} 条",
|
||||
{ count: plain(library.page.total) },
|
||||
library.page.total,
|
||||
)}${refreshing ? text(" · 正在核对…") : ""}`
|
||||
: text("正在读取…")}
|
||||
</span>
|
||||
{!library.stale && (
|
||||
<button
|
||||
type="button"
|
||||
className="quiet map-tool"
|
||||
aria-label={text("刷新地图记忆")}
|
||||
title={text("刷新地图记忆")}
|
||||
disabled={library.loading || hidden}
|
||||
onClick={library.onRetry}
|
||||
>
|
||||
<RefreshCw size={16} aria-hidden="true" />
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
{library && (library.loading || library.stale || library.failed) ? (
|
||||
<p className="map-list-status" aria-live="polite">
|
||||
{library.failed
|
||||
? text("读取失败")
|
||||
: library.stale
|
||||
? text("正在核对来源…")
|
||||
: refreshing && library.page?.total !== 0
|
||||
? text("正在核对…")
|
||||
: text("正在读取…")}
|
||||
</p>
|
||||
) : null}
|
||||
{!props.simplified && (
|
||||
<div className="map-groups">
|
||||
<button
|
||||
@@ -873,7 +857,7 @@ export function MemoryMap(props: Props) {
|
||||
!library.loading &&
|
||||
!library.failed &&
|
||||
!library.stale && (
|
||||
<nav aria-label={text("地图记忆分页")} className="map-list-heading">
|
||||
<nav aria-label={text("地图记忆分页")} className="map-pagination">
|
||||
<button
|
||||
type="button"
|
||||
className="quiet map-tool"
|
||||
@@ -1089,6 +1073,7 @@ export function MemoryMap(props: Props) {
|
||||
<MemoryEvidencePanel
|
||||
open={inspectorOpen && !!(activeId || visibleGroupNode)}
|
||||
suspended={overlaysSuspended}
|
||||
scrollResetKey={activeId ?? visibleGroupNode?.node_id ?? "empty"}
|
||||
onClose={closeInspector}
|
||||
>
|
||||
<header className="map-inspector-heading">
|
||||
|
||||
@@ -26,7 +26,7 @@ it("opens a Spanish composer without saving, processing or enabling anything", a
|
||||
screen.getByRole("textbox", { name: "¿Qué quieres recordar?" }),
|
||||
).toBeTruthy();
|
||||
expect(
|
||||
(screen.getByRole("button", { name: "Guardar texto" }) as HTMLButtonElement)
|
||||
(screen.getByRole("button", { name: "Guardar" }) as HTMLButtonElement)
|
||||
.disabled,
|
||||
).toBe(true);
|
||||
await userEvent.click(
|
||||
@@ -165,12 +165,12 @@ it("saves from an empty library with the linked-app scope by default", async ()
|
||||
expect(document.activeElement).toBe(input);
|
||||
await userEvent.type(input, note.text);
|
||||
expect(
|
||||
screen.getByRole("button", { name: "保存原话" }).hasAttribute("disabled"),
|
||||
screen.getByRole("button", { name: "保存" }).hasAttribute("disabled"),
|
||||
).toBe(false);
|
||||
expect(
|
||||
screen.queryByRole("checkbox", { name: /允许已关联应用读取/ }),
|
||||
).toBeNull();
|
||||
await userEvent.click(screen.getByRole("button", { name: "保存原话" }));
|
||||
await userEvent.click(screen.getByRole("button", { name: "保存" }));
|
||||
await waitFor(() =>
|
||||
expect(api.saveMemoryNote).toHaveBeenCalledWith(
|
||||
{
|
||||
@@ -208,7 +208,7 @@ it("preserves draft and request identity after an uncertain save", async () => {
|
||||
page();
|
||||
await userEvent.click(screen.getByRole("button", { name: "添加记忆" }));
|
||||
await userEvent.type(screen.getByRole("textbox"), note.text);
|
||||
await userEvent.click(screen.getByRole("button", { name: "保存原话" }));
|
||||
await userEvent.click(screen.getByRole("button", { name: "保存" }));
|
||||
expect(await screen.findByRole("alert")).toBeTruthy();
|
||||
expect((screen.getByRole("textbox") as HTMLTextAreaElement).value).toBe(
|
||||
note.text,
|
||||
@@ -219,7 +219,7 @@ it("preserves draft and request identity after an uncertain save", async () => {
|
||||
expect(result?.status).toBe("unknown");
|
||||
expect(result?.reason).toBe("save_result_unconfirmed");
|
||||
expect(result?.status).not.toBe("failed");
|
||||
await userEvent.click(screen.getByRole("button", { name: "保存原话" }));
|
||||
await userEvent.click(screen.getByRole("button", { name: "保存" }));
|
||||
expect(api.saveMemoryNote.mock.calls[0][0].request_id).toBe(
|
||||
api.saveMemoryNote.mock.calls[1][0].request_id,
|
||||
);
|
||||
@@ -235,7 +235,7 @@ it("closes as saved when the POST response is lost but the operation exists", as
|
||||
const callbacks = page();
|
||||
await userEvent.click(screen.getByRole("button", { name: "添加记忆" }));
|
||||
await userEvent.type(screen.getByRole("textbox"), note.text);
|
||||
await userEvent.click(screen.getByRole("button", { name: "保存原话" }));
|
||||
await userEvent.click(screen.getByRole("button", { name: "保存" }));
|
||||
await waitFor(() =>
|
||||
expect(api.getMemoryNoteOperation).toHaveBeenCalledOnce(),
|
||||
);
|
||||
@@ -292,7 +292,7 @@ it("does not ask for separate consent when an old note is waiting for permission
|
||||
page();
|
||||
await userEvent.click(screen.getByRole("button", { name: "添加记忆" }));
|
||||
|
||||
expect(await screen.findByText("原话已保存,等待整理")).toBeTruthy();
|
||||
expect(await screen.findByText("正在等待整理")).toBeTruthy();
|
||||
expect(screen.queryByRole("button", { name: "模型授权" })).toBeNull();
|
||||
await userEvent.click(screen.getByRole("button", { name: "重新整理" }));
|
||||
expect(api.processMemoryNote).toHaveBeenCalledExactlyOnceWith(
|
||||
|
||||
@@ -16,8 +16,8 @@ import { isDefiniteRefusal } from "./write-outcome";
|
||||
|
||||
const states: Record<MemoryNoteReceipt["state"], string> = {
|
||||
waiting_for_model: "原话已保存,等待可用模型",
|
||||
waiting_for_permission: "原话已保存,等待整理",
|
||||
pending: "原话已保存,等待整理",
|
||||
waiting_for_permission: "正在等待整理",
|
||||
pending: "正在等待整理",
|
||||
running: "正在整理",
|
||||
failed: "原话已保存,整理未完成",
|
||||
ready: "整理已完成",
|
||||
@@ -371,7 +371,7 @@ export function MemoryNoteComposer({
|
||||
</button>
|
||||
)}
|
||||
<button type="submit" disabled={busy || !text.trim()}>
|
||||
{copy(busy ? "保存中…" : "保存原话")}
|
||||
{copy(busy ? "保存中…" : "保存")}
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
|
||||
@@ -25,9 +25,7 @@ it("translates memory filters and detail affordances live without refetching", a
|
||||
expect(
|
||||
screen.getByRole("textbox", { name: "Filtrar memorias del mapa" }),
|
||||
).toBeTruthy();
|
||||
expect(
|
||||
screen.getByRole("button", { name: "Actualizar memorias" }),
|
||||
).toBeTruthy();
|
||||
expect(screen.getByRole("button", { name: "Buscar" })).toBeTruthy();
|
||||
const calls = reads.sdkLibrary.mock.calls.length;
|
||||
act(() => {
|
||||
localStorage.setItem(LANGUAGE_STORAGE_KEY, "zh-CN");
|
||||
@@ -36,6 +34,7 @@ it("translates memory filters and detail affordances live without refetching", a
|
||||
);
|
||||
});
|
||||
expect(screen.getByRole("heading", { name: "记忆", level: 1 })).toBeTruthy();
|
||||
expect(screen.getByRole("button", { name: "搜索" })).toBeTruthy();
|
||||
expect(reads.sdkLibrary).toHaveBeenCalledTimes(calls);
|
||||
localStorage.clear();
|
||||
});
|
||||
|
||||
@@ -772,6 +772,31 @@ button.rail-button.active::before {
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
.map-pagination {
|
||||
display: flex;
|
||||
flex: none;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 8px;
|
||||
margin-top: auto;
|
||||
padding: 12px 6px 0;
|
||||
border-top: 1px solid var(--bd);
|
||||
color: var(--tx3);
|
||||
font-size: 12px;
|
||||
}
|
||||
.map-pagination > span {
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
.map-list-status {
|
||||
flex: none;
|
||||
margin: 0;
|
||||
padding: 4px;
|
||||
color: var(--tx3);
|
||||
font-size: 12px;
|
||||
line-height: 20px;
|
||||
}
|
||||
.map-filter-notice {
|
||||
font-size: 12px;
|
||||
line-height: 1.65;
|
||||
|
||||
@@ -24,14 +24,31 @@
|
||||
border-radius: 16px;
|
||||
background: #fff;
|
||||
}
|
||||
.r6-settings .r6-language-setting h2 {
|
||||
margin: 0 0 12px;
|
||||
.r6-language-setting-title {
|
||||
margin: 0 0 18px;
|
||||
font-size: 18px;
|
||||
line-height: 28px;
|
||||
}
|
||||
.r6-settings .r6-language-setting-row {
|
||||
display: grid;
|
||||
grid-template-columns: 96px minmax(0, 1fr);
|
||||
align-items: center;
|
||||
gap: 16px;
|
||||
min-height: 44px;
|
||||
}
|
||||
.r6-settings .r6-language-setting-row label {
|
||||
min-width: 0;
|
||||
font-size: 14px;
|
||||
line-height: 24px;
|
||||
white-space: nowrap;
|
||||
}
|
||||
.r6-settings .r6-language-setting select {
|
||||
box-sizing: border-box;
|
||||
width: 100%;
|
||||
max-width: 100%;
|
||||
min-height: 36px;
|
||||
padding: 6px 10px;
|
||||
min-width: 0;
|
||||
min-height: 44px;
|
||||
padding: 8px 12px;
|
||||
border: 1px solid #dce4eb;
|
||||
border-radius: 8px;
|
||||
color: inherit;
|
||||
@@ -42,8 +59,11 @@
|
||||
outline: 2px solid #225f9d;
|
||||
outline-offset: 2px;
|
||||
}
|
||||
.r6-settings .r6-language-setting > p {
|
||||
margin: 12px 0 0;
|
||||
.r6-language-setting-description {
|
||||
margin: 16px 0 0;
|
||||
color: #5e6e7a;
|
||||
font-size: 13px;
|
||||
line-height: 22px;
|
||||
overflow-wrap: anywhere;
|
||||
}
|
||||
.r6-settings .r6-settings-layout {
|
||||
|
||||
@@ -112,6 +112,8 @@
|
||||
.r6-shell .r6-memory .map-search {
|
||||
height: 44px;
|
||||
margin: 13px 0 21px;
|
||||
gap: 7px;
|
||||
padding: 0 4px 0 12px;
|
||||
border: 1px solid #c6d2da;
|
||||
background: rgb(255 255 255 / 50%);
|
||||
border-radius: 8px;
|
||||
@@ -120,7 +122,33 @@
|
||||
min-height: 40px;
|
||||
font-size: 13px;
|
||||
}
|
||||
.r6-shell .r6-memory .map-search-submit {
|
||||
min-height: 34px;
|
||||
padding: 0 12px;
|
||||
border: 1px solid rgb(64 87 103 / 18%);
|
||||
border-radius: 6px;
|
||||
background: #4f6373;
|
||||
color: #fff;
|
||||
font:
|
||||
500 12px / 20px "Noto Sans SC",
|
||||
"PingFang SC",
|
||||
sans-serif;
|
||||
box-shadow: inset 0 1px 0 rgb(255 255 255 / 18%);
|
||||
transition:
|
||||
background-color var(--r6-fast),
|
||||
border-color var(--r6-fast);
|
||||
}
|
||||
.r6-shell .r6-memory .map-search-submit:hover:not(:disabled) {
|
||||
background: #435966;
|
||||
border-color: rgb(48 71 87 / 28%);
|
||||
}
|
||||
.r6-shell .r6-memory .map-search-submit:focus-visible {
|
||||
outline: 2px solid #557c93;
|
||||
outline-offset: 2px;
|
||||
}
|
||||
.r6-shell .r6-memory .map-memory-list {
|
||||
flex: 1 1 0;
|
||||
min-height: 0;
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
}
|
||||
@@ -208,6 +236,9 @@
|
||||
font-weight: 500;
|
||||
}
|
||||
.selected-memory-detail {
|
||||
display: flex;
|
||||
min-width: 0;
|
||||
flex-direction: column;
|
||||
color: #384a57;
|
||||
font-family: "Noto Sans SC", "PingFang SC", sans-serif;
|
||||
}
|
||||
@@ -228,6 +259,8 @@
|
||||
}
|
||||
.selected-memory-body {
|
||||
display: block;
|
||||
flex: none;
|
||||
min-width: 0;
|
||||
font-size: 17px;
|
||||
line-height: 29px;
|
||||
letter-spacing: normal;
|
||||
|
||||
@@ -1722,6 +1722,7 @@ const baseEnglishMessages = {
|
||||
"memory:原话已保存,等待可用模型":
|
||||
"Original saved; waiting for an available model",
|
||||
"memory:原话已保存,等待整理": "Original saved; waiting for processing",
|
||||
"memory:正在等待整理": "Awaiting processing",
|
||||
"memory:正在整理": "Processing",
|
||||
"memory:原话已保存,整理未完成": "Original saved; processing incomplete",
|
||||
"memory:整理已完成": "Processing complete",
|
||||
|
||||
@@ -288,6 +288,7 @@ export const memoryChineseMessages = {
|
||||
"memory:重新整理": "重新整理",
|
||||
"memory:原话已保存,等待可用模型": "原话已保存,等待可用模型",
|
||||
"memory:原话已保存,等待整理": "原话已保存,等待整理",
|
||||
"memory:正在等待整理": "正在等待整理",
|
||||
"memory:正在整理": "正在整理",
|
||||
"memory:原话已保存,整理未完成": "原话已保存,整理未完成",
|
||||
"memory:整理已完成": "整理已完成",
|
||||
@@ -696,6 +697,7 @@ export const memorySpanishMessages: Record<
|
||||
"memory:重新整理": "Volver a procesar",
|
||||
"memory:原话已保存,等待可用模型": "Texto guardado; esperando un modelo",
|
||||
"memory:原话已保存,等待整理": "Texto guardado; pendiente de procesar",
|
||||
"memory:正在等待整理": "Pendiente de procesar",
|
||||
"memory:正在整理": "Procesando",
|
||||
"memory:原话已保存,整理未完成": "Texto guardado; procesamiento incompleto",
|
||||
"memory:整理已完成": "Procesamiento completado",
|
||||
|
||||
@@ -0,0 +1,197 @@
|
||||
# -*- coding: utf-8 -*-
|
||||
"""list_memories(scene=...) 与 memory_stats:三类记忆都列得出、数得清。
|
||||
|
||||
MemHub 的记忆库只调 list_memories,不传 workspace_id 时只列 chat,coding /
|
||||
working 两类经验既列不出也数不到:一个 45 条记忆的库在界面上只剩 22 条。
|
||||
"""
|
||||
|
||||
import asyncio
|
||||
from datetime import datetime, timedelta
|
||||
from types import SimpleNamespace
|
||||
|
||||
import pytest
|
||||
|
||||
from hy_memory.client import HyMemoryClient
|
||||
from hy_memory.coding.store import CodingMemoryStore
|
||||
from hy_memory.coding.types import CodingMemory, WorkingFact
|
||||
from hy_memory.models.memory import MemoryLayer, MemoryStatus
|
||||
|
||||
BASE = datetime(2026, 10, 20, 8, 0, 0)
|
||||
|
||||
|
||||
def _node(node_id, *, created, modified=None, layer=MemoryLayer.L2_FACT):
|
||||
return SimpleNamespace(
|
||||
node_id=node_id,
|
||||
layer=layer,
|
||||
gmt_created=created,
|
||||
gmt_modified=modified,
|
||||
valid_from=None,
|
||||
session_id="s",
|
||||
source="cursor",
|
||||
)
|
||||
|
||||
|
||||
class _FakeVectorStore:
|
||||
def __init__(self, nodes):
|
||||
self.nodes = nodes
|
||||
|
||||
async def list_by_user(self, *, layers=None, **_):
|
||||
if layers == [MemoryLayer.L0_BASIC_INFO]:
|
||||
return []
|
||||
return list(self.nodes)
|
||||
|
||||
|
||||
async def _store(coding_config, mock_embed, variant):
|
||||
store = CodingMemoryStore(coding_config, embed_service=mock_embed, variant=variant)
|
||||
await store.initialize()
|
||||
return store
|
||||
|
||||
|
||||
def _client(nodes, stores):
|
||||
client = object.__new__(HyMemoryClient)
|
||||
client._vector_store = _FakeVectorStore(nodes)
|
||||
client._coding_stores = dict(stores)
|
||||
client._config = None
|
||||
return client
|
||||
|
||||
|
||||
def _unwrapped(name):
|
||||
# The public async_* methods hop onto the client's own loop thread; the
|
||||
# logic under test does not need one.
|
||||
return getattr(HyMemoryClient, name).__wrapped__
|
||||
|
||||
|
||||
async def test_stats_counts_every_scene_with_its_latest_times(coding_config, mock_embed):
|
||||
coding = await _store(coding_config, mock_embed, "coding")
|
||||
working = await _store(coding_config, mock_embed, "working")
|
||||
try:
|
||||
await coding.insert(CodingMemory(
|
||||
experience_id="c1", user_id="u", task="T", solution="S",
|
||||
created_at=BASE, updated_at=BASE + timedelta(hours=1),
|
||||
))
|
||||
await coding.insert(CodingMemory(
|
||||
experience_id="c2", user_id="u", task="T", solution="S",
|
||||
workspace_id="ws/a",
|
||||
created_at=BASE + timedelta(hours=2), updated_at=BASE + timedelta(hours=3),
|
||||
))
|
||||
await coding.insert(CodingMemory(
|
||||
experience_id="other-user", user_id="v", task="T", solution="S",
|
||||
))
|
||||
await working.insert(CodingMemory(
|
||||
experience_id="w1", user_id="u", task="T", solution="S",
|
||||
created_at=BASE, updated_at=BASE,
|
||||
))
|
||||
await working.insert_fact(WorkingFact(fact_id="f1", user_id="u", statement="x"))
|
||||
nodes = [
|
||||
_node("m1", created=BASE, modified=BASE + timedelta(days=1)),
|
||||
_node("m2", created=BASE + timedelta(hours=5)),
|
||||
]
|
||||
client = _client(nodes, {"coding": coding, "working": working})
|
||||
|
||||
stats = await _unwrapped("async_memory_stats")(client, user_id="u")
|
||||
|
||||
assert stats["total"] == 2 + 2 + 1
|
||||
assert stats["scenes"]["chat"] == {
|
||||
"count": 2,
|
||||
"latest_created_at": int((BASE + timedelta(hours=5)).timestamp()),
|
||||
"latest_updated_at": int((BASE + timedelta(days=1)).timestamp()),
|
||||
}
|
||||
assert stats["scenes"]["coding"]["count"] == 2
|
||||
assert stats["scenes"]["coding"]["latest_created_at"] == int(
|
||||
(BASE + timedelta(hours=2)).timestamp()
|
||||
)
|
||||
# The store stamps updated_at on write; the stat is the newest it holds.
|
||||
stored = [await coding.get_by_id(eid, user_id="u") for eid in ("c1", "c2")]
|
||||
assert stats["scenes"]["coding"]["latest_updated_at"] == max(
|
||||
int(item.updated_at.timestamp()) for item in stored
|
||||
)
|
||||
assert stats["scenes"]["working"]["count"] == 1
|
||||
assert stats["scenes"]["working"]["facts"] == 1
|
||||
finally:
|
||||
await coding.close()
|
||||
await working.close()
|
||||
|
||||
|
||||
async def test_stats_of_an_unwritten_scene_is_zero_without_creating_its_database(
|
||||
coding_config,
|
||||
):
|
||||
client = _client([], {})
|
||||
client._config = coding_config
|
||||
|
||||
stats = await _unwrapped("async_memory_stats")(client, user_id="u")
|
||||
|
||||
assert stats["total"] == 0
|
||||
assert stats["scenes"]["coding"] == {
|
||||
"count": 0, "latest_created_at": None, "latest_updated_at": None, "facts": 0,
|
||||
}
|
||||
assert not CodingMemoryStore.database_exists(
|
||||
coding_config, db_path=coding_config.coding.db_path, variant="coding"
|
||||
)
|
||||
|
||||
|
||||
async def test_list_by_scene_reads_that_scene_alone_with_its_real_total(
|
||||
coding_config, mock_embed,
|
||||
):
|
||||
coding = await _store(coding_config, mock_embed, "coding")
|
||||
working = await _store(coding_config, mock_embed, "working")
|
||||
try:
|
||||
for index in range(3):
|
||||
await coding.insert(CodingMemory(
|
||||
experience_id=f"c{index}", user_id="u", task=f"T{index}", solution="S",
|
||||
workspace_id="ws/a" if index == 0 else None,
|
||||
updated_at=BASE + timedelta(hours=index),
|
||||
))
|
||||
await working.insert(CodingMemory(
|
||||
experience_id="w1", user_id="u", task="W", solution="S",
|
||||
))
|
||||
client = _client([], {"coding": coding, "working": working})
|
||||
list_memories = _unwrapped("async_list_memories")
|
||||
|
||||
page = await list_memories(client, user_id="u", scene="coding", limit=2)
|
||||
assert page["variant"] == "coding"
|
||||
assert [item["experience_id"] for item in page["experiences"]] == ["c2", "c1"]
|
||||
assert page["total"] == 3
|
||||
|
||||
scoped = await list_memories(client, user_id="u", scene="coding", workspace_id="ws/a")
|
||||
assert [item["experience_id"] for item in scoped["experiences"]] == ["c0"]
|
||||
assert scoped["total"] == 1
|
||||
|
||||
other = await list_memories(client, user_id="u", scene="working")
|
||||
assert [item["experience_id"] for item in other["experiences"]] == ["w1"]
|
||||
|
||||
archived = await list_memories(
|
||||
client, user_id="u", scene="coding", statuses=[MemoryStatus.ARCHIVED]
|
||||
)
|
||||
assert archived["experiences"] == [] and archived["total"] == 0
|
||||
finally:
|
||||
await coding.close()
|
||||
await working.close()
|
||||
|
||||
|
||||
def test_an_unknown_scene_is_refused():
|
||||
with pytest.raises(ValueError, match="scene"):
|
||||
HyMemoryClient._list_scene("raw")
|
||||
assert HyMemoryClient._list_scene(None) is None
|
||||
assert HyMemoryClient._list_scene(" Coding ") == "coding"
|
||||
|
||||
|
||||
def test_list_without_scene_still_lists_chat(monkeypatch):
|
||||
client = _client([_node("m1", created=BASE)], {})
|
||||
client._sort_memory_nodes = lambda nodes, order: nodes
|
||||
client._memory_node_to_list_item = lambda node: {"memory_id": node.node_id}
|
||||
|
||||
async def no_graph(**_):
|
||||
return None
|
||||
|
||||
async def no_op_log(**_):
|
||||
return None
|
||||
|
||||
client._list_graph_bucket = no_graph
|
||||
client._maybe_store_op_log = no_op_log
|
||||
client._backend_identity = lambda: {}
|
||||
client._normalize_layers = lambda layers: layers
|
||||
|
||||
out = asyncio.run(_unwrapped("async_list_memories")(client, user_id="u"))
|
||||
|
||||
assert out["vdb"]["total"] == 1
|
||||
assert out["vdb"]["memories"] == [{"memory_id": "m1"}]
|
||||
Reference in new issue
Block a user