Compare commits

..
Author SHA1 Message Date
kristinheandCursor 81ed1d816f fix(memhub): open source memories from map nodes
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-10-28 12:00:26 +08:00
52 changed files with 389 additions and 1243 deletions

No files matched your search

-26
View File
@@ -4,14 +4,6 @@ Hy Memory and MemHub are versioned and released independently.
## Unreleased
- MemHub shows when working and coding experiences were talked about. After
the SDK upgrade every one of them read "时间未知" (time unknown) because the
SDK never stored their conversation time; hy-memory 2.0.7 stores it and
Core now passes it through the memory list, detail, search and home
summary. An experience stored before 2.0.7 shows its write time, labelled
"写入于" / "写入时间", instead of "时间未知". Metadata repair also writes
the conversation time it recovers from the migration bundle into the SDK's
coding/working stores when they already have the column.
- 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
@@ -76,24 +68,6 @@ Hy Memory and MemHub are versioned and released independently.
`hy_node_permissions` proof table. Evidence, governed memories, model
profiles, SDK stores, and source/host grants are preserved.
## Hy Memory 2.0.7 — 2026-10-28
- Published
[`hy-memory==2.0.7`](https://pypi.org/project/hy-memory/2.0.7/).
- Coding and working experiences and facts now keep their conversation time.
`add(..., memory_at=...)` reached only the chat path before; the
productivity path dropped it, so every experience had only its write time
and MemHub showed them as "时间未知" (time unknown). `coding_memory_meta`
and `working_fact` gain a nullable `memory_at` column (epoch seconds) in
both the working and coding stores; existing databases get it on open, and
their old rows stay `None`. Without `memory_at` a write uses the time the
request arrived, as chat does. When an experience is updated by a later
conversation it keeps the later of the two times.
- `list_memories` (`scene="coding"`, `"working"` and `"all"`), `get` and
`search` return `memory_at` for experiences and facts in the same field and
format as chat (epoch seconds, `None` when unknown). Experience search
results now also carry `created_at`.
## Hy Memory 2.0.6 — 2026-10-27
- Published
+1 -8
View File
@@ -1544,7 +1544,6 @@ class HyMemoryClient:
# LLM 写的"这个结论一路怎么改成现在这样",直接进提示词;
# 没被覆盖过就是空串。不参与检索——召回认的是 statement 的当前值。
"fact_chain_summary": fact.fact_chain_summary,
"memory_at": _epoch_seconds(fact.memory_at),
"created_at": fact.created_at.isoformat() if fact.created_at else None,
"score": round(float(score), 4),
"source": fact.source,
@@ -1653,8 +1652,6 @@ class HyMemoryClient:
"score": (round(float(score_by_id[d.experience_id]), 4)
if d.experience_id in score_by_id else None),
"confidence": d.confidence,
"memory_at": _epoch_seconds(d.memory_at),
"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,
"extraction_source": d.extraction_source,
@@ -1956,7 +1953,6 @@ class HyMemoryClient:
"content" chat 正文;经验为 "task\nsolution";事实为 statement
"score" RRF 融合分;"lane_score" 为该路原始分;"lane_rank" 为该路名次
"source", "created_at", "updated_at"(秒级 epoch,取不到为 None)
"memory_at" 对话时间(秒级 epoch),各路原有字段,三路同名同格式
chat 路内部名次:normal → schema → proactive 依次排;working/coding 路
内部名次:事实与经验各按原顺序交替(事实在前),两层谁也不压谁。
"""
@@ -2148,8 +2144,7 @@ class HyMemoryClient:
"limit", "offset", "request_id", "elapsed_ms"}
每条带 "scene"、"kind"(chat | experience)、"memory_id"、"content"
(经验为 "task\nsolution")、"source"、"status"、"created_at"、
"updated_at"(秒级 epoch),以及该路原有字段(含对话时间
"memory_at",秒级 epoch,三路同名同格式)。
"updated_at"(秒级 epoch),以及该路原有字段。
"""
return self._loop_thread.run(
self.async_list_memories(
@@ -2972,7 +2967,6 @@ class HyMemoryClient:
session_id=session_id,
request_id=request_id,
source=source,
memory_at=memory_at,
)
# Public receipt always identifies the productivity namespace.
resp["variant"] = scene_variant
@@ -5926,7 +5920,6 @@ class HyMemoryClient:
"source_fact_ids": list(d.source_fact_ids),
"confidence": d.confidence,
"status": MemoryStatus.ACTIVE.value,
"memory_at": _epoch_seconds(d.memory_at),
"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,
+1 -5
View File
@@ -454,10 +454,9 @@ class WorkingFactReconciler:
workspace_id: Optional[str] = None,
branch: Optional[str] = None,
source: str = "default",
memory_at: Optional[datetime] = None,
) -> Tuple[List[WorkingFact], List[FactReconcileOp]]:
"""
执行 reconcile + 持久化。memory_at 是这段对话的时间,记到新事实上。
执行 reconcile + 持久化。
Returns:
(真正落库的新事实, 执行过的 ops)。LLM 挂掉时退化为全 INSERT 不覆盖,
@@ -494,7 +493,6 @@ class WorkingFactReconciler:
workspace_id=workspace_id,
branch=branch,
source=source,
memory_at=memory_at,
)
logger.info(
f"[fact-reconcile] drafts={len(drafts)} inserted={len(facts)} "
@@ -783,7 +781,6 @@ class WorkingFactReconciler:
workspace_id: Optional[str],
branch: Optional[str] = None,
source: str = "default",
memory_at: Optional[datetime] = None,
) -> Tuple[List[WorkingFact], List[FactReconcileOp]]:
inserted: List[WorkingFact] = []
ops: List[FactReconcileOp] = []
@@ -804,7 +801,6 @@ class WorkingFactReconciler:
subject=draft.subject,
details=draft.details,
status="active",
memory_at=memory_at,
)
allowed = {c.fact_id for c in (candidates[idx] if idx < len(candidates) else [])}
-1
View File
@@ -577,7 +577,6 @@ class CodingMemoryReconciler:
confidence=d.confidence,
source=d.source,
extraction_source=d.extraction_source,
memory_at=d.memory_at,
)
+5 -41
View File
@@ -86,7 +86,6 @@ CREATE TABLE IF NOT EXISTS coding_memory_meta (
confidence REAL NOT NULL DEFAULT 0.7,
source TEXT NOT NULL DEFAULT 'default',
extraction_source TEXT NOT NULL DEFAULT 'auto_extract',
memory_at INTEGER,
created_at TEXT NOT NULL,
updated_at TEXT NOT NULL
);
@@ -110,7 +109,6 @@ CREATE TABLE IF NOT EXISTS working_fact (
derived_experience_ids TEXT NOT NULL DEFAULT '[]',
status TEXT NOT NULL DEFAULT 'active',
fact_chain_summary TEXT NOT NULL DEFAULT '',
memory_at INTEGER,
created_at TEXT NOT NULL
);
-- 反查前驱(谁的 fact_link 指向我)走这个索引,写演变总结时要顺着链往回走
@@ -166,8 +164,6 @@ _ADDED_COLUMNS = {
"coding_memory_meta": {
"source_fact_ids": "TEXT NOT NULL DEFAULT '[]'",
"extraction_source": "TEXT NOT NULL DEFAULT 'auto_extract'",
# 对话时间,秒级 epoch(同 chat)。老库里的行是 NULL:那时没存过。
"memory_at": "INTEGER",
},
# 早期版本存过一列 fact_chain(机械累积的历代说法),已废弃:同样的信息顺着
# fact_link 反查就有。老库里那一列留着不管,有默认值不影响插入。
@@ -181,7 +177,6 @@ _ADDED_COLUMNS = {
"subject": "TEXT NOT NULL DEFAULT ''",
"details": "TEXT NOT NULL DEFAULT ''",
"source": "TEXT NOT NULL DEFAULT 'default'",
"memory_at": "INTEGER",
},
}
@@ -295,29 +290,6 @@ def _parse_dt(s: Optional[str]) -> Optional[datetime]:
return None
def _from_epoch(value: Any) -> Optional[datetime]:
"""memory_at 列(秒级 epoch)→ datetime;NULL 或坏值返回 None。"""
if value is None:
return None
try:
return datetime.fromtimestamp(int(value))
except Exception:
return None
def _to_epoch(value: Optional[datetime]) -> Optional[int]:
return int(value.timestamp()) if isinstance(value, datetime) else None
def _later(a: Optional[datetime], b: Optional[datetime]) -> Optional[datetime]:
"""两个对话时间取较晚的;一边缺就取另一边。"""
if a is None:
return b
if b is None:
return a
return a if _to_epoch(a) >= _to_epoch(b) else b
def _row_to_memory(row: sqlite3.Row) -> CodingMemory:
"""SQLite row → CodingMemory"""
return CodingMemory(
@@ -336,7 +308,6 @@ def _row_to_memory(row: sqlite3.Row) -> CodingMemory:
confidence=row["confidence"],
source=row["source"],
extraction_source=row["extraction_source"],
memory_at=_from_epoch(row["memory_at"]),
created_at=_parse_dt(row["created_at"]),
updated_at=_parse_dt(row["updated_at"]),
)
@@ -363,7 +334,6 @@ def _row_to_fact(row: sqlite3.Row) -> WorkingFact:
# 老库刚 ADD COLUMN 时可能取不到这一列,用 keys() 兜一下
fact_chain_summary=(row["fact_chain_summary"] or ""
if "fact_chain_summary" in row.keys() else ""),
memory_at=_from_epoch(row["memory_at"]),
created_at=_parse_dt(row["created_at"]),
)
@@ -627,8 +597,6 @@ class CodingMemoryStore:
memory.updated_at = datetime.utcnow()
if memory.created_at is None:
memory.created_at = prev.created_at
# 合并后的经验讲的是两段对话,对话时间取较晚的那段
memory.memory_at = _later(prev.memory_at, memory.memory_at)
await _run_in_pool(self._upsert_sql_sync, memory)
# 删旧 VDB
await self._delete_vec_keys_by_count(memory.experience_id, prev_key_count)
@@ -1231,8 +1199,8 @@ class CodingMemoryStore:
experience_id, user_id, agent_id, task, search_keys, solution,
boundary_envs, workspace_id, branch, session_id,
files, source_fact_ids, confidence, source, extraction_source,
memory_at, created_at, updated_at
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
created_at, updated_at
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
ON CONFLICT(experience_id) DO UPDATE SET
user_id = excluded.user_id,
agent_id = excluded.agent_id,
@@ -1248,7 +1216,6 @@ class CodingMemoryStore:
confidence = excluded.confidence,
source = excluded.source,
extraction_source = excluded.extraction_source,
memory_at = excluded.memory_at,
updated_at = excluded.updated_at
""",
(
@@ -1259,7 +1226,6 @@ class CodingMemoryStore:
json.dumps(memory.files, ensure_ascii=False),
json.dumps(memory.source_fact_ids, ensure_ascii=False),
memory.confidence, memory.source, memory.extraction_source,
_to_epoch(memory.memory_at),
(memory.created_at or datetime.utcnow()).isoformat(),
(memory.updated_at or datetime.utcnow()).isoformat(),
),
@@ -1400,8 +1366,8 @@ class CodingMemoryStore:
fact_id, user_id, agent_id, session_id, workspace_id, branch,
source, statement, files, subject, details,
fact_link, derived_experience_ids,
status, fact_chain_summary, memory_at, created_at
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
status, fact_chain_summary, created_at
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
ON CONFLICT(fact_id) DO UPDATE SET
user_id = excluded.user_id,
agent_id = excluded.agent_id,
@@ -1416,8 +1382,7 @@ class CodingMemoryStore:
fact_link = excluded.fact_link,
derived_experience_ids = excluded.derived_experience_ids,
status = excluded.status,
fact_chain_summary = excluded.fact_chain_summary,
memory_at = excluded.memory_at
fact_chain_summary = excluded.fact_chain_summary
""",
(
fact.fact_id, fact.user_id, fact.agent_id,
@@ -1430,7 +1395,6 @@ class CodingMemoryStore:
json.dumps(fact.derived_experience_ids, ensure_ascii=False),
fact.status,
fact.fact_chain_summary or "",
_to_epoch(fact.memory_at),
(fact.created_at or datetime.utcnow()).isoformat(),
),
)
-14
View File
@@ -25,11 +25,6 @@ FactStatus = Literal["active", "superseded"]
MAX_FACT_CHAIN_ENTRIES = 8
def _epoch(value: Optional[datetime]) -> Optional[int]:
"""memory_at 对外是秒级 epoch,与 chat 一致。"""
return int(value.timestamp()) if isinstance(value, datetime) else None
# ================================================================
# 草稿(LLM extract 阶段输出)
# ================================================================
@@ -58,8 +53,6 @@ class CodingMemoryDraft:
files: List[str] = field(default_factory=list)
source: str = "default"
extraction_source: str = "auto_extract"
# 对话时间(SDK 注入,同 chat 的 memory_at)
memory_at: Optional[datetime] = None
def __post_init__(self) -> None:
from ..utils.source_filter import normalize_source
@@ -80,7 +73,6 @@ class CodingMemoryDraft:
"files": list(self.files),
"source": self.source,
"extraction_source": self.extraction_source,
"memory_at": _epoch(self.memory_at),
}
@@ -119,9 +111,6 @@ class CodingMemory:
extraction_source: str = "auto_extract"
# 时间
# memory_at 是对话时间(同 chat),created_at / updated_at 是写库时间。
# UPDATE 合并时取两者中较晚的那个对话时间,见 store.update。
memory_at: Optional[datetime] = None
created_at: Optional[datetime] = None
updated_at: Optional[datetime] = None
@@ -146,7 +135,6 @@ class CodingMemory:
"confidence": self.confidence,
"source": self.source,
"extraction_source": self.extraction_source,
"memory_at": _epoch(self.memory_at),
"created_at": self.created_at.isoformat() if self.created_at else None,
"updated_at": self.updated_at.isoformat() if self.updated_at else None,
}
@@ -261,7 +249,6 @@ class WorkingFact:
fact_chain_summary: str = ""
# 时间
memory_at: Optional[datetime] = None # 对话时间(同 chat),没传时是收到请求的时间
created_at: Optional[datetime] = None # 产生时间,新的优先
def __post_init__(self) -> None:
@@ -285,7 +272,6 @@ class WorkingFact:
"derived_experience_ids": list(self.derived_experience_ids),
"status": self.status,
"fact_chain_summary": self.fact_chain_summary,
"memory_at": _epoch(self.memory_at),
"created_at": self.created_at.isoformat() if self.created_at else None,
}
+1 -8
View File
@@ -18,7 +18,6 @@ CodingWriter - 写入端协调器
import logging
import time
import uuid
from datetime import datetime
from typing import Any, Dict, List, Optional
from ..agent.llm_provider import LLMProvider
@@ -80,11 +79,9 @@ class CodingWriter:
session_id: Optional[str] = None,
request_id: Optional[str] = None,
source: str = "default",
memory_at: Optional[datetime] = None,
) -> Dict[str, Any]:
"""
working/coding 段写入端到端。memory_at 是对话时间,记到这次写出的经验和
事实上;不传则用收到请求的时间(与 chat 链路一致)。返回响应 dict(兼容 client.add 返回结构):
working/coding 段写入端到端。返回响应 dict(兼容 client.add 返回结构):
{
"success": True,
"scene": "productivity",
@@ -104,8 +101,6 @@ class CodingWriter:
"""
request_id = request_id or str(uuid.uuid4())
t0 = time.perf_counter()
if memory_at is None:
memory_at = datetime.now()
if not messages:
return self._empty_response(request_id, t0)
@@ -177,7 +172,6 @@ class CodingWriter:
# extractor 只负责内容;调用来源由 SDK 入口注入,不能交给 LLM 生成。
for draft in drafts:
draft.source = source
draft.memory_at = memory_at
# 3. 经验层 reconcile + 持久化
ops: List[ReconcileOp] = (
@@ -203,7 +197,6 @@ class CodingWriter:
workspace_id=workspace_id,
branch=branch,
source=source,
memory_at=memory_at,
)
experience_ids = [op.target_experience_id for op in ops
+2 -2
View File
@@ -18,7 +18,7 @@ If you run into a problem, please open an Issue with the version, platform,repro
## 发行与平台合同
源码当前固定使用公开 PyPI 正式版 `hy-memory==2.0.7`。MemHub 源码版本为 `1.0.5`
源码当前固定使用公开 PyPI 正式版 `hy-memory==2.0.6`。MemHub 源码版本为 `1.0.5`
(build 14),GitHub 仓库为
[`Tencent-Hunyuan/Hy-Memory`](https://github.com/Tencent-Hunyuan/Hy-Memory),npm 包名为
`memhub-cli`。当前代码架构见
@@ -118,7 +118,7 @@ MemHub 的 SQLite 数据默认位于 `~/.one-memory`,不在 npm 包或 Runtime
## Hy Memory
SDK 依赖固定为公开 PyPI 正式版 `hy-memory==2.0.7`。SDK 配置、权限边界及 search
SDK 依赖固定为公开 PyPI 正式版 `hy-memory==2.0.6`。SDK 配置、权限边界及 search
返回格式见 [`MEMORY.md`](MEMORY.md)。HY search 返回的 `content` 已包含 SDK 统一生成的
逻辑时间和演化上下文;调用方不应再次转换时间戳或重复拼接 `When`。
+1 -1
View File
@@ -2352,7 +2352,7 @@
{
"SPDXID": "SPDXRef-Package-hy-memory",
"name": "hy-memory",
"versionInfo": "2.0.7",
"versionInfo": "2.0.6",
"downloadLocation": "NOASSERTION",
"filesAnalyzed": false,
"licenseConcluded": "Apache-2.0",
+1 -1
View File
@@ -9,7 +9,7 @@ Per-package license texts are not reproduced here yet; the license
column is filled in as part of release review, and the SBOM records
`NOASSERTION` until then rather than guessing.
## hy-memory 2.0.7
## hy-memory 2.0.6
License: Apache-2.0
+1 -1
View File
@@ -12,7 +12,7 @@ dependencies = [
"fastapi>=0.141.1",
"filelock>=3.32.4",
# Public SDK version validated against MemHub's permission and recall contracts.
"hy-memory==2.0.7",
"hy-memory==2.0.6",
# chromadb arrives through the HY SDK; locked here so an unrelated upgrade
# cannot move the SDK's store. Built-in embedding consumes numpy directly.
"chromadb==1.5.9",
@@ -14,7 +14,7 @@ from pathlib import Path
from typing import Literal, cast
HY_MEMORY_DISTRIBUTION = "hy-memory"
HY_MEMORY_VERSION = "2.0.7"
HY_MEMORY_VERSION = "2.0.6"
HY_MEMORY_NAMESPACE = "governed-v2"
DiagnosticLogLevel = Literal["error", "warning", "info", "debug"]
@@ -426,7 +426,6 @@ def _repair_coding(
path = root / name
if not path.is_file():
continue
_ensure_coding_schema(path)
with closing(sqlite3.connect(path)) as database:
tables = {
str(row[0])
@@ -438,14 +437,10 @@ def _repair_coding(
):
if table not in tables:
continue
# _ensure_coding_schema added it; an SDK without the column
# (before 2.0.7) leaves the time to the vector repair alone.
has_memory_at = "memory_at" in _columns(database, table)
rows = database.execute(
f"SELECT {identifier},source,created_at,"
f"{'memory_at' if has_memory_at else 'NULL'} FROM {table}"
f"SELECT {identifier},source,created_at FROM {table}"
).fetchall()
for memory_id, source, created_at, memory_at in rows:
for memory_id, source, created_at in rows:
desired = mapping.get(str(memory_id))
if desired is None:
continue
@@ -454,13 +449,6 @@ def _repair_coding(
if desired.source and source in {None, "", "default"}:
updates.append("source=?")
values.append(desired.source)
if (
has_memory_at
and desired.memory_at is not None
and memory_at != desired.memory_at
):
updates.append("memory_at=?")
values.append(desired.memory_at)
if desired.created_at is not None:
desired_created_at = (
datetime.fromtimestamp(desired.created_at, tz=UTC)
@@ -482,32 +470,6 @@ def _repair_coding(
return repaired
def _ensure_coding_schema(path: Path) -> None:
"""Bring a Coding/Working store to the installed SDK's schema.
A migrated home's stores were written by an older SDK and may lack columns
the repair writes (``memory_at`` since hy-memory 2.0.7). The repair runs
before any SDK client opens them, so the SDK's own schema step is run here
rather than repeating its DDL. ``CodingMemoryStore`` has no public
schema-only entry point: ``initialize()`` also opens its Chroma
collections, which must stay closed during the offline repair. So only its
SQLite step runs, on a store object that carries nothing but that path.
"""
from hy_memory.coding.store import ( # type: ignore[import-untyped]
CodingMemoryStore,
)
# Relies on the pinned SDK's internals (hy-memory==2.0.7 is pinned exactly).
store = CodingMemoryStore.__new__(CodingMemoryStore)
store._db_path = str(path)
store._conn = None
try:
store._init_sqlite_sync()
finally:
if store._conn is not None:
store._conn.close()
def _batches[T](values: list[T] | tuple[T, ...]) -> Iterator[tuple[T, ...]]:
for start in range(0, len(values), _BATCH):
yield tuple(values[start : start + _BATCH])
@@ -151,7 +151,6 @@ class HySdkMemoryRecord(BaseModel):
if not content.strip():
raise HySdkReadError("hy_memory_record_shape_unknown")
status = str(raw.get("status") or "active")
memory_at = raw.get("memory_at")
created_at = _epoch(raw.get("created_at"))
updated_at = _epoch(raw.get("updated_at"))
try:
@@ -168,9 +167,6 @@ class HySdkMemoryRecord(BaseModel):
str(raw["session_id"]) if isinstance(raw.get("session_id"), str) else None
),
source=str(raw["source"]) if isinstance(raw.get("source"), str) else None,
# Conversation time; hy-memory 2.0.7 stores it for experiences
# and facts. Older stores leave it unknown.
memory_at=memory_at if isinstance(memory_at, int) else None,
gmt_created=created_at,
scene=cast(MemoryScene, variant),
kind=cast(MemoryKindName, kind),
@@ -55,9 +55,6 @@ def chat_row(memory_id, content, *, source="cursor"):
}
EXPERIENCE_MEMORY_AT = 1_789_990_000
def experience_row(memory_id, task, solution, *, variant="working", source="codebuddy"):
return {
"experience_id": memory_id,
@@ -66,8 +63,6 @@ def experience_row(memory_id, task, solution, *, variant="working", source="code
"task": task,
"solution": solution,
"source": source,
# Conversation time, epoch seconds like chat (hy-memory 2.0.7).
"memory_at": EXPERIENCE_MEMORY_AT,
"created_at": "2026-10-01T08:00:00",
"updated_at": "2026-10-02T08:00:00",
"_variant": variant,
@@ -451,7 +446,6 @@ def test_keyword_filter_reads_experience_and_fact_text(tmp_path, monkeypatch):
assert experience["content"] == "Fix flaky tests\nPin the pytest seed"
assert experience["status"] == "active"
assert experience["updated_at"] > experience["created_at"]
assert experience["memory_at"] == EXPERIENCE_MEMORY_AT
fact = by_id["fact-1"]
assert (fact["scene"], fact["kind"], fact["variant"]) == ("coding", "fact", "coding")
assert fact["content"] == "The pytest suite runs in CI"
@@ -494,10 +488,6 @@ def test_empty_query_lists_all_scenes_with_source_filter_and_totals(tmp_path, mo
"chat",
"chat",
]
assert [item["memory_at"] for item in body["memories"][:2]] == [
EXPERIENCE_MEMORY_AT,
EXPERIENCE_MEMORY_AT,
]
call = sdk.list_calls[0]
assert call["scene"] == "all"
assert call["include_sources"] is None
@@ -537,7 +527,6 @@ def test_detail_edit_delete_pass_kind_and_variant(tmp_path, monkeypatch):
assert record["task"] == "Old task"
assert record["solution"] == "Old solution"
assert record["content"] == "Old task\nOld solution"
assert record["memory_at"] == EXPERIENCE_MEMORY_AT
assert sdk.get_calls[-1] == ("exp-1", "experience", "working")
# Chat detail with no namespace keeps the plain SDK call.
@@ -703,6 +692,5 @@ def test_home_summary_counts_what_the_memory_page_lists(tmp_path, monkeypatch):
assert (home_list["offset"], home_list["limit"], home_list["order"]) == (0, 1, "desc")
assert body["latest"]["memory_id"] == "exp-w"
assert body["latest"]["scene"] == "working"
assert body["latest"]["memory_at"] == EXPERIENCE_MEMORY_AT
run_with_sdk(tmp_path, monkeypatch, scenario)
@@ -137,38 +137,19 @@ def test_post_cutover_metadata_repair_restores_source_and_original_times(store):
)
coding = data_dir / "hy-memory" / "governed-v2" / "coding_cv.sqlite3"
with sqlite3.connect(coding) as database:
# A store written by hy-memory 2.0.6, before the conversation time
# column: the repair must add it through the SDK and then fill it.
database.executescript(
"""
CREATE TABLE coding_memory_meta (
experience_id TEXT PRIMARY KEY, user_id TEXT NOT NULL,
agent_id TEXT NOT NULL DEFAULT 'default_agent', task TEXT NOT NULL,
search_keys TEXT NOT NULL DEFAULT '[]', solution TEXT NOT NULL,
boundary_envs TEXT NOT NULL DEFAULT '', workspace_id TEXT,
branch TEXT, session_id TEXT, files TEXT NOT NULL DEFAULT '[]',
source_fact_ids TEXT NOT NULL DEFAULT '[]',
confidence REAL NOT NULL DEFAULT 0.7,
source TEXT NOT NULL DEFAULT 'default',
extraction_source TEXT NOT NULL DEFAULT 'auto_extract',
created_at TEXT NOT NULL, updated_at TEXT NOT NULL
);
CREATE TABLE working_fact (
fact_id TEXT PRIMARY KEY, user_id TEXT NOT NULL,
agent_id TEXT NOT NULL DEFAULT 'default_agent', session_id TEXT,
workspace_id TEXT, branch TEXT, source TEXT NOT NULL DEFAULT 'default',
statement TEXT NOT NULL, files TEXT NOT NULL DEFAULT '[]',
subject TEXT NOT NULL DEFAULT '', details TEXT NOT NULL DEFAULT '',
fact_link TEXT, derived_experience_ids TEXT NOT NULL DEFAULT '[]',
status TEXT NOT NULL DEFAULT 'active',
fact_chain_summary TEXT NOT NULL DEFAULT '', created_at TEXT NOT NULL
);
"""
database.execute(
"CREATE TABLE coding_memory_meta ("
"experience_id TEXT,user_id TEXT,agent_id TEXT,solution TEXT,"
"source TEXT,created_at TEXT,updated_at TEXT)"
)
database.execute(
"INSERT INTO coding_memory_meta "
"(experience_id,user_id,agent_id,task,solution,source,created_at,updated_at) "
"VALUES ('sdk-1','local','scope','remember','remember','default','now','now')"
"CREATE TABLE working_fact ("
"fact_id TEXT,user_id TEXT,agent_id TEXT,statement TEXT,"
"source TEXT,created_at TEXT)"
)
database.execute(
"INSERT INTO coding_memory_meta VALUES "
"('sdk-1','local','scope','remember','default','now','now')"
)
result = repair_sdk_metadata_from_bundle(
@@ -187,11 +168,10 @@ def test_post_cutover_metadata_repair_restores_source_and_original_times(store):
2026, 10, 18, tzinfo=UTC
)
with sqlite3.connect(coding) as database:
source, memory_at, created_at = database.execute(
"SELECT source,memory_at,created_at FROM coding_memory_meta WHERE experience_id='sdk-1'"
source, created_at = database.execute(
"SELECT source,created_at FROM coding_memory_meta WHERE experience_id='sdk-1'"
).fetchone()
assert source == "workbuddy"
assert datetime.fromtimestamp(memory_at, tz=UTC) == datetime(2026, 10, 17, tzinfo=UTC)
assert created_at.startswith("2026-10-18T00:00:00")
repeated = repair_sdk_metadata_from_bundle(
@@ -91,7 +91,7 @@ def test_bundle_contains_management_sdk_config_and_verified_manifest(tmp_path):
assert manifest["bundle_id"] == "migration-1"
assert manifest["sdk_namespace"] == "governed-v2"
assert manifest["runtime_build"] == "build-13"
assert manifest["snapshot_sdk_version"] == "2.0.7"
assert manifest["snapshot_sdk_version"] == "2.0.6"
paths = {item["path"]: item for item in manifest["files"]}
assert {
"management/one-memory.sqlite3",
+4 -4
View File
@@ -991,7 +991,7 @@ wheels = [
[[package]]
name = "hy-memory"
version = "2.0.7"
version = "2.0.6"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "chromadb" },
@@ -1008,9 +1008,9 @@ dependencies = [
{ name = "scikit-learn" },
{ name = "tiktoken" },
]
sdist = { url = "https://files.pythonhosted.org/packages/17/b5/87a43c8d8b77fdfbbe7974d1e93af50ddf82317c5d56e180b865fb19e181/hy_memory-2.0.7.tar.gz", hash = "sha256:85636487e5bd4b6595a8c15301499efb365b6010c236a45c51c144f6986a4222", size = 854913, upload-time = "2026-10-28T03:35:38.127Z" }
sdist = { url = "https://files.pythonhosted.org/packages/18/98/312ac8bcbe41e63200a93d296300b2e6748e1a532dad01f009dad57ba408/hy_memory-2.0.6.tar.gz", hash = "sha256:d0f54d54867ee7e722a8dd1a88a00c0576904435f15ae7899fe640840bff74bc", size = 855200, upload-time = "2026-10-27T07:53:10.022Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/7c/b8/a6e0bfd17aeb432b0470f4dc707271e6476f5ac34f3b301ab3ad2e1c0d83/hy_memory-2.0.7-py3-none-any.whl", hash = "sha256:80a501e17987202ee1150a91b071313e890300620f353f9e89c967286dc1a645", size = 919841, upload-time = "2026-10-28T03:35:35.399Z" },
{ url = "https://files.pythonhosted.org/packages/27/f1/d4866cffe426d77b857168cc694b15df866f3d9f45152c17c148e1595db4/hy_memory-2.0.6-py3-none-any.whl", hash = "sha256:b0e666f8e89d1763bb31c0e67502df74434134a638ff6fd1912de9e936be2483", size = 918955, upload-time = "2026-10-27T07:53:07.686Z" },
]
[[package]]
@@ -1837,7 +1837,7 @@ requires-dist = [
{ name = "fastapi", specifier = ">=0.141.1" },
{ name = "filelock", specifier = ">=3.32.4" },
{ name = "httpx", specifier = ">=0.28.1" },
{ name = "hy-memory", specifier = "==2.0.7" },
{ name = "hy-memory", specifier = "==2.0.6" },
{ name = "keyring", specifier = ">=25.7.0" },
{ name = "numpy", specifier = "==2.5.2" },
{ name = "onnxruntime", specifier = "==1.23.2" },
@@ -15,7 +15,6 @@ const fake = vi.hoisted(() => ({
missingBroker: false,
hostCapability: true,
open: vi.fn(),
refreshHosts: vi.fn(async (_layouts: unknown, _options: unknown) => ({})),
host: vi.fn<
(input: {
hostId: string;
@@ -275,9 +274,7 @@ beforeEach(async () => {
fake.open.mockResolvedValue({
hostGrantStore: {},
repairInstalledCommand: vi.fn(async () => []),
refreshInstalledHosts: fake.refreshHosts,
});
fake.refreshHosts.mockClear();
fake.host.mockResolvedValue({ state: "synthetic-remote-result" });
Object.defineProperty(process, "resourcesPath", {
configurable: true,
@@ -593,11 +590,5 @@ describe("packaged Desktop host-control ownership", () => {
expect(fake.ensureRuntime).not.toHaveBeenCalled();
expect(fake.createClient).not.toHaveBeenCalled();
expect(fake.windows).toHaveLength(1);
// The embedded Runtime is this process, so Desktop refreshes the hosts
// it installed once the Runtime is up, as a standalone one would itself.
expect(fake.refreshHosts).toHaveBeenCalledTimes(1);
expect(fake.refreshHosts.mock.calls[0]?.[1]).toMatchObject({
diagnostics: expect.anything(),
});
});
});
@@ -21,8 +21,6 @@ import {
claudeCodeLayout,
codexLayout,
cursorLayout,
MCP_TOOLSET_KEY,
MCP_TOOLSET_VERSION,
mcpConfigPath,
piLayout,
planRecallInstall,
@@ -31,7 +29,6 @@ import {
workbuddyLayout,
workbuddyScatteredLayout,
} from "@one-memory/host-bridge/installer";
import type { DiagnosticInput } from "@one-memory/runtime-control";
import { afterEach, describe, expect, it, vi } from "vitest";
import { probeRecallInstall } from "../../../packages/host-bridge/src/recall-probe.js";
import { skillFilePath } from "../../../packages/host-bridge/src/skill-drift.js";
@@ -1387,197 +1384,3 @@ describe("upgrading installs left by an earlier release", () => {
);
});
});
describe("refreshing installed hosts after an upgrade", () => {
function recorder() {
const events: DiagnosticInput[] = [];
return {
events,
sink: {
log: async (event: DiagnosticInput) => {
events.push(event);
},
},
};
}
async function linkedWorkbuddy(context: Awaited<ReturnType<typeof fixture>>) {
const layout = workbuddyLayout(path.join(context.root, "workbuddy"));
const preview = await context.permissions.previewInstall(layout);
const result = await context.permissions.install(
layout,
preview.fingerprint,
);
await context.permissions.setPermission(
layout,
approval(required(result.permission.receipt)),
);
return layout;
}
/** Put back the skill an earlier release wrote, journaled as ours. */
async function ageSkill(layout: ReturnType<typeof workbuddyLayout>) {
const skillPath = required(skillFilePath(layout));
const aged = (await readFile(skillPath, "utf8"))
.replace(/skill-version=\d+/, "skill-version=15")
.replace("`MemHub_recall`", "`MemHub_ask`");
await writeFile(skillPath, aged);
const journal = JSON.parse(await readFile(layout.journalPath, "utf8"));
journal.installed_skill_hash = createHash("sha256")
.update(aged)
.digest("hex");
await writeFile(layout.journalPath, JSON.stringify(journal));
return aged;
}
it("rewrites an old skill on the first start of a build, and only once per build", async () => {
const context = await fixture();
const layout = await linkedWorkbuddy(context);
const current = await readFile(required(skillFilePath(layout)), "utf8");
await ageSkill(layout);
const { events, sink } = recorder();
const first = await context.permissions.refreshInstalledHosts([layout], {
build: "34",
diagnostics: sink,
});
expect(first).toMatchObject({
state: "refreshed",
synced: ["workbuddy"],
failed: [],
});
expect(await readFile(required(skillFilePath(layout)), "utf8")).toBe(
current,
);
// Same build again: one file read, nothing rewritten, even if stale.
const aged = await ageSkill(layout);
expect(
await context.permissions.refreshInstalledHosts([layout], {
build: "34",
diagnostics: sink,
}),
).toMatchObject({ state: "already_refreshed", synced: [] });
expect(await readFile(required(skillFilePath(layout)), "utf8")).toBe(aged);
// The next build refreshes again.
expect(
await context.permissions.refreshInstalledHosts([layout], {
build: "35",
}),
).toMatchObject({ state: "refreshed", synced: ["workbuddy"] });
expect(await readFile(required(skillFilePath(layout)), "utf8")).toBe(
current,
);
// Diagnostics carry host ids and counts only, never file content.
expect(events.map((event) => event.event)).toEqual([
"host_refresh.host_updated",
"host_refresh.completed",
"host_refresh.skipped",
]);
expect(events[0].details).toEqual({ source_id: "workbuddy" });
expect(events[1].details).toEqual({
count: 1,
updated_count: 1,
error_count: 0,
});
expect(JSON.stringify(events)).not.toContain("MemHub_");
expect(JSON.stringify(events)).not.toContain(context.root);
});
it("leaves a skill the user edited untouched", async () => {
const context = await fixture();
const layout = await linkedWorkbuddy(context);
const edited = `${await ageSkill(layout)}\nUser addition\n`;
await writeFile(required(skillFilePath(layout)), edited);
expect(
await context.permissions.refreshInstalledHosts([layout], {
build: "34",
}),
).toMatchObject({ state: "refreshed", synced: [], failed: [] });
expect(await readFile(required(skillFilePath(layout)), "utf8")).toBe(
edited,
);
});
it("rewrites an MCP entry without the tool set stamp, in the plugin manifest too", async () => {
const context = await fixture();
const layout = await linkedWorkbuddy(context);
const plugin = required(layout.plugin);
const manifests = [
path.join(plugin.installRoot, ".codebuddy-plugin", "plugin.json"),
path.join(
plugin.marketplaceRoot,
"plugins",
plugin.pluginName,
".codebuddy-plugin",
"plugin.json",
),
];
// What an earlier release wrote: the same entry with no stamp, journaled.
const journal = JSON.parse(await readFile(layout.journalPath, "utf8"));
for (const manifestPath of manifests) {
const manifest = JSON.parse(await readFile(manifestPath, "utf8"));
expect(manifest.mcpServers["one-memory"].env[MCP_TOOLSET_KEY]).toBe(
MCP_TOOLSET_VERSION,
);
delete manifest.mcpServers["one-memory"].env[MCP_TOOLSET_KEY];
const text = `${JSON.stringify(manifest, null, 2)}\n`;
await writeFile(manifestPath, text);
const entry = journal.plugin_files.find(
(file: { path: string }) => file.path === manifestPath,
);
entry.hash = createHash("sha256").update(text).digest("hex");
journal.installed_server_hash = createHash("sha256")
.update(JSON.stringify(manifest.mcpServers["one-memory"]))
.digest("hex");
}
await writeFile(layout.journalPath, JSON.stringify(journal));
const granted = await context.permissions.getPermission(layout);
expect(granted.state).toBe("approved");
expect(
(await readInstalledRecallCommand(layout))?.env[MCP_TOOLSET_KEY],
).toBeUndefined();
expect(
await context.permissions.refreshInstalledHosts([layout], {
build: "34",
}),
).toMatchObject({ synced: ["workbuddy"], failed: [] });
for (const manifestPath of manifests) {
const manifest = JSON.parse(await readFile(manifestPath, "utf8"));
expect(manifest.mcpServers["one-memory"].env[MCP_TOOLSET_KEY]).toBe(
MCP_TOOLSET_VERSION,
);
}
// Rewriting our own entry keeps the user's authorization.
expect(await context.permissions.getPermission(layout)).toEqual(granted);
});
it("rewrites a config-file MCP entry without the stamp", async () => {
const context = await fixture();
await install(context);
const configPath = mcpConfigPath(context.layout);
const config = JSON.parse(await readFile(configPath, "utf8"));
delete config.mcpServers["one-memory"].env[MCP_TOOLSET_KEY];
await writeFile(configPath, `${JSON.stringify(config, null, 2)}\n`);
const journal = JSON.parse(
await readFile(context.layout.journalPath, "utf8"),
);
journal.installed_server_hash = createHash("sha256")
.update(JSON.stringify(config.mcpServers["one-memory"]))
.digest("hex");
await writeFile(context.layout.journalPath, JSON.stringify(journal));
expect(
await context.permissions.refreshInstalledHosts([context.layout]),
).toMatchObject({ state: "refreshed", synced: ["cursor"], failed: [] });
const refreshed = JSON.parse(await readFile(configPath, "utf8"));
expect(refreshed.mcpServers["one-memory"].env[MCP_TOOLSET_KEY]).toBe(
MCP_TOOLSET_VERSION,
);
expect(refreshed.mcpServers.neighbor).toEqual({ command: "keep" });
});
});
-18
View File
@@ -1369,24 +1369,6 @@ async function initialize(isQuitting: () => boolean): Promise<void> {
});
await runtimeSupervisor.start();
if (isQuitting()) return;
// A standalone Runtime refreshes the hosts it installed as it starts, so
// an upgrade through the App or `memhub upgrade` is covered there. An
// embedded one is this process: do the same here, now it is up. Only a
// packaged build, so a source checkout never repoints real hosts at itself.
if (localPermissions && app.isPackaged) {
void localPermissions
.refreshInstalledHosts(
hosts.map((host) => host.layout),
{
build:
packagedBuildNumber === undefined
? undefined
: String(packagedBuildNumber),
diagnostics: hostDiagnostics,
},
)
.catch(() => undefined);
}
if (
app.isPackaged &&
process.platform === "darwin" &&
+226 -103
View File
@@ -5,7 +5,6 @@ import type {
MemoryGraphNode,
MemoryGraphResponse,
MemoryProcessingSummary,
SourceImportReceipt as Receipt,
SourceResponse,
} from "@one-memory/contracts";
import {
@@ -40,12 +39,6 @@ import { afterEach, expect, it, vi } from "vitest";
import { MemoryMap } from "./MemoryMap";
import type { MemoryArrival } from "./memory-arrival-state";
const { sourceReceipt } = vi.hoisted(() => ({
sourceReceipt: vi.fn(),
}));
vi.mock("./api", () => ({
getSourceImportReceipt: sourceReceipt,
}));
vi.mock("./MemoryGraph3D", () => ({
MemoryGraph3D: ({
graph,
@@ -517,7 +510,7 @@ it("D96: the memory page has no raw-dialogue tab and never loads the dialogue li
it("D96: lists a memory by when its conversation happened, not when it was generated", () => {
const today = new Date().toISOString();
const thisYear = new Date().getFullYear();
const items = ["第一条", "第二条", "第三条", "第四条"].map((content, i) => ({
const items = ["第一条", "第二条", "第三条"].map((content, i) => ({
...memories[0],
memory_id: `t${i}`,
content,
@@ -532,11 +525,10 @@ it("D96: lists a memory by when its conversation happened, not when it was gener
total: 519,
offset: 100,
limit: 100,
source_ids_by_memory: { t0: [], t1: [], t2: [], t3: [] },
source_ids_by_memory: { t0: [], t1: [], t2: [] },
source_time_by_memory: {
t0: { at: `${thisYear}-10-02T10:54:00`, basis: "conversation" },
t1: { at: `${thisYear}-09-30T08:00:00`, basis: "import" },
t3: { at: `${thisYear}-09-29T08:00:00`, basis: "write" },
},
})}
/>,
@@ -548,10 +540,6 @@ it("D96: lists a memory by when its conversation happened, not when it was gener
expect(
within(list).getByRole("button", { name: /第二条/ }).textContent,
).toContain("导入于 9月30日");
// An experience stored without a conversation time names its write time.
expect(
within(list).getByRole("button", { name: /第四条/ }).textContent,
).toContain("写入于 9月29日");
// D99: the generated day keeps its own line, so a memory made today from an
// old conversation still reads as new rather than looking misplaced.
expect(
@@ -987,35 +975,6 @@ const importedSources = [
imported_conversations: 1,
},
] as SourceResponse[];
const importedReceipt: Receipt = {
source_id: "codex",
measured_at: "2026-09-14T00:00:00Z",
messages: 5,
conversations: 2,
person_messages: 2,
agent_messages: 2,
tool_messages: 0,
unknown_author_messages: 1,
unknown_time_messages: 1,
examples: [
{
evidence_id: "raw-codex",
role: "user",
written_by: "person",
created_at: null,
excerpt: "合成来源原文,不是治理记忆。",
truncated: false,
},
],
};
async function openSourceReceipt() {
await userEvent.click(
screen.getByRole("button", { name: "图中选择 Codex · 2 段" }),
);
await userEvent.click(screen.getByText("查看已读内容"));
await waitFor(() => expect(sourceReceipt).toHaveBeenCalledWith("codex"));
}
it("REV09: selecting an imported source retains the canvas and only its authorized node", async () => {
render(<Host items={[]} sources={importedSources} />);
const canvas = screen.getByTestId("canvas");
@@ -1030,7 +989,8 @@ it("REV09: selecting an imported source retains the canvas and only its authoriz
expect(
screen.getByRole("list", { name: "地图记忆列表" }).children,
).toHaveLength(0);
expect(sourceReceipt).not.toHaveBeenCalled();
expect(screen.getByRole("heading", { name: "记忆详情" })).toBeTruthy();
expect(screen.getByText("还没有已生成的记忆")).toBeTruthy();
await userEvent.click(screen.getByRole("button", { name: /全部记忆/ }));
expect(within(canvas).getAllByRole("button")).toHaveLength(2);
expect(canvas.dataset.selected ?? "").toBe("");
@@ -1052,83 +1012,247 @@ it("REV09: fallback search matches source names and IDs without bypassing the se
);
await userEvent.type(search, "cOdEx");
expect(screen.getByTestId("canvas").dataset.selected).toBe("source:codex");
expect(screen.getByText("查看已读内容")).toBeTruthy();
expect(screen.getByText("还没有已生成的记忆")).toBeTruthy();
await userEvent.clear(search);
await userEvent.type(search, "Cursor");
expect(screen.queryByTestId("canvas")).toBeNull();
expect(screen.queryByText("查看已读内容")).toBeNull();
expect(screen.queryByText("正在读取记忆详情…")).toBeNull();
await userEvent.clear(search);
expect(screen.getByTestId("canvas").dataset.selected ?? "").toBe("");
expect(screen.queryByText("查看已读内容")).toBeNull();
expect(screen.queryByText("正在读取记忆详情…")).toBeNull();
});
it("REV09: source selection uses the actual permission-checked receipt API", async () => {
sourceReceipt.mockResolvedValue(importedReceipt);
render(<Host items={[]} sources={importedSources} />);
await openSourceReceipt();
expect(
await screen.findByText(importedReceipt.examples[0].excerpt),
).toBeTruthy();
expect(screen.getByText("用户 · 时间未知")).toBeTruthy();
expect(screen.getByText("原始消息").nextElementSibling?.textContent).toBe(
"5",
it("selecting a source node waits for its settled page, then opens the first memory", async () => {
const onQuery = vi.fn();
const onSource = vi.fn();
const resetPage = vi.fn();
const oldPage = {
items: memories,
total: 2,
offset: 0,
limit: 20,
source_ids_by_memory: { m0: ["codex"], m1: ["codex"] },
};
const view = render(
<Host
simplified
sources={importedSources}
libraryView={{
...pagedView(oldPage),
query: "旧查询",
settledQuery: "旧查询",
onQuery,
onSource,
resetPage,
}}
/>,
);
// No raw conversation is read by id on the SDK data plane.
expect(screen.queryByRole("button", { name: "查看完整对话" })).toBeNull();
await act(async () => {});
await userEvent.click(screen.getByRole("button", { name: "图中选择 Codex" }));
expect(onQuery).toHaveBeenCalledWith("");
expect(onSource).toHaveBeenCalledWith("codex");
expect(resetPage).toHaveBeenCalledOnce();
view.rerender(
<Host
simplified
sources={importedSources}
libraryView={{
...pagedView(oldPage),
source: "codex",
settledSource: "",
loading: true,
onQuery,
onSource,
resetPage,
}}
/>,
);
expect(
screen
.getAllByRole("status")
.some((node) => node.textContent === "正在读取记忆详情…"),
).toBe(true);
const settledItems = [memories[1], memories[0]];
view.rerender(
<Host
simplified
items={settledItems}
sources={importedSources}
libraryView={{
...pagedView({
...oldPage,
items: settledItems,
source_ids_by_memory: { m1: ["codex"], m0: ["codex"] },
}),
source: "codex",
settledSource: "codex",
settledQuery: "",
onQuery,
onSource,
resetPage,
}}
/>,
);
expect(await screen.findByRole("heading", { name: "第二条" })).toBeTruthy();
expect(screen.queryByText("查看已读内容")).toBeNull();
expect(document.querySelector(".source-import-receipt")).toBeNull();
});
it.each(["revoked", "removed", "revoked-hidden"])(
"REV09: %s source drops fallback, preview and a late receipt without reopening on reauthorization",
async (state) => {
let finish: (value: Receipt) => void = () => {};
sourceReceipt.mockImplementation(
() =>
new Promise<Receipt>((resolve) => {
finish = resolve;
}),
);
const view = render(<Host items={[]} sources={[importedSources[0]]} />);
await openSourceReceipt();
view.rerender(
<Host
items={[]}
hidden={state === "revoked-hidden"}
sources={
state === "removed"
? []
: [{ ...importedSources[0], authorized: false }]
}
/>,
);
expect(screen.queryByTestId("canvas")).toBeNull();
expect(document.querySelector(".source-import-receipt")).toBeNull();
await act(async () => finish(importedReceipt));
view.rerender(<Host items={[]} sources={[importedSources[0]]} />);
expect(screen.getByTestId("canvas").dataset.selected ?? "").toBe("");
expect(screen.queryByText(importedReceipt.examples[0].excerpt)).toBeNull();
expect(screen.queryByText("查看已读内容")).toBeNull();
expect(sourceReceipt).toHaveBeenCalledTimes(1);
},
);
it("does not replace a newer memory choice when a source page settles", async () => {
const page = {
items: memories,
total: 2,
offset: 0,
limit: 20,
source_ids_by_memory: { m0: ["codex"], m1: ["codex"] },
};
const view = render(
<Host simplified sources={importedSources} libraryView={pagedView(page)} />,
);
await act(async () => {});
await userEvent.click(screen.getByRole("button", { name: "图中选择 Codex" }));
await userEvent.click(
screen.getByRole("button", { name: "图中选择 第二条" }),
);
expect(await screen.findByRole("heading", { name: "第二条" })).toBeTruthy();
it("REV09: compact preview close keeps source selection for reopening", async () => {
view.rerender(
<Host
simplified
sources={importedSources}
libraryView={{
...pagedView(page),
source: "codex",
settledSource: "codex",
}}
/>,
);
expect(screen.getByRole("heading", { name: "第二条" })).toBeTruthy();
});
it("closing the compact source inspector cancels its pending auto-open", async () => {
vi.stubGlobal("matchMedia", (query: string) => ({
matches: query.includes("max-width"),
addEventListener() {},
removeEventListener() {},
}));
sourceReceipt.mockResolvedValue(importedReceipt);
render(<Host items={[]} sources={importedSources} />);
await openSourceReceipt();
await screen.findByText(importedReceipt.examples[0].excerpt);
const page = {
items: memories,
total: 2,
offset: 0,
limit: 20,
source_ids_by_memory: { m0: ["codex"], m1: ["codex"] },
};
const view = render(
<Host simplified sources={importedSources} libraryView={pagedView(page)} />,
);
await act(async () => {});
await userEvent.click(screen.getByRole("button", { name: "图中选择 Codex" }));
await userEvent.click(screen.getByRole("button", { name: "关闭记忆详情" }));
expect(document.querySelector(".source-import-receipt")).toBeNull();
view.rerender(
<Host
simplified
sources={importedSources}
libraryView={{
...pagedView(page),
source: "codex",
settledSource: "codex",
}}
/>,
);
expect(screen.queryByRole("dialog", { name: "记忆与原文" })).toBeNull();
expect(screen.queryByRole("heading", { name: "第一条" })).toBeNull();
});
it("keeps the source selection and offers retry when its page fails", async () => {
const onRetry = vi.fn();
const page = {
items: memories,
total: 2,
offset: 0,
limit: 20,
source_ids_by_memory: { m0: ["codex"], m1: ["codex"] },
};
const view = render(
<Host simplified sources={importedSources} libraryView={pagedView(page)} />,
);
await act(async () => {});
await userEvent.click(screen.getByRole("button", { name: "图中选择 Codex" }));
view.rerender(
<Host
simplified
sources={importedSources}
libraryView={{
...pagedView(page),
source: "codex",
settledSource: "codex",
failed: true,
onRetry,
}}
/>,
);
expect(
screen
.getAllByRole("alert")
.some((node) => node.textContent === "记忆暂时无法读取"),
).toBe(true);
await userEvent.click(screen.getByRole("button", { name: "重试" }));
expect(onRetry).toHaveBeenCalledOnce();
});
it("drops a pending source selection when that source is revoked", async () => {
const page = {
items: memories,
total: 2,
offset: 0,
limit: 20,
source_ids_by_memory: { m0: ["codex"], m1: ["codex"] },
};
const view = render(
<Host
simplified
sources={[importedSources[0]]}
libraryView={pagedView(page)}
/>,
);
await act(async () => {});
await userEvent.click(screen.getByRole("button", { name: "图中选择 Codex" }));
expect(screen.getByTestId("canvas").dataset.selected).toBe("source:codex");
await userEvent.click(
screen.getByRole("button", { name: "查看选中来源原文" }),
view.rerender(
<Host
simplified
sources={[{ ...importedSources[0], authorized: false }]}
libraryView={{
...pagedView(page),
source: "codex",
settledSource: "codex",
}}
/>,
);
expect(screen.queryByRole("button", { name: "图中选择 Codex" })).toBeNull();
expect(screen.getByTestId("canvas").dataset.selected).not.toBe(
"source:codex",
);
expect(screen.queryByText("正在读取记忆详情…")).toBeNull();
view.rerender(
<Host
simplified
sources={[importedSources[0]]}
libraryView={{
...pagedView(page),
source: "codex",
settledSource: "codex",
}}
/>,
);
expect(screen.getByTestId("canvas").dataset.selected).not.toBe(
"source:codex",
);
expect(screen.getByRole("dialog", { name: "记忆与原文" })).toBeTruthy();
expect(screen.getByText("查看已读内容")).toBeTruthy();
});
it("REV09: first governed memories replace fallback without remounting the graph or breaking memory IDs", async () => {
@@ -1167,5 +1291,4 @@ it.each([
render(<Host items={[]} sources={sources} />);
expect(screen.queryByTestId("canvas")).toBeNull();
expect(document.querySelector(".source-import-receipt")).toBeNull();
expect(sourceReceipt).not.toHaveBeenCalled();
});
+80 -19
View File
@@ -48,7 +48,6 @@ import {
import { OptionMenu } from "./OptionMenu";
import { R6Button } from "./R6Controls";
import { SelectedMemoryDetail } from "./SelectedMemoryDetail";
import { SourceImportReceipt } from "./SourceImportReceipt";
import { sourceNameOr } from "./source-label";
import { useMediaPreference } from "./use-media-preference";
import { Answer, NodeDetail, type SavedAnswer } from "./Workbench";
@@ -97,6 +96,8 @@ type Props = {
loading: boolean;
failed: boolean;
stale: boolean;
settledSource?: string;
settledQuery?: string;
/** The source id the SDK uses for "no source given" (shown as 未识别来源). */
unattributedSource?: string;
onQuery(value: string): void;
@@ -139,7 +140,7 @@ function monthDay(value: string, locale: GuiLocale): string | null {
/** D96: when the cited conversation happened, not when the memory was generated. */
function sourceStamp(
time: { at: string; basis: "conversation" | "import" | "write" } | undefined,
time: { at: string; basis: "conversation" | "import" } | undefined,
fallback: string | null | undefined,
locale: GuiLocale = "zh-CN",
): string {
@@ -147,11 +148,7 @@ function sourceStamp(
if (time && day)
return memoryText(
locale,
time.basis === "conversation"
? "对话于 {day}"
: time.basis === "write"
? "写入于 {day}"
: "导入于 {day}",
time.basis === "conversation" ? "对话于 {day}" : "导入于 {day}",
{ day },
);
return dayStamp(fallback, locale);
@@ -217,6 +214,9 @@ export function MemoryMap(props: Props) {
}, [submittedQuery]);
const [selectedId, setSelectedId] = useState<string | null>(null);
const [groupNode, setGroupNode] = useState<MemoryGraphNode | null>(null);
const [pendingSourceSelection, setPendingSourceSelection] = useState<
string | null
>(null);
const [question, setQuestion] = useState("");
const [inspectorOpen, setInspectorOpen] = useState(false);
const [filterNotice, setFilterNotice] = useState("");
@@ -367,6 +367,7 @@ export function MemoryMap(props: Props) {
// Source revocation also clears a suspended preview, without closing shared memory detail.
if (groupNode && !visibleGroupNode) {
setGroupNode(null);
setPendingSourceSelection(null);
setInspectorOpen(false);
}
}, [groupNode, visibleGroupNode]);
@@ -393,6 +394,7 @@ export function MemoryMap(props: Props) {
const pick = useCallback(
(memoryId: string, scroll = true) => {
if (!active.some((memory) => memory.memory_id === memoryId)) return;
setPendingSourceSelection(null);
scrollSelection.current = scroll;
if (!filtered.some((memory) => memory.memory_id === memoryId)) {
setGroupKey("");
@@ -406,6 +408,32 @@ export function MemoryMap(props: Props) {
},
[active, filtered, onOpenMemory],
);
useEffect(() => {
if (
!library ||
!pendingSourceSelection ||
overlaysSuspended ||
library.loading ||
library.failed ||
!selectedSource ||
selectedSource.source_id !== pendingSourceSelection ||
library.source !== pendingSourceSelection ||
(library.settledSource ?? library.source) !== pendingSourceSelection ||
(library.settledQuery ?? library.query).trim() ||
library.page?.offset !== 0
)
return;
setPendingSourceSelection(null);
const first = active[0];
if (first) pick(first.memory_id, false);
}, [
active,
library,
overlaysSuspended,
pendingSourceSelection,
pick,
selectedSource,
]);
useEffect(() => {
if (!props.simplified || openedDefault.current || overlaysSuspended) return;
if (library?.loading && filtered.length === 0) return;
@@ -456,6 +484,7 @@ export function MemoryMap(props: Props) {
const clear = useCallback(() => {
setSelectedId(null);
setGroupNode(null);
setPendingSourceSelection(null);
setInspectorOpen(false);
onCloseDetail();
}, [onCloseDetail]);
@@ -487,6 +516,7 @@ export function MemoryMap(props: Props) {
clear();
return;
}
setPendingSourceSelection(null);
setInspectorOpen(false);
}
function chooseGroup(key: string) {
@@ -513,8 +543,17 @@ export function MemoryMap(props: Props) {
setAxis("source");
setGroupKey(`source:${node.source_id}`);
if (library) {
setPendingSourceSelection(node.source_id);
library.onQuery("");
library.onSource(node.source_id);
library.resetPage();
} else {
const first = sourceGroups
.find((entry) => entry.key === `source:${node.source_id}`)
?.memoryIds.find((memoryId) =>
active.some((memory) => memory.memory_id === memoryId),
);
if (first) pick(first, false);
}
}
if (node.node_type === "topic") {
@@ -991,11 +1030,13 @@ export function MemoryMap(props: Props) {
<button
type="button"
className="quiet map-tool"
aria-label={text(
selectedSource ? "查看选中来源原文" : "查看选中记忆原文",
)}
aria-label={text("记忆详情")}
title={text("查看原文")}
onClick={() => setInspectorOpen(true)}
onClick={() => {
setInspectorOpen(true);
if (selectedSource && library)
setPendingSourceSelection(selectedSource.source_id);
}}
>
<FileText size={17} />
</button>
@@ -1130,7 +1171,7 @@ export function MemoryMap(props: Props) {
{inspectorDetail
? text("记忆详情")
: selectedSource
? text("来源原文")
? text("记忆详情")
: visibleGroupNode
? text("关联节点")
: text("记忆详情")}
@@ -1149,13 +1190,33 @@ export function MemoryMap(props: Props) {
</header>
<div className="map-inspector-body" ref={inspectorBody}>
{selectedSource ? (
inspectorOpen &&
!overlaysSuspended && (
<SourceImportReceipt
key={selectedSource.source_id}
source={selectedSource}
/>
)
<div className="map-inspector-empty map-inspector-loading">
{library?.failed &&
library.source === selectedSource.source_id ? (
<>
<p role="alert">{text("记忆暂时无法读取")}</p>
<button
type="button"
className="secondary"
onClick={library.onRetry}
>
{text("重试")}
</button>
</>
) : pendingSourceSelection || library?.loading ? (
<>
<div className="map-detail-skeleton" aria-hidden="true">
<i />
<i />
<i />
<i />
</div>
<p role="status">{text("正在读取记忆详情…")}</p>
</>
) : (
<p>{text("还没有已生成的记忆")}</p>
)}
</div>
) : props.simplified && inspectorDetail ? (
<div
aria-busy={
+2
View File
@@ -1027,6 +1027,8 @@ export function MemorySpace({
loading: sdkLibrary.loading,
failed: sdkLibrary.failed,
stale: false,
settledSource: sdkLibrary.settledSource,
settledQuery: sdkLibrary.settledQuery,
unattributedSource: UNATTRIBUTED_SOURCE_ID,
onQuery: setSdkLibraryQuery,
onSource: setSdkLibrarySource,
@@ -176,41 +176,3 @@ it("stamps today's source and conversation time without an evidence page", () =>
expect(screen.getByText(/对话时间 今天 /)).toBeTruthy();
expect(screen.queryByRole("button", { name: "查看原文" })).toBeNull();
});
it("shows an experience's conversation time when the SDK stored one", () => {
const today = new Date().toISOString();
render(
<SelectedMemoryDetail
detail={{
...detail,
evidence: [],
sdk_status: "active",
source_id: "codebuddy",
sdk_memory_at: today,
sdk_created_at: today,
sdk_ref: { scene: "coding", kind: "experience", variant: "coding" },
}}
sources={[]}
/>,
);
expect(screen.getByText(/对话时间 今天 /)).toBeTruthy();
expect(screen.queryByText(/时间未知/)).toBeNull();
});
it("labels an experience without a conversation time by its write time", () => {
render(
<SelectedMemoryDetail
detail={{
...detail,
evidence: [],
sdk_status: "active",
source_id: "codebuddy",
sdk_memory_at: null,
sdk_created_at: new Date().toISOString(),
sdk_ref: { scene: "working", kind: "experience", variant: "working" },
}}
sources={[]}
/>,
);
expect(screen.getByText(/来源 codebuddy · 写入时间 今天 /)).toBeTruthy();
expect(screen.queryByText(/对话时间/)).toBeNull();
expect(screen.queryByText(/时间未知/)).toBeNull();
});
+17 -34
View File
@@ -19,10 +19,7 @@ export function SelectedMemoryDetail({
detail: MemoryDetail;
sources: SourceResponse[];
/** D99: when the cited conversation happened, in the list's own terms. */
conversationAt?: {
at: string;
basis: "conversation" | "import" | "write";
} | null;
conversationAt?: { at: string; basis: "conversation" | "import" } | null;
}) {
const { locale, t } = useGuiLanguage();
const memory = detail.memory;
@@ -45,17 +42,6 @@ export function SelectedMemoryDetail({
? t("selectedMemory.status.superseded")
: t("selectedMemory.status.unknown");
const sourceId = detail.source_id ?? evidence?.source_id;
const created = clockStamp(
detail.sdk_created_at ?? memory.updated_at,
locale,
);
// An SDK experience or fact stored before hy-memory 2.0.7 has no
// conversation time. It shows when it was written, named as such, instead
// of "时间未知".
const writtenOnly =
detail.sdk_ref !== undefined &&
detail.sdk_ref.kind !== "chat" &&
!detail.sdk_memory_at;
const source =
detail.sdk_status && sourceId === UNATTRIBUTED_SOURCE_ID
? t("selectedMemory.sourceUnattributed")
@@ -91,28 +77,25 @@ export function SelectedMemoryDetail({
<p className="selected-memory-source">
{t("selectedMemory.sourceLine", {
source,
time: writtenOnly
? t("selectedMemory.writeTime", { created })
: clockStamp(
detail.sdk_memory_at ?? evidence?.created_at,
locale,
),
time: clockStamp(
detail.sdk_memory_at ?? evidence?.created_at,
locale,
),
})}
</p>
) : null}
{writtenOnly ? null : (
<p className="selected-memory-times">
{t("selectedMemory.timestamps", {
conversation: clockStamp(
conversationAt?.at ??
detail.sdk_memory_at ??
evidence?.created_at,
locale,
),
created,
})}
</p>
)}
<p className="selected-memory-times">
{t("selectedMemory.timestamps", {
conversation: clockStamp(
conversationAt?.at ?? detail.sdk_memory_at ?? evidence?.created_at,
locale,
),
created: clockStamp(
detail.sdk_created_at ?? memory.updated_at,
locale,
),
})}
</p>
</article>
);
}
+2 -32
View File
@@ -119,12 +119,8 @@ it("carries each memory's scene and namespace, and edits an experience's solutio
});
expect(page.window_id).toBe("w1");
expect(page.window_reset).toBe(true);
// Stored without a conversation time: it shows when it was written, not
// an "import".
expect(page.source_time_by_memory?.e).toEqual({
at: new Date((NOW - 86_400) * 1_000).toISOString(),
basis: "write",
});
// An experience has no conversation time to show as "imported".
expect(page.source_time_by_memory?.e).toBeUndefined();
expect(page.items[1]).toMatchObject({
kind: "working",
updated_at: new Date((NOW - 3_600) * 1_000).toISOString(),
@@ -144,29 +140,3 @@ it("carries each memory's scene and namespace, and edits an experience's solutio
expect(sdkEditableText(experience)).toBe("Pin seed");
expect(sdkEditableText(record({}))).toBe("memory");
});
it("shows an experience's conversation time when the SDK stored one", () => {
const at = NOW - 2 * 86_400;
const experience = record({
memory_id: "e",
layer: "experience",
scene: "coding",
kind: "experience",
variant: "coding",
memory_at: at,
created_at: NOW - 3_600,
});
const page = sdkLibraryPage({
memories: [experience],
total: 1,
limit: 20,
offset: 0,
});
expect(page.source_time_by_memory?.e).toEqual({
at: new Date(at * 1_000).toISOString(),
basis: "conversation",
});
expect(sdkDetail(experience).sdk_memory_at).toBe(
new Date(at * 1_000).toISOString(),
);
});
+6 -8
View File
@@ -154,24 +154,22 @@ export function sdkDetail(record: SdkMemoryRecord): MemoryDetail {
export function sdkLibraryPage(page: SdkMemoryPage): MemoryLibraryPage {
const sourceTime: Record<
string,
{ at: string; basis: "conversation" | "import" | "write" }
{ at: string; basis: "conversation" | "import" }
> = {};
const sourceIds: Record<string, string[]> = {};
const refs: Record<string, SdkMemoryRef> = {};
for (const record of page.memories) {
sourceIds[record.memory_id] = [sourceOf(record)];
refs[record.memory_id] = sdkMemoryRef(record);
// An experience or fact stored before hy-memory 2.0.7 has no
// conversation time. It shows when it was written, labelled as such,
// rather than calling its creation an import.
const chat = refs[record.memory_id].kind === "chat";
// An experience or fact has no conversation time; the list shows when it
// was last updated instead of calling its creation an import.
if (refs[record.memory_id].kind !== "chat") continue;
const conversation = plausible(record.memory_at);
const at =
conversation ?? plausible(chat ? record.gmt_created : record.created_at);
const at = conversation ?? plausible(record.gmt_created);
if (at === null) continue;
sourceTime[record.memory_id] = {
at: new Date(at * 1_000).toISOString(),
basis: conversation !== null ? "conversation" : chat ? "import" : "write",
basis: conversation !== null ? "conversation" : "import",
};
}
return {
+1 -1
View File
@@ -282,7 +282,7 @@ Support is declared per immutable GitHub Release manifest:
- Linux x64/arm64 Runtime on native GHA runners.
- macOS arm64/x64 App/Runtime: Developer ID, notarization, native acceptance.
- Windows is not supported.
- `hy-memory==2.0.7` is the current stable SDK dependency; the release
- `hy-memory==2.0.6` is the current stable SDK dependency; the release
lock and [`../MEMORY.md`](../MEMORY.md) are authoritative when the pin changes.
See [`INSTALLATION.md`](INSTALLATION.md).
@@ -39,12 +39,10 @@ which talks to a broker on the same machine.
| `ONE_MEMORY_BROKER_DISCOVERY` | absolute path to the discovery file | How the server finds the running app. Wrong value means recall returns nothing, with no error. |
| `ONE_MEMORY_MANAGED_BY` | `one-memory` | Marks the entry as ours, so our installer will update or remove it later instead of treating it as a stranger's and leaving it alone. |
| `ELECTRON_RUN_AS_NODE` | `1` | The MCP entry ships inside the app bundle and is started through the app's own binary. Without this the binary opens the window instead of running the script. |
| `ONE_MEMORY_MCP_TOOLSET` | tool set version, e.g. `3` | Names the MemHub tool set the entry was written for. MemHub changes it only when tools are added, removed or renamed, so hosts that cache a server's tool list by its launch config restart the server and drop removed tools. The server itself ignores it. |
`ONE_MEMORY_MANAGED_BY` is optional for a hand-made entry. Set it if you want
MemHub to manage the entry for you afterwards; leave it out if you would
rather own the entry yourself. `ONE_MEMORY_MCP_TOOLSET` is optional too; a
managed entry without the current value is rewritten on the next upgrade.
rather own the entry yourself.
## 3. MCP hosts
@@ -59,8 +57,7 @@ Register a stdio server named `one-memory`. The product name is MemHub; the conf
"env": {
"ELECTRON_RUN_AS_NODE": "1",
"ONE_MEMORY_BROKER_DISCOVERY": "<absolute path to the discovery file>",
"ONE_MEMORY_MANAGED_BY": "one-memory",
"ONE_MEMORY_MCP_TOOLSET": "3"
"ONE_MEMORY_MANAGED_BY": "one-memory"
}
}
}
+1 -1
View File
@@ -2,7 +2,7 @@
"name": "one-memory",
"version": "1.0.5",
"release": {
"build_number": 34,
"build_number": 33,
"macos_min_version": "13.0",
"app_update": "manual",
"runtime_manifest_schema": 1,
+2 -4
View File
@@ -468,12 +468,10 @@ export interface MemoryLibraryPage {
limit: number;
/** Active evidence sources for each returned memory, from the same authorized page snapshot. */
source_ids_by_memory?: Record<string, string[]>;
/** When each returned memory's cited conversation happened (D96). `import`
* when only the import time is known; `write` for an SDK experience or fact
* stored without a conversation time, which shows when it was written. */
/** When each returned memory's cited conversation happened (D96). `import` when only the import time is known. */
source_time_by_memory?: Record<
string,
{ at: string; basis: "conversation" | "import" | "write" }
{ at: string; basis: "conversation" | "import" }
>;
/** True when the read budget ran out mid-page (typically during a history
* import): a partial snapshot, not an authoritative view. Show "importing"
@@ -5,8 +5,6 @@ import { describe, expect, it } from "vitest";
import type { RecallRuntime } from "./recall-installer.js";
import {
DISCOVERY_KEY,
MCP_TOOLSET_KEY,
MCP_TOOLSET_VERSION,
OWNER_KEY,
OWNER_VALUE,
renderConnectionMaterial,
@@ -62,7 +60,6 @@ describe("mcp install snippet", () => {
const env = parsed.mcpServers[SERVER_NAME].env;
expect(env[DISCOVERY_KEY]).toBe(runtime().discoveryPath);
expect(env[OWNER_KEY]).toBe(OWNER_VALUE);
expect(env[MCP_TOOLSET_KEY]).toBe(MCP_TOOLSET_VERSION);
expect(env.ELECTRON_RUN_AS_NODE).toBe("1");
});
@@ -177,10 +174,9 @@ describe("public contract document", () => {
// reports it to us. Same class of problem R-33 treated for skill text,
// and treated the same way: one source, checked by a test.
const doc = await fs.readFile(CONTRACT_DOC, "utf8");
for (const key of [DISCOVERY_KEY, OWNER_KEY, MCP_TOOLSET_KEY]) {
for (const key of [DISCOVERY_KEY, OWNER_KEY]) {
expect(doc).toContain(key);
}
expect(doc).toContain(`"${MCP_TOOLSET_KEY}": "${MCP_TOOLSET_VERSION}"`);
expect(doc).toContain(OWNER_VALUE);
expect(doc).toContain(SERVER_NAME);
});
@@ -1,7 +1,6 @@
import { describe, expect, it } from "vitest";
import { type GrantedTools, McpSession } from "./mcp-session.js";
import { MCP_TOOL_NAMES, MCP_TOOLSET_VERSION } from "./mcp-toolset.js";
function sessionWith(granted: GrantedTools): McpSession {
return new McpSession(
@@ -72,14 +71,3 @@ describe("BUG-15: the listed tools follow what the grant allows", () => {
expect(names).toHaveLength(3);
});
});
describe("the tool set stamped on installed entries", () => {
it("names exactly the tools the server lists", async () => {
// Change a tool without moving MCP_TOOLSET_VERSION and hosts that cache
// tool lists by config fingerprint keep showing the old set. Update both,
// and this pinned version, together.
const names = await listedNames(sessionWith(async () => null));
expect(names).toEqual([...MCP_TOOL_NAMES]);
expect(MCP_TOOLSET_VERSION).toBe("3");
});
});
@@ -127,8 +127,6 @@ export class McpSession {
}
private async listedTools() {
// Any change to this set moves MCP_TOOL_NAMES and MCP_TOOLSET_VERSION in
// `mcp-toolset.ts`, so installed entries change and hosts relist.
const definitions = [
recallToolDefinition(),
writeToolDefinition(),
@@ -1,29 +0,0 @@
/** The tool contract of MemHub's MCP server, and the stamp that names it.
*
* Kept apart from `mcp-session.ts` so the installer can stamp an entry without
* pulling in the broker client, and listed here rather than inferred so a test
* can hold `tools/list` to it: change a tool there and that test fails until
* this list, and with it the version, moves too.
*/
/** The tools `tools/list` returns, in order. */
export const MCP_TOOL_NAMES = [
"MemHub_recall",
"MemHub_write",
"MemHub_diagnostic_trace",
] as const;
/** Bumped whenever a tool is added, removed or renamed.
*
* Written into every managed MCP entry under `MCP_TOOLSET_KEY`. Hosts such as
* WorkBuddy cache a server's tool list under a fingerprint of its launch
* config, and our entry is otherwise byte-identical across upgrades, since it
* runs through the `current` symlink. Moving this value is what changes that
* fingerprint, so those hosts restart the server and drop the tools it no
* longer has. Deliberately not the build number: that would rewrite every
* host's config on every upgrade, for nothing.
*/
export const MCP_TOOLSET_VERSION = "3";
/** The env key the version is written under. */
export const MCP_TOOLSET_KEY = "ONE_MEMORY_MCP_TOOLSET";
@@ -5,7 +5,6 @@ import { describe, expect, it } from "vitest";
import {
applyRecallInstall,
MCP_TOOLSET_VERSION,
mcpConfigPath,
opencodeLayout,
planRecallInstall,
@@ -47,7 +46,6 @@ describe("OpenCode recall installer", () => {
ELECTRON_RUN_AS_NODE: "1",
ONE_MEMORY_MANAGED_BY: "one-memory",
ONE_MEMORY_BROKER_DISCOVERY: "/opt/one-memory/runtime.json",
ONE_MEMORY_MCP_TOOLSET: MCP_TOOLSET_VERSION,
},
enabled: true,
});
@@ -58,7 +58,6 @@ import {
readDshServer,
removeDshServer,
} from "./dsh-patch.js";
import { MCP_TOOLSET_KEY, MCP_TOOLSET_VERSION } from "./mcp-toolset.js";
import {
RECALL_PRESENTATION_GUIDANCE,
RECALL_PRESENTATION_GUIDANCE_ZH,
@@ -89,12 +88,8 @@ export {
type RecallLinkState,
type RecallProbe,
} from "./recall-probe.js";
export {
MCP_TOOLSET_KEY,
MCP_TOOLSET_VERSION,
planRecallRepoint,
type RecallRepoint,
};
export { planRecallRepoint, type RecallRepoint };
/** Rewrite hosts whose installed command is not the one we would write now.
*
@@ -2849,9 +2844,6 @@ function managedServer(runtime: RecallRuntime): ManagedServer {
ELECTRON_RUN_AS_NODE: "1",
[DISCOVERY_KEY]: runtime.discoveryPath,
[OWNER_KEY]: OWNER_VALUE,
// Moves the host's fingerprint of this entry when the tool set changes;
// see `MCP_TOOLSET_VERSION`. An entry without it reads as stale.
[MCP_TOOLSET_KEY]: MCP_TOOLSET_VERSION,
...(runtime.credentialFile === undefined
? {}
: { ONE_MEMORY_HOST_CREDENTIAL_FILE: runtime.credentialFile }),
@@ -5,7 +5,6 @@ import path from "node:path";
import { describe, expect, it } from "vitest";
import { applyCursorInstall, planCursorInstall } from "./cursor-installer.js";
import { MCP_TOOLSET_KEY, MCP_TOOLSET_VERSION } from "./mcp-toolset.js";
import {
applyRecallInstall,
applyRecallUninstall,
@@ -16,7 +15,6 @@ import {
planRecallUninstall,
SKILL_MARKER,
SKILL_VERSION,
verifyRecallInstall,
} from "./recall-installer.js";
import {
checkSkillDrift,
@@ -389,59 +387,6 @@ describe("skill drift (R-33)", () => {
});
}, 30_000);
it("marks stale an MCP entry written before the tool set was stamped, and sync stamps it", async () => {
// Our entry is otherwise identical across upgrades, so hosts that cache a
// tool list by config fingerprint keep the removed tools until it changes.
const cursorDir = await fixtureDir();
const discovery = path.join(cursorDir, "broker.json");
await writeFile(discovery, "{}\n");
await applyCursorInstall(
await planCursorInstall(options(cursorDir, discovery)),
);
const layout = cursorLayout(cursorDir);
const configPath = layout.configPath as string;
const config = JSON.parse(await readFile(configPath, "utf8")) as {
mcpServers: Record<string, { env: Record<string, string> }>;
};
expect(config.mcpServers["one-memory"].env[MCP_TOOLSET_KEY]).toBe(
MCP_TOOLSET_VERSION,
);
// What an earlier release wrote and journaled: the same entry, no stamp.
delete config.mcpServers["one-memory"].env[MCP_TOOLSET_KEY];
await writeFile(configPath, `${JSON.stringify(config, null, 2)}\n`);
const journal = JSON.parse(await readFile(layout.journalPath, "utf8"));
journal.installed_server_hash = createHash("sha256")
.update(JSON.stringify(config.mcpServers["one-memory"]))
.digest("hex");
await writeFile(layout.journalPath, `${JSON.stringify(journal)}\n`);
// Still intact as installed, so the link is not reported broken...
await expect(verifyRecallInstall(layout)).resolves.toMatchObject({
hostId: "cursor",
});
// ...but stale against this release, and safe to sync without asking.
await expect(
checkSkillDrift(layout, runtime(discovery)),
).resolves.toMatchObject({
state: "stale",
detail: "the MCP entry predates the tool set this MemHub serves",
});
const sync = await planSkillSync(layout, runtime(discovery));
expect(sync?.requiresConfirmation).toBe(false);
if (sync === null) throw new Error("expected a stale entry sync");
await applyRecallInstall(sync.plan);
const synced = JSON.parse(await readFile(configPath, "utf8")) as {
mcpServers: Record<string, { env: Record<string, string> }>;
};
expect(synced.mcpServers["one-memory"].env[MCP_TOOLSET_KEY]).toBe(
MCP_TOOLSET_VERSION,
);
await expect(
checkSkillDrift(layout, runtime(discovery)),
).resolves.toMatchObject({ state: "in_sync" });
}, 30_000);
it("requires recall for dependent answers, not on every unrelated turn", async () => {
const cursorDir = await fixtureDir();
const discovery = path.join(cursorDir, "broker.json");
+3 -48
View File
@@ -2,7 +2,6 @@ import { createHash } from "node:crypto";
import path from "node:path";
import { captureFilePath, namesCapture } from "./capture-hooks.js";
import { PLUGIN_HOOKS } from "./codebuddy-plugin.js";
import { MCP_TOOLSET_KEY, MCP_TOOLSET_VERSION } from "./mcp-toolset.js";
import {
asInstalled,
expectedPluginHooks,
@@ -10,7 +9,6 @@ import {
type RecallHostLayout,
type RecallInstallPlan,
type RecallRuntime,
readInstalledRecallCommand,
renderSkillContent,
SKILL_VERSION,
} from "./recall-installer.js";
@@ -101,23 +99,7 @@ export async function checkSkillDrift(
runtime: RecallRuntime,
): Promise<SkillDrift | null> {
const skill = await checkSkillFileDrift(layout, runtime);
if (skill === null) {
// A host with no skill mechanism still has an entry whose tool set can
// age. One whose skill is merely missing is left alone: syncing would put
// back a file somebody removed.
if (skillFilePath(layout) !== null) return null;
return (await serverToolsetDrift(layout)) === "stale"
? {
hostId: layout.hostId,
state: "stale",
installedVersion: null,
currentVersion: SKILL_VERSION,
skillPath: null,
detail: TOOLSET_STALE_DETAIL,
}
: null;
}
if (skill.state === "modified") return skill;
if (skill === null || skill.state === "modified") return skill;
const hooks = await pluginHooksDrift(layout, runtime);
if (hooks === "modified") {
return {
@@ -126,19 +108,15 @@ export async function checkSkillDrift(
detail: "the plugin hook file was edited after install",
};
}
if (skill.state === "stale") return skill;
if (hooks === "stale") {
if (hooks === "stale" && skill.state === "in_sync") {
return {
...skill,
state: "stale",
detail: "plugin hooks are still ours, but have moved since install",
};
}
if ((await serverToolsetDrift(layout)) === "stale") {
return { ...skill, state: "stale", detail: TOOLSET_STALE_DETAIL };
}
const capture = await captureHooksDrift(layout);
if (capture === "stale") {
if (capture === "stale" && skill.state === "in_sync") {
return {
...skill,
state: "stale",
@@ -148,29 +126,6 @@ export async function checkSkillDrift(
return skill;
}
const TOOLSET_STALE_DETAIL =
"the MCP entry predates the tool set this MemHub serves";
/** Whether our MCP entry names the tool set this release serves.
*
* The entry is otherwise identical across upgrades, so a host that caches a
* server's tool list under a fingerprint of its config (WorkBuddy does) keeps
* listing tools that are gone until the entry itself changes. Only an entry
* marked as ours is read; a CLI-channel host has no entry at all.
*/
async function serverToolsetDrift(
layout: RecallHostLayout,
): Promise<"in_sync" | "stale" | null> {
if (layout.channel !== "mcp") return null;
// A config the host cannot parse either is not ours to judge here; the
// install and link checks report it.
const installed = await readInstalledRecallCommand(layout).catch(() => null);
if (installed === null) return null;
return installed.env[MCP_TOOLSET_KEY] === MCP_TOOLSET_VERSION
? "in_sync"
: "stale";
}
/** Detect duplicated command hooks left by earlier installs.
*
* `checkSkillDrift` historically compared only the Skill and plugin hooks;
@@ -108,7 +108,6 @@ export const currentChineseMessages = {
"selectedMemory.version": "第 {revision} 版",
"selectedMemory.sourceLine": "来源 {source} · {time}",
"selectedMemory.timestamps": "对话时间 {conversation} · 沉淀时间 {created}",
"selectedMemory.writeTime": "写入时间 {created}",
"selectedMemory.edit": "编辑",
"selectedMemory.evidence": "原始依据",
"selectedMemory.evidenceLine": "{author} · 原对话 {time}:",
@@ -259,7 +258,6 @@ export const currentEnglishMessages: Record<
"selectedMemory.sourceLine": "Source {source} · {time}",
"selectedMemory.timestamps":
"Conversation time {conversation} · Created {created}",
"selectedMemory.writeTime": "Written {created}",
"selectedMemory.edit": "Edit",
"selectedMemory.evidence": "Original evidence",
"selectedMemory.evidenceLine": "{author} · Original conversation {time}:",
@@ -420,7 +418,6 @@ export const currentSpanishMessages: Record<
"selectedMemory.sourceLine": "Origen {source} · {time}",
"selectedMemory.timestamps":
"Conversación {conversation} · Guardada {created}",
"selectedMemory.writeTime": "Escrita {created}",
"selectedMemory.edit": "Editar",
"selectedMemory.evidence": "Evidencia original",
"selectedMemory.evidenceLine": "{author} · Conversación original {time}:",
@@ -1774,7 +1774,6 @@ const baseEnglishMessages = {
" · Import count is still updating; refresh to check",
"memory:对话于 {day}": "Conversation on {day}",
"memory:导入于 {day}": "Imported on {day}",
"memory:写入于 {day}": "Written on {day}",
"memory:已开始处理所选对话": "Processing selected conversations",
"memory:处理请求已受理": "Processing request received",
"memory:模型连接已通过。首批记忆生成后,会逐步出现在记忆列表里。":
@@ -336,7 +336,6 @@ export const memoryChineseMessages = {
"memory: · 导入数量仍在更新,可刷新核对": " · 导入数量仍在更新,可刷新核对",
"memory:对话于 {day}": "对话于 {day}",
"memory:导入于 {day}": "导入于 {day}",
"memory:写入于 {day}": "写入于 {day}",
"memory:已开始处理所选对话": "已开始处理所选对话",
"memory:处理请求已受理": "处理请求已受理",
"memory:模型连接已通过。首批记忆生成后,会逐步出现在记忆列表里。":
@@ -751,7 +750,6 @@ export const memorySpanishMessages: Record<
" · La importación continúa; actualiza para verificar",
"memory:对话于 {day}": "Conversación del {day}",
"memory:导入于 {day}": "Importada el {day}",
"memory:写入于 {day}": "Escrita el {day}",
"memory:已开始处理所选对话": "Procesamiento de conversaciones iniciado",
"memory:处理请求已受理": "Solicitud de procesamiento aceptada",
"memory:模型连接已通过。首批记忆生成后,会逐步出现在记忆列表里。":
@@ -41,7 +41,6 @@ import {
repairInstalledRecallCommands,
verifyRecallInstall,
} from "@one-memory/host-bridge/installer";
import type { DiagnosticInput, DiagnosticSink } from "./diagnostic-log.js";
import {
hasSyncHookArtifacts,
loadSyncHookAssets,
@@ -97,22 +96,8 @@ interface InstallationBinding {
}
const CREDENTIAL_ENV = "ONE_MEMORY_HOST_CREDENTIAL_FILE";
/** The build whose host refresh last completed, beside the grants. */
const HOST_REFRESH_FILE = "host-refresh.json";
const locks = new Map<string, Promise<void>>();
/** What one pass of `refreshInstalledHosts` did, by host id. */
export interface HostRefreshReport {
/** `already_refreshed` when this build had already completed a pass. */
state: "refreshed" | "already_refreshed";
checked: number;
/** Hosts whose installed command or discovery path was moved. */
repointed: string[];
/** Hosts whose stale skill, hooks, plugin files or MCP entry were rewritten. */
synced: string[];
failed: string[];
}
export const LOCAL_EXPERIENCE_UNAVAILABLE = "体验版暂不支持";
/** Trusted local Runtime control plane. Never exposed through MCP or the Core API.
@@ -216,20 +201,11 @@ export class HostPermissions {
* touching authorization. A skill the user edited is left for the detail page.
*/
async syncStaleInstalls(layouts: RecallHostLayout[]): Promise<string[]> {
return (await this.syncStale(layouts)).synced;
}
private async syncStale(
layouts: RecallHostLayout[],
): Promise<{ synced: string[]; failed: string[] }> {
const synced: string[] = [];
const failed: string[] = [];
for (const layout of layouts) {
try {
await this.withHostLock(layout, async () => {
// Not linked from this data directory is not ours to sync, and not
// a failure either.
if (!(await this.isLinked(layout))) return;
await this.requireLinked(layout);
const sync = await planSkillSync(layout, this.runtimeFor(layout));
if (sync === null || sync.requiresConfirmation) return;
const preview = await previewRecallInstall(sync.plan);
@@ -239,128 +215,9 @@ export class HostPermissions {
});
} catch {
// One host that cannot be synced must not stop the runtime starting.
failed.push(layout.hostId);
}
}
return { synced, failed };
}
/** Refresh every host this directory installed, once per build.
*
* An upgrade changes the call rules, hooks and MCP entry we would write, but
* nothing rewrote the copies hosts already had, so they kept telling the
* model to call tools that were gone. This runs the same repoint and stale
* sync the detail page offers, without asking, for files that are still
* exactly ours; an edited skill stays for the user to confirm.
*
* The build it last completed for is recorded beside the grants, so later
* launches of the same build cost one file read. A run in which any host
* failed is not recorded, so the next launch tries again. With no build to
* name (a source checkout) it runs every time and records nothing.
*/
async refreshInstalledHosts(
layouts: RecallHostLayout[],
options: { build?: string; diagnostics?: DiagnosticSink } = {},
): Promise<HostRefreshReport> {
const log = (input: DiagnosticInput) =>
options.diagnostics?.log(input).catch(() => undefined);
const build = options.build?.trim() || undefined;
if (build !== undefined && (await this.readRefreshedBuild()) === build) {
await log({
level: "info",
component: "host.refresh",
event: "host_refresh.skipped",
outcome: "skipped",
details: { reason: "already_refreshed", count: layouts.length },
});
return {
state: "already_refreshed",
checked: layouts.length,
repointed: [],
synced: [],
failed: [],
};
}
const started = Date.now();
const repointed: string[] = [];
const failed: string[] = [];
for (const layout of layouts) {
try {
repointed.push(...(await this.repairInstalledCommands([layout])));
} catch {
failed.push(layout.hostId);
}
}
const stale = await this.syncStale(layouts);
failed.push(...stale.failed.filter((hostId) => !failed.includes(hostId)));
for (const hostId of new Set([...repointed, ...stale.synced])) {
await log({
level: "info",
component: "host.refresh",
event: "host_refresh.host_updated",
outcome: "succeeded",
details: { source_id: hostId },
});
}
for (const hostId of failed) {
await log({
level: "warn",
component: "host.refresh",
event: "host_refresh.host_failed",
outcome: "failed",
error_code: "host_refresh_failed",
details: { source_id: hostId },
});
}
if (build !== undefined && failed.length === 0) {
await this.writeRefreshedBuild(build).catch(() => undefined);
}
await log({
level: failed.length === 0 ? "info" : "warn",
component: "host.refresh",
event: "host_refresh.completed",
outcome: failed.length === 0 ? "succeeded" : "degraded",
duration_ms: Date.now() - started,
details: {
count: layouts.length,
updated_count: new Set([...repointed, ...stale.synced]).size,
error_count: failed.length,
},
});
return {
state: "refreshed",
checked: layouts.length,
repointed,
synced: stale.synced,
failed,
};
}
private async readRefreshedBuild(): Promise<string | null> {
try {
return await storageGuard(async () => {
const file = path.join(this.directory, HOST_REFRESH_FILE);
if ((await optionalStat(file)) === null) return null;
const value: unknown = JSON.parse(await privateRead(file));
return isObject(value) &&
value.schemaVersion === 1 &&
typeof value.build === "string"
? value.build
: null;
});
} catch {
// Unreadable reads as never refreshed: the refresh is idempotent.
return null;
}
}
private async writeRefreshedBuild(build: string): Promise<void> {
await storageGuard(() =>
privateWrite(
path.join(this.directory, HOST_REFRESH_FILE),
`${JSON.stringify({ schemaVersion: 1, build })}\n`,
),
);
return synced;
}
/** Same repair for one host. Does not revoke or reissue its authorization. */
@@ -1085,16 +942,6 @@ export class HostPermissions {
throw new Error("host_permission_installation_incomplete");
}
/** `requireLinked` as a question, for sweeps that skip hosts not ours. */
private async isLinked(layout: RecallHostLayout): Promise<boolean> {
if (!supported(layout)) return false;
const current = await this.inspect(layout, false);
return (
(current.state === "approved" || current.state === "install-only") &&
current.reason === null
);
}
/** Turning off a host with nothing of ours on it is an empty removal, not a
* failure; one whose state cannot be settled is an empty removal carrying
* what to look at, which uninstall refuses. Only what is ours for certain
@@ -79,7 +79,6 @@ export {
} from "./host-control-client.js";
export {
HostPermissions,
type HostRefreshReport,
LOCAL_EXPERIENCE_UNAVAILABLE,
type RecallPermissionStatus,
type RevokeRecallPermissionInput,
@@ -226,7 +226,6 @@ describe("host runtime attestation", () => {
"repairInstalledCommand",
"repairInstalledCommands",
"syncStaleInstalls",
"refreshInstalledHosts",
"previewInstall",
"install",
"setPermission",
@@ -600,7 +600,6 @@ export function guardedPermissions(
"previewSyncHook",
"applySyncHook",
"syncStaleInstalls",
"refreshInstalledHosts",
]);
return new Proxy(owner, {
get(target, property, receiver) {
@@ -740,17 +739,10 @@ export async function createRuntimeService(
).filter((host) => host.hostId !== "openclaw");
await verifyHostRuntime().then(
async () => {
// Every Runtime start after an upgrade, the App's or `memhub
// upgrade`'s, comes through here first; once per build after that.
await permissions.refreshInstalledHosts(
await permissions.repairInstalledCommands(
hosts.map((host) => host.layout),
{
build:
config.buildIdentity?.buildNumber ??
config.buildIdentity?.version,
diagnostics: hostDiagnostics,
},
);
await permissions.syncStaleInstalls(hosts.map((host) => host.layout));
},
() => undefined,
);
+1 -1
View File
@@ -36,7 +36,7 @@ MEMMY_LICENSE = (
MEMMY_PROVENANCE = MEMMY_LICENSE.with_name("PROVENANCE.md")
MEMMY_PIN = "25acd5f29fc7df73b943713abe61f1b2f9839887"
HY_MEMORY_NAME = "hy-memory"
HY_MEMORY_VERSION = "2.0.7"
HY_MEMORY_VERSION = "2.0.6"
class SbomError(RuntimeError):
+1 -1
View File
@@ -12,7 +12,7 @@ test("installation docs disclose publication and lifecycle boundaries", async ()
"utf8",
);
assert.match(readme, /`hy-memory==2\.0\.7`/);
assert.match(readme, /`hy-memory==2\.0\.6`/);
assert.match(readme, /`v1\.0\.5` 的发行目标是 Linux x64\/arm64 Runtime/);
assert.match(readme, /Linux 制品由 GitHub Actions/);
assert.match(readme, /macOS.*Developer ID 签名、公证/s);
+3 -3
View File
@@ -13,7 +13,7 @@ spec.loader.exec_module(module)
def test_hy_memory_notice_comes_from_the_installed_dist_info(tmp_path: Path) -> None:
dist_info = tmp_path / "hy_memory-2.0.7.dist-info"
dist_info = tmp_path / "hy_memory-2.0.6.dist-info"
licenses = dist_info / "licenses"
licenses.mkdir(parents=True)
(dist_info / "METADATA").write_text(
@@ -21,7 +21,7 @@ def test_hy_memory_notice_comes_from_the_installed_dist_info(tmp_path: Path) ->
(
"Metadata-Version: 2.4",
"Name: hy-memory",
"Version: 2.0.7",
"Version: 2.0.6",
"License-Expression: Apache-2.0",
"",
)
@@ -38,7 +38,7 @@ def test_hy_memory_notice_comes_from_the_installed_dist_info(tmp_path: Path) ->
notice = module.hy_memory_notice(dist_info)
assert notice["name"] == "hy-memory"
assert notice["version"] == "2.0.7"
assert notice["version"] == "2.0.6"
assert notice["license"] == "Apache-2.0"
assert notice["copyright"] == "Copyright (C) 2026 Tencent. All rights reserved."
assert "Licensed under the Apache License" in notice["text"]
+1 -1
View File
@@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
[project]
name = "hy-memory"
version = "2.0.7"
version = "2.0.6"
description = "HY Memory - Industrial-grade dual-system cognitive memory framework"
readme = "README.md"
requires-python = ">=3.9"
-229
View File
@@ -1,229 +0,0 @@
# -*- coding: utf-8 -*-
"""经验与事实记下对话时间 memory_at,并以秒级 epoch 对外返回(与 chat 一致)。
之前生产力链路丢了 add() 传进来的 memory_at,coding_memory_meta / working_fact
也没有这一列,MemHub 里所有经验都显示"时间未知"。
"""
import sqlite3
import time
from datetime import datetime, timedelta, timezone
from types import SimpleNamespace
import pytest
from hy_memory.client import HyMemoryClient
from hy_memory.coding.extractor import CodingMemoryExtractor
from hy_memory.coding.reconciler import CodingMemoryReconciler
from hy_memory.coding.store import CodingMemoryStore, _CREATE_TABLE_SQL, _CREATE_FACT_TABLE_SQL
from hy_memory.coding.types import CodingMemory, WorkingFact
from hy_memory.coding.writer import CodingWriter
from tests.coding._helpers import MockLLMProvider
from tests.coding.test_list_and_stats_by_scene import _client, _unwrapped
from tests.coding.test_write_scene_routing import _messages
from tests.coding.test_writer_e2e import _coding_messages, _extract_resp, _reconcile_resp
T1 = datetime(2026, 10, 1, 9, 30, tzinfo=timezone.utc)
T2 = T1 + timedelta(days=3)
def _epoch(value: datetime) -> int:
return int(value.timestamp())
def _writer(store, embed, llm):
return CodingWriter(
store=store,
extractor=CodingMemoryExtractor(llm),
reconciler=CodingMemoryReconciler(store, embed, llm),
llm_provider=llm, embed_service=embed,
)
@pytest.mark.asyncio
class TestStore:
async def test_round_trip_as_epoch(self, coding_store):
await coding_store.insert(CodingMemory(
experience_id="e1", user_id="u", task="T", solution="S", memory_at=T1,
))
await coding_store.insert_fact(WorkingFact(
fact_id="f1", user_id="u", statement="x", memory_at=T1,
))
exp = await coding_store.get_by_id("e1", user_id="u")
fact = await coding_store.get_fact("f1", user_id="u")
assert _epoch(exp.memory_at) == _epoch(T1)
assert exp.to_dict()["memory_at"] == _epoch(T1)
assert fact.to_dict()["memory_at"] == _epoch(T1)
async def test_missing_time_stays_unknown(self, coding_store):
await coding_store.insert(CodingMemory(
experience_id="e1", user_id="u", task="T", solution="S",
))
exp = await coding_store.get_by_id("e1", user_id="u")
assert exp.memory_at is None and exp.to_dict()["memory_at"] is None
@pytest.mark.parametrize("old, new", [(T1, T2), (T2, T1), (T2, None)])
async def test_update_keeps_the_later_time(self, coding_store, old, new):
await coding_store.insert(CodingMemory(
experience_id="e1", user_id="u", task="T", solution="S",
session_id="s", memory_at=old,
))
await coding_store.update(CodingMemory(
experience_id="e1", user_id="u", task="T", solution="S2",
session_id="s", memory_at=new,
))
exp = await coding_store.get_by_id("e1", user_id="u")
assert exp.solution == "S2"
assert _epoch(exp.memory_at) == _epoch(T2)
async def test_edit_keeps_the_time(self, coding_store):
await coding_store.insert(CodingMemory(
experience_id="e1", user_id="u", task="T", solution="S", memory_at=T1,
))
await coding_store.edit_experience("e1", user_id="u", solution="S2")
exp = await coding_store.get_by_id("e1", user_id="u")
assert _epoch(exp.memory_at) == _epoch(T1)
@pytest.mark.asyncio
@pytest.mark.parametrize("variant", ["working", "coding"])
async def test_existing_database_gains_the_column_on_open(coding_config, mock_embed, variant):
store = CodingMemoryStore(coding_config, embed_service=mock_embed, variant=variant)
# 先按老 schema(没有 memory_at)建库并放一行
conn = sqlite3.connect(store._db_path)
conn.executescript(_CREATE_TABLE_SQL.replace(" memory_at INTEGER,\n", ""))
conn.executescript(_CREATE_FACT_TABLE_SQL.replace(" memory_at INTEGER,\n", ""))
conn.execute(
"INSERT INTO coding_memory_meta (experience_id, user_id, task, solution, "
"created_at, updated_at) VALUES ('old', 'u', 'T', 'S', '2026-01-01T00:00:00', "
"'2026-01-01T00:00:00')"
)
conn.commit()
conn.close()
await store.initialize()
try:
old = await store.get_by_id("old", user_id="u")
assert old is not None and old.memory_at is None
await store.insert_fact(WorkingFact(
fact_id="f1", user_id="u", statement="x", memory_at=T1,
))
assert _epoch((await store.get_fact("f1")).memory_at) == _epoch(T1)
finally:
await store.close()
# 再开一次不出错(ADD COLUMN 是幂等的)
again = CodingMemoryStore(coding_config, embed_service=mock_embed, variant=variant)
await again.initialize()
await again.close()
@pytest.mark.asyncio
class TestWriter:
def _llm(self):
return MockLLMProvider([
_extract_resp(
[{"task": "打包发布", "solution": "S", "confidence": 0.9}],
[{"statement": "发布脚本在 scripts/publish_package.sh"}],
),
_reconcile_resp([{"draft_idx": 0, "action": "ADD"}]),
])
async def test_conversation_time_reaches_both_layers(self, coding_store, mock_embed):
resp = await _writer(coding_store, mock_embed, self._llm()).write(
_coding_messages(), user_id="u", session_id="s", memory_at=T1,
)
exp = await coding_store.get_by_id(resp["experience_ids"][0], user_id="u")
fact = await coding_store.get_fact(resp["fact_ids"][0], user_id="u")
assert _epoch(exp.memory_at) == _epoch(T1)
assert _epoch(fact.memory_at) == _epoch(T1)
async def test_defaults_to_request_time_like_chat(self, coding_store, mock_embed):
before = int(time.time())
resp = await _writer(coding_store, mock_embed, self._llm()).write(
_coding_messages(), user_id="u", session_id="s",
)
exp = await coding_store.get_by_id(resp["experience_ids"][0], user_id="u")
fact = await coding_store.get_fact(resp["fact_ids"][0], user_id="u")
for stamp in (_epoch(exp.memory_at), _epoch(fact.memory_at)):
assert before <= stamp <= int(time.time()) + 1
@pytest.mark.asyncio
async def test_add_passes_the_time_to_the_productivity_writer(monkeypatch):
seen = {}
async def _judge(*args, **kwargs):
return "working"
class _Writer:
async def write(self, *args, **kwargs):
seen.update(kwargs)
return {"success": True, "experience_ids": [], "fact_ids": []}
async def _get_writer(*, variant):
return _Writer()
monkeypatch.setattr("hy_memory.client.classify_messages_scene", _judge)
ns = SimpleNamespace(
_config=SimpleNamespace(coding=SimpleNamespace(enable=True)),
_coding_llm_provider=object(),
_cache=None,
_get_coding_writer=_get_writer,
_normalize_scene=HyMemoryClient._normalize_scene,
_scene_variant=HyMemoryClient._scene_variant,
_parse_input=HyMemoryClient._parse_input,
)
await HyMemoryClient._async_add_impl(
ns, _messages(), user_id="u1", agent_id="a1", memory_at=T1,
)
assert seen["memory_at"] == T1
@pytest.mark.asyncio
async def test_list_get_and_search_return_epoch_seconds(coding_config, mock_embed):
coding = CodingMemoryStore(coding_config, embed_service=mock_embed, variant="coding")
await coding.initialize()
try:
await coding.insert(CodingMemory(
experience_id="e1", user_id="u", task="打包发布", solution="S", memory_at=T1,
))
await coding.insert_fact(WorkingFact(
fact_id="f1", user_id="u", statement="打包发布", memory_at=T2,
derived_experience_ids=["e1"],
))
client = _client([], {"coding": coding})
list_memories = _unwrapped("async_list_memories")
page = await list_memories(client, user_id="u", scene="coding")
assert page["experiences"][0]["memory_at"] == _epoch(T1)
merged = await list_memories(client, user_id="u", scene="all")
assert merged["memories"][0]["memory_at"] == _epoch(T1)
exp = await coding.get_by_id("e1", user_id="u")
got = HyMemoryClient._format_productivity_get(
exp, kind="experience", variant="coding", request_id="r", elapsed_ms=0,
)
assert got["memory_at"] == _epoch(T1)
client._embed_service = mock_embed
client._coding_store = coding
async def _ready(variant="working"):
return None
client._get_coding_writer = _ready
client._cache = None
resp = await HyMemoryClient._search_coding(
client, target_query="打包发布", user_ids=["u"], workspace_id=None,
branch=None, limit=5, working_fact_min_score=0.0,
working_experience_min_score=0.0, request_id="r",
t0=time.perf_counter(), variant="coding",
)
assert resp["facts"][0]["memory_at"] == _epoch(T2)
assert resp["experiences"][0]["memory_at"] == _epoch(T1)
unified = HyMemoryClient._search_lane_items("coding", resp)
assert {item["memory_at"] for item in unified} == {_epoch(T1), _epoch(T2)}
finally:
await coding.close()