Compare commits

..
Author SHA1 Message Date
alvinfeiandClaude Opus 5.5 3064515b43 Merge main into fix/migration-chroma-settings
Keep both CHANGELOG entries.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-10-27 14:30:30 +08:00
feitianxiang e5c8cc3819 Merge pull request #190 from Tencent-Hunyuan/feat/mcp-three-tools
feat(memhub): cut the MCP server to recall, write and diagnostic trace
2026-10-27 14:13:53 +08:00
kristinhe1111 3c5451db24 Merge pull request #183 from Tencent-Hunyuan/fix/home-recent-update-metadata
fix(memhub): stabilize recent updates and memory details
2026-10-27 13:22:45 +08:00
kristinheandCursor 099f5ae7c0 test(memhub): update memory search affordance
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-10-27 13:13:55 +08:00
kristinhe1111 e7625032d8 Merge pull request #187 from Tencent-Hunyuan/fix/memory-filter-clear-query
fix(memhub): 清空搜索框时移除已提交的记忆关键词
2026-10-27 13:11:07 +08:00
kristinhe1111 3aa3b5957d Merge pull request #186 from Tencent-Hunyuan/fix/hy-debug-pipeline-logs
fix(memhub): surface HY pipeline logs in Debug
2026-10-27 12:24:15 +08:00
kristinheandCursor 409ab9a9b4 fix(memhub): clear submitted memory query with input
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-10-27 12:22:55 +08:00
kristinheandCursor 2735d8693a fix(memhub): prioritize memory list space
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-10-27 12:19:49 +08:00
kristinheandCursor 47bbfb4b27 fix(memhub): simplify memory note copy
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-10-27 12:11:54 +08:00
kristinheandCursor 3f169c9951 style(memhub): format language selector
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-10-27 12:01:58 +08:00
kristinheandCursor 24f699a726 fix(memhub): surface HY pipeline logs in Debug
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-10-27 11:43:35 +08:00
kristinheandCursor cb97aa5822 fix(memhub): show attributed app calls on home
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-10-27 11:43:25 +08:00
alvinfeiandClaude Opus 5.5 38a4da6849 fix: list and count every memory scene, and migrate without a Chroma clash
SDK: list_memories(scene="chat"|"coding"|"working") lists one kind of
memory. coding and working list the scene's whole store, newest update
first, with workspace_id still narrowing it, and total is the real count
instead of the page size. memory_stats() returns each scene's count and
latest created/updated time plus their total, reading no memory text.
Without a workspace, coding and working memories could not be listed or
counted, so a 45-memory library listed 22.

MemHub migration: reattaching legacy vectors opened Chroma with its own
settings and never closed it, so the readback that followed in the same
process could not open the store ("An instance of Chroma already exists
... with different settings"). Every 1.0.4 home with legacy vectors
failed its first upgrade launch. Offline code now opens Chroma as the SDK
does and closes it.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-10-27 11:41:10 +08:00
kristinheandCursor 48a5d70026 fix(memhub): stabilize language setting layout
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-10-27 11:20:53 +08:00
kristinheandCursor b21c929728 fix(memhub): stabilize memory detail layout
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-10-27 11:12:47 +08:00
kristinheandCursor b9b3c587ff fix(memhub): hide recent update internals
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-10-27 11:01:14 +08:00
29 changed files with 961 additions and 190 deletions

No files matched your search

+11
View File
@@ -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
View File
@@ -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,
+36
View File
@@ -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),
}
# ------------------------------------------------------------
# 召回
# ------------------------------------------------------------
+15 -3
View File
@@ -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"
+17 -15
View File
@@ -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(
+6 -1
View File
@@ -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({
+79 -7
View File
@@ -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
+22 -16
View File
@@ -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(
+18 -6
View File
@@ -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;
+38 -4
View File
@@ -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", () => {
+25 -40
View File
@@ -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(
+3 -3
View File
@@ -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>
+2 -3
View File
@@ -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();
});
+25
View File
@@ -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;
+26 -6
View File
@@ -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 {
+33
View File
@@ -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"}]