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
12 changed files with 313 additions and 432 deletions

No files matched your search

-16
View File
@@ -68,22 +68,6 @@ Hy Memory and MemHub are versioned and released independently.
`hy_node_permissions` proof table. Evidence, governed memories, model `hy_node_permissions` proof table. Evidence, governed memories, model
profiles, SDK stores, and source/host grants are preserved. profiles, SDK stores, and source/host grants are preserved.
## Hy Memory 2.0.7 — 2026-10-28
- 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 ## Hy Memory 2.0.6 — 2026-10-27
- Published - Published
+1 -8
View File
@@ -1544,7 +1544,6 @@ class HyMemoryClient:
# LLM 写的"这个结论一路怎么改成现在这样",直接进提示词; # LLM 写的"这个结论一路怎么改成现在这样",直接进提示词;
# 没被覆盖过就是空串。不参与检索——召回认的是 statement 的当前值。 # 没被覆盖过就是空串。不参与检索——召回认的是 statement 的当前值。
"fact_chain_summary": fact.fact_chain_summary, "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, "created_at": fact.created_at.isoformat() if fact.created_at else None,
"score": round(float(score), 4), "score": round(float(score), 4),
"source": fact.source, "source": fact.source,
@@ -1653,8 +1652,6 @@ class HyMemoryClient:
"score": (round(float(score_by_id[d.experience_id]), 4) "score": (round(float(score_by_id[d.experience_id]), 4)
if d.experience_id in score_by_id else None), if d.experience_id in score_by_id else None),
"confidence": d.confidence, "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, "updated_at": d.updated_at.isoformat() if d.updated_at else None,
"source": d.source, "source": d.source,
"extraction_source": d.extraction_source, "extraction_source": d.extraction_source,
@@ -1956,7 +1953,6 @@ class HyMemoryClient:
"content" chat 正文;经验为 "task\nsolution";事实为 statement "content" chat 正文;经验为 "task\nsolution";事实为 statement
"score" RRF 融合分;"lane_score" 为该路原始分;"lane_rank" 为该路名次 "score" RRF 融合分;"lane_score" 为该路原始分;"lane_rank" 为该路名次
"source", "created_at", "updated_at"(秒级 epoch,取不到为 None) "source", "created_at", "updated_at"(秒级 epoch,取不到为 None)
"memory_at" 对话时间(秒级 epoch),各路原有字段,三路同名同格式
chat 路内部名次:normal → schema → proactive 依次排;working/coding 路 chat 路内部名次:normal → schema → proactive 依次排;working/coding 路
内部名次:事实与经验各按原顺序交替(事实在前),两层谁也不压谁。 内部名次:事实与经验各按原顺序交替(事实在前),两层谁也不压谁。
""" """
@@ -2148,8 +2144,7 @@ class HyMemoryClient:
"limit", "offset", "request_id", "elapsed_ms"} "limit", "offset", "request_id", "elapsed_ms"}
每条带 "scene"、"kind"(chat | experience)、"memory_id"、"content" 每条带 "scene"、"kind"(chat | experience)、"memory_id"、"content"
(经验为 "task\nsolution")、"source"、"status"、"created_at"、 (经验为 "task\nsolution")、"source"、"status"、"created_at"、
"updated_at"(秒级 epoch),以及该路原有字段(含对话时间 "updated_at"(秒级 epoch),以及该路原有字段。
"memory_at",秒级 epoch,三路同名同格式)。
""" """
return self._loop_thread.run( return self._loop_thread.run(
self.async_list_memories( self.async_list_memories(
@@ -2972,7 +2967,6 @@ class HyMemoryClient:
session_id=session_id, session_id=session_id,
request_id=request_id, request_id=request_id,
source=source, source=source,
memory_at=memory_at,
) )
# Public receipt always identifies the productivity namespace. # Public receipt always identifies the productivity namespace.
resp["variant"] = scene_variant resp["variant"] = scene_variant
@@ -5926,7 +5920,6 @@ class HyMemoryClient:
"source_fact_ids": list(d.source_fact_ids), "source_fact_ids": list(d.source_fact_ids),
"confidence": d.confidence, "confidence": d.confidence,
"status": MemoryStatus.ACTIVE.value, "status": MemoryStatus.ACTIVE.value,
"memory_at": _epoch_seconds(d.memory_at),
"created_at": d.created_at.isoformat() if d.created_at else None, "created_at": d.created_at.isoformat() if d.created_at else None,
"updated_at": d.updated_at.isoformat() if d.updated_at else None, "updated_at": d.updated_at.isoformat() if d.updated_at else None,
"source": d.source, "source": d.source,
+1 -5
View File
@@ -454,10 +454,9 @@ class WorkingFactReconciler:
workspace_id: Optional[str] = None, workspace_id: Optional[str] = None,
branch: Optional[str] = None, branch: Optional[str] = None,
source: str = "default", source: str = "default",
memory_at: Optional[datetime] = None,
) -> Tuple[List[WorkingFact], List[FactReconcileOp]]: ) -> Tuple[List[WorkingFact], List[FactReconcileOp]]:
""" """
执行 reconcile + 持久化。memory_at 是这段对话的时间,记到新事实上。 执行 reconcile + 持久化。
Returns: Returns:
(真正落库的新事实, 执行过的 ops)。LLM 挂掉时退化为全 INSERT 不覆盖, (真正落库的新事实, 执行过的 ops)。LLM 挂掉时退化为全 INSERT 不覆盖,
@@ -494,7 +493,6 @@ class WorkingFactReconciler:
workspace_id=workspace_id, workspace_id=workspace_id,
branch=branch, branch=branch,
source=source, source=source,
memory_at=memory_at,
) )
logger.info( logger.info(
f"[fact-reconcile] drafts={len(drafts)} inserted={len(facts)} " f"[fact-reconcile] drafts={len(drafts)} inserted={len(facts)} "
@@ -783,7 +781,6 @@ class WorkingFactReconciler:
workspace_id: Optional[str], workspace_id: Optional[str],
branch: Optional[str] = None, branch: Optional[str] = None,
source: str = "default", source: str = "default",
memory_at: Optional[datetime] = None,
) -> Tuple[List[WorkingFact], List[FactReconcileOp]]: ) -> Tuple[List[WorkingFact], List[FactReconcileOp]]:
inserted: List[WorkingFact] = [] inserted: List[WorkingFact] = []
ops: List[FactReconcileOp] = [] ops: List[FactReconcileOp] = []
@@ -804,7 +801,6 @@ class WorkingFactReconciler:
subject=draft.subject, subject=draft.subject,
details=draft.details, details=draft.details,
status="active", status="active",
memory_at=memory_at,
) )
allowed = {c.fact_id for c in (candidates[idx] if idx < len(candidates) else [])} 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, confidence=d.confidence,
source=d.source, source=d.source,
extraction_source=d.extraction_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, confidence REAL NOT NULL DEFAULT 0.7,
source TEXT NOT NULL DEFAULT 'default', source TEXT NOT NULL DEFAULT 'default',
extraction_source TEXT NOT NULL DEFAULT 'auto_extract', extraction_source TEXT NOT NULL DEFAULT 'auto_extract',
memory_at INTEGER,
created_at TEXT NOT NULL, created_at TEXT NOT NULL,
updated_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 '[]', derived_experience_ids TEXT NOT NULL DEFAULT '[]',
status TEXT NOT NULL DEFAULT 'active', status TEXT NOT NULL DEFAULT 'active',
fact_chain_summary TEXT NOT NULL DEFAULT '', fact_chain_summary TEXT NOT NULL DEFAULT '',
memory_at INTEGER,
created_at TEXT NOT NULL created_at TEXT NOT NULL
); );
-- 反查前驱(谁的 fact_link 指向我)走这个索引,写演变总结时要顺着链往回走 -- 反查前驱(谁的 fact_link 指向我)走这个索引,写演变总结时要顺着链往回走
@@ -166,8 +164,6 @@ _ADDED_COLUMNS = {
"coding_memory_meta": { "coding_memory_meta": {
"source_fact_ids": "TEXT NOT NULL DEFAULT '[]'", "source_fact_ids": "TEXT NOT NULL DEFAULT '[]'",
"extraction_source": "TEXT NOT NULL DEFAULT 'auto_extract'", "extraction_source": "TEXT NOT NULL DEFAULT 'auto_extract'",
# 对话时间,秒级 epoch(同 chat)。老库里的行是 NULL:那时没存过。
"memory_at": "INTEGER",
}, },
# 早期版本存过一列 fact_chain(机械累积的历代说法),已废弃:同样的信息顺着 # 早期版本存过一列 fact_chain(机械累积的历代说法),已废弃:同样的信息顺着
# fact_link 反查就有。老库里那一列留着不管,有默认值不影响插入。 # fact_link 反查就有。老库里那一列留着不管,有默认值不影响插入。
@@ -181,7 +177,6 @@ _ADDED_COLUMNS = {
"subject": "TEXT NOT NULL DEFAULT ''", "subject": "TEXT NOT NULL DEFAULT ''",
"details": "TEXT NOT NULL DEFAULT ''", "details": "TEXT NOT NULL DEFAULT ''",
"source": "TEXT NOT NULL DEFAULT 'default'", "source": "TEXT NOT NULL DEFAULT 'default'",
"memory_at": "INTEGER",
}, },
} }
@@ -295,29 +290,6 @@ def _parse_dt(s: Optional[str]) -> Optional[datetime]:
return None 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: def _row_to_memory(row: sqlite3.Row) -> CodingMemory:
"""SQLite row → CodingMemory""" """SQLite row → CodingMemory"""
return CodingMemory( return CodingMemory(
@@ -336,7 +308,6 @@ def _row_to_memory(row: sqlite3.Row) -> CodingMemory:
confidence=row["confidence"], confidence=row["confidence"],
source=row["source"], source=row["source"],
extraction_source=row["extraction_source"], extraction_source=row["extraction_source"],
memory_at=_from_epoch(row["memory_at"]),
created_at=_parse_dt(row["created_at"]), created_at=_parse_dt(row["created_at"]),
updated_at=_parse_dt(row["updated_at"]), updated_at=_parse_dt(row["updated_at"]),
) )
@@ -363,7 +334,6 @@ def _row_to_fact(row: sqlite3.Row) -> WorkingFact:
# 老库刚 ADD COLUMN 时可能取不到这一列,用 keys() 兜一下 # 老库刚 ADD COLUMN 时可能取不到这一列,用 keys() 兜一下
fact_chain_summary=(row["fact_chain_summary"] or "" fact_chain_summary=(row["fact_chain_summary"] or ""
if "fact_chain_summary" in row.keys() else ""), if "fact_chain_summary" in row.keys() else ""),
memory_at=_from_epoch(row["memory_at"]),
created_at=_parse_dt(row["created_at"]), created_at=_parse_dt(row["created_at"]),
) )
@@ -627,8 +597,6 @@ class CodingMemoryStore:
memory.updated_at = datetime.utcnow() memory.updated_at = datetime.utcnow()
if memory.created_at is None: if memory.created_at is None:
memory.created_at = prev.created_at 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) await _run_in_pool(self._upsert_sql_sync, memory)
# 删旧 VDB # 删旧 VDB
await self._delete_vec_keys_by_count(memory.experience_id, prev_key_count) 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, experience_id, user_id, agent_id, task, search_keys, solution,
boundary_envs, workspace_id, branch, session_id, boundary_envs, workspace_id, branch, session_id,
files, source_fact_ids, confidence, source, extraction_source, files, source_fact_ids, confidence, source, extraction_source,
memory_at, created_at, updated_at created_at, updated_at
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?) ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
ON CONFLICT(experience_id) DO UPDATE SET ON CONFLICT(experience_id) DO UPDATE SET
user_id = excluded.user_id, user_id = excluded.user_id,
agent_id = excluded.agent_id, agent_id = excluded.agent_id,
@@ -1248,7 +1216,6 @@ class CodingMemoryStore:
confidence = excluded.confidence, confidence = excluded.confidence,
source = excluded.source, source = excluded.source,
extraction_source = excluded.extraction_source, extraction_source = excluded.extraction_source,
memory_at = excluded.memory_at,
updated_at = excluded.updated_at updated_at = excluded.updated_at
""", """,
( (
@@ -1259,7 +1226,6 @@ class CodingMemoryStore:
json.dumps(memory.files, ensure_ascii=False), json.dumps(memory.files, ensure_ascii=False),
json.dumps(memory.source_fact_ids, ensure_ascii=False), json.dumps(memory.source_fact_ids, ensure_ascii=False),
memory.confidence, memory.source, memory.extraction_source, memory.confidence, memory.source, memory.extraction_source,
_to_epoch(memory.memory_at),
(memory.created_at or datetime.utcnow()).isoformat(), (memory.created_at or datetime.utcnow()).isoformat(),
(memory.updated_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, fact_id, user_id, agent_id, session_id, workspace_id, branch,
source, statement, files, subject, details, source, statement, files, subject, details,
fact_link, derived_experience_ids, fact_link, derived_experience_ids,
status, fact_chain_summary, memory_at, created_at status, fact_chain_summary, created_at
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?) ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
ON CONFLICT(fact_id) DO UPDATE SET ON CONFLICT(fact_id) DO UPDATE SET
user_id = excluded.user_id, user_id = excluded.user_id,
agent_id = excluded.agent_id, agent_id = excluded.agent_id,
@@ -1416,8 +1382,7 @@ class CodingMemoryStore:
fact_link = excluded.fact_link, fact_link = excluded.fact_link,
derived_experience_ids = excluded.derived_experience_ids, derived_experience_ids = excluded.derived_experience_ids,
status = excluded.status, status = excluded.status,
fact_chain_summary = excluded.fact_chain_summary, fact_chain_summary = excluded.fact_chain_summary
memory_at = excluded.memory_at
""", """,
( (
fact.fact_id, fact.user_id, fact.agent_id, fact.fact_id, fact.user_id, fact.agent_id,
@@ -1430,7 +1395,6 @@ class CodingMemoryStore:
json.dumps(fact.derived_experience_ids, ensure_ascii=False), json.dumps(fact.derived_experience_ids, ensure_ascii=False),
fact.status, fact.status,
fact.fact_chain_summary or "", fact.fact_chain_summary or "",
_to_epoch(fact.memory_at),
(fact.created_at or datetime.utcnow()).isoformat(), (fact.created_at or datetime.utcnow()).isoformat(),
), ),
) )
-14
View File
@@ -25,11 +25,6 @@ FactStatus = Literal["active", "superseded"]
MAX_FACT_CHAIN_ENTRIES = 8 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 阶段输出) # 草稿(LLM extract 阶段输出)
# ================================================================ # ================================================================
@@ -58,8 +53,6 @@ class CodingMemoryDraft:
files: List[str] = field(default_factory=list) files: List[str] = field(default_factory=list)
source: str = "default" source: str = "default"
extraction_source: str = "auto_extract" extraction_source: str = "auto_extract"
# 对话时间(SDK 注入,同 chat 的 memory_at)
memory_at: Optional[datetime] = None
def __post_init__(self) -> None: def __post_init__(self) -> None:
from ..utils.source_filter import normalize_source from ..utils.source_filter import normalize_source
@@ -80,7 +73,6 @@ class CodingMemoryDraft:
"files": list(self.files), "files": list(self.files),
"source": self.source, "source": self.source,
"extraction_source": self.extraction_source, "extraction_source": self.extraction_source,
"memory_at": _epoch(self.memory_at),
} }
@@ -119,9 +111,6 @@ class CodingMemory:
extraction_source: str = "auto_extract" 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 created_at: Optional[datetime] = None
updated_at: Optional[datetime] = None updated_at: Optional[datetime] = None
@@ -146,7 +135,6 @@ class CodingMemory:
"confidence": self.confidence, "confidence": self.confidence,
"source": self.source, "source": self.source,
"extraction_source": self.extraction_source, "extraction_source": self.extraction_source,
"memory_at": _epoch(self.memory_at),
"created_at": self.created_at.isoformat() if self.created_at else None, "created_at": self.created_at.isoformat() if self.created_at else None,
"updated_at": self.updated_at.isoformat() if self.updated_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 = "" fact_chain_summary: str = ""
# 时间 # 时间
memory_at: Optional[datetime] = None # 对话时间(同 chat),没传时是收到请求的时间
created_at: Optional[datetime] = None # 产生时间,新的优先 created_at: Optional[datetime] = None # 产生时间,新的优先
def __post_init__(self) -> None: def __post_init__(self) -> None:
@@ -285,7 +272,6 @@ class WorkingFact:
"derived_experience_ids": list(self.derived_experience_ids), "derived_experience_ids": list(self.derived_experience_ids),
"status": self.status, "status": self.status,
"fact_chain_summary": self.fact_chain_summary, "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, "created_at": self.created_at.isoformat() if self.created_at else None,
} }
+1 -8
View File
@@ -18,7 +18,6 @@ CodingWriter - 写入端协调器
import logging import logging
import time import time
import uuid import uuid
from datetime import datetime
from typing import Any, Dict, List, Optional from typing import Any, Dict, List, Optional
from ..agent.llm_provider import LLMProvider from ..agent.llm_provider import LLMProvider
@@ -80,11 +79,9 @@ class CodingWriter:
session_id: Optional[str] = None, session_id: Optional[str] = None,
request_id: Optional[str] = None, request_id: Optional[str] = None,
source: str = "default", source: str = "default",
memory_at: Optional[datetime] = None,
) -> Dict[str, Any]: ) -> Dict[str, Any]:
""" """
working/coding 段写入端到端。memory_at 是对话时间,记到这次写出的经验和 working/coding 段写入端到端。返回响应 dict(兼容 client.add 返回结构):
事实上;不传则用收到请求的时间(与 chat 链路一致)。返回响应 dict(兼容 client.add 返回结构):
{ {
"success": True, "success": True,
"scene": "productivity", "scene": "productivity",
@@ -104,8 +101,6 @@ class CodingWriter:
""" """
request_id = request_id or str(uuid.uuid4()) request_id = request_id or str(uuid.uuid4())
t0 = time.perf_counter() t0 = time.perf_counter()
if memory_at is None:
memory_at = datetime.now()
if not messages: if not messages:
return self._empty_response(request_id, t0) return self._empty_response(request_id, t0)
@@ -177,7 +172,6 @@ class CodingWriter:
# extractor 只负责内容;调用来源由 SDK 入口注入,不能交给 LLM 生成。 # extractor 只负责内容;调用来源由 SDK 入口注入,不能交给 LLM 生成。
for draft in drafts: for draft in drafts:
draft.source = source draft.source = source
draft.memory_at = memory_at
# 3. 经验层 reconcile + 持久化 # 3. 经验层 reconcile + 持久化
ops: List[ReconcileOp] = ( ops: List[ReconcileOp] = (
@@ -203,7 +197,6 @@ class CodingWriter:
workspace_id=workspace_id, workspace_id=workspace_id,
branch=branch, branch=branch,
source=source, source=source,
memory_at=memory_at,
) )
experience_ids = [op.target_experience_id for op in ops experience_ids = [op.target_experience_id for op in ops
+224 -96
View File
@@ -5,7 +5,6 @@ import type {
MemoryGraphNode, MemoryGraphNode,
MemoryGraphResponse, MemoryGraphResponse,
MemoryProcessingSummary, MemoryProcessingSummary,
SourceImportReceipt as Receipt,
SourceResponse, SourceResponse,
} from "@one-memory/contracts"; } from "@one-memory/contracts";
import { import {
@@ -40,12 +39,6 @@ import { afterEach, expect, it, vi } from "vitest";
import { MemoryMap } from "./MemoryMap"; import { MemoryMap } from "./MemoryMap";
import type { MemoryArrival } from "./memory-arrival-state"; import type { MemoryArrival } from "./memory-arrival-state";
const { sourceReceipt } = vi.hoisted(() => ({
sourceReceipt: vi.fn(),
}));
vi.mock("./api", () => ({
getSourceImportReceipt: sourceReceipt,
}));
vi.mock("./MemoryGraph3D", () => ({ vi.mock("./MemoryGraph3D", () => ({
MemoryGraph3D: ({ MemoryGraph3D: ({
graph, graph,
@@ -982,35 +975,6 @@ const importedSources = [
imported_conversations: 1, imported_conversations: 1,
}, },
] as SourceResponse[]; ] 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 () => { it("REV09: selecting an imported source retains the canvas and only its authorized node", async () => {
render(<Host items={[]} sources={importedSources} />); render(<Host items={[]} sources={importedSources} />);
const canvas = screen.getByTestId("canvas"); const canvas = screen.getByTestId("canvas");
@@ -1025,7 +989,8 @@ it("REV09: selecting an imported source retains the canvas and only its authoriz
expect( expect(
screen.getByRole("list", { name: "地图记忆列表" }).children, screen.getByRole("list", { name: "地图记忆列表" }).children,
).toHaveLength(0); ).toHaveLength(0);
expect(sourceReceipt).not.toHaveBeenCalled(); expect(screen.getByRole("heading", { name: "记忆详情" })).toBeTruthy();
expect(screen.getByText("还没有已生成的记忆")).toBeTruthy();
await userEvent.click(screen.getByRole("button", { name: /全部记忆/ })); await userEvent.click(screen.getByRole("button", { name: /全部记忆/ }));
expect(within(canvas).getAllByRole("button")).toHaveLength(2); expect(within(canvas).getAllByRole("button")).toHaveLength(2);
expect(canvas.dataset.selected ?? "").toBe(""); expect(canvas.dataset.selected ?? "").toBe("");
@@ -1047,83 +1012,247 @@ it("REV09: fallback search matches source names and IDs without bypassing the se
); );
await userEvent.type(search, "cOdEx"); await userEvent.type(search, "cOdEx");
expect(screen.getByTestId("canvas").dataset.selected).toBe("source:codex"); expect(screen.getByTestId("canvas").dataset.selected).toBe("source:codex");
expect(screen.getByText("查看已读内容")).toBeTruthy(); expect(screen.getByText("还没有已生成的记忆")).toBeTruthy();
await userEvent.clear(search); await userEvent.clear(search);
await userEvent.type(search, "Cursor"); await userEvent.type(search, "Cursor");
expect(screen.queryByTestId("canvas")).toBeNull(); expect(screen.queryByTestId("canvas")).toBeNull();
expect(screen.queryByText("查看已读内容")).toBeNull(); expect(screen.queryByText("正在读取记忆详情…")).toBeNull();
await userEvent.clear(search); await userEvent.clear(search);
expect(screen.getByTestId("canvas").dataset.selected ?? "").toBe(""); 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 () => { it("selecting a source node waits for its settled page, then opens the first memory", async () => {
sourceReceipt.mockResolvedValue(importedReceipt); const onQuery = vi.fn();
render(<Host items={[]} sources={importedSources} />); const onSource = vi.fn();
await openSourceReceipt(); const resetPage = vi.fn();
expect( const oldPage = {
await screen.findByText(importedReceipt.examples[0].excerpt), items: memories,
).toBeTruthy(); total: 2,
expect(screen.getByText("用户 · 时间未知")).toBeTruthy(); offset: 0,
expect(screen.getByText("原始消息").nextElementSibling?.textContent).toBe( limit: 20,
"5", 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. await act(async () => {});
expect(screen.queryByRole("button", { name: "查看完整对话" })).toBeNull(); 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"])( it("does not replace a newer memory choice when a source page settles", async () => {
"REV09: %s source drops fallback, preview and a late receipt without reopening on reauthorization", const page = {
async (state) => { items: memories,
let finish: (value: Receipt) => void = () => {}; total: 2,
sourceReceipt.mockImplementation( offset: 0,
() => limit: 20,
new Promise<Receipt>((resolve) => { source_ids_by_memory: { m0: ["codex"], m1: ["codex"] },
finish = resolve; };
}), const view = render(
); <Host simplified sources={importedSources} libraryView={pagedView(page)} />,
const view = render(<Host items={[]} sources={[importedSources[0]]} />); );
await openSourceReceipt(); await act(async () => {});
view.rerender( await userEvent.click(screen.getByRole("button", { name: "图中选择 Codex" }));
<Host await userEvent.click(
items={[]} screen.getByRole("button", { name: "图中选择 第二条" }),
hidden={state === "revoked-hidden"} );
sources={ expect(await screen.findByRole("heading", { name: "第二条" })).toBeTruthy();
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("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) => ({ vi.stubGlobal("matchMedia", (query: string) => ({
matches: query.includes("max-width"), matches: query.includes("max-width"),
addEventListener() {}, addEventListener() {},
removeEventListener() {}, removeEventListener() {},
})); }));
sourceReceipt.mockResolvedValue(importedReceipt); const page = {
render(<Host items={[]} sources={importedSources} />); items: memories,
await openSourceReceipt(); total: 2,
await screen.findByText(importedReceipt.examples[0].excerpt); 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: "关闭记忆详情" })); 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"); 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 () => { it("REV09: first governed memories replace fallback without remounting the graph or breaking memory IDs", async () => {
@@ -1162,5 +1291,4 @@ it.each([
render(<Host items={[]} sources={sources} />); render(<Host items={[]} sources={sources} />);
expect(screen.queryByTestId("canvas")).toBeNull(); expect(screen.queryByTestId("canvas")).toBeNull();
expect(document.querySelector(".source-import-receipt")).toBeNull(); expect(document.querySelector(".source-import-receipt")).toBeNull();
expect(sourceReceipt).not.toHaveBeenCalled();
}); });
+78 -13
View File
@@ -48,7 +48,6 @@ import {
import { OptionMenu } from "./OptionMenu"; import { OptionMenu } from "./OptionMenu";
import { R6Button } from "./R6Controls"; import { R6Button } from "./R6Controls";
import { SelectedMemoryDetail } from "./SelectedMemoryDetail"; import { SelectedMemoryDetail } from "./SelectedMemoryDetail";
import { SourceImportReceipt } from "./SourceImportReceipt";
import { sourceNameOr } from "./source-label"; import { sourceNameOr } from "./source-label";
import { useMediaPreference } from "./use-media-preference"; import { useMediaPreference } from "./use-media-preference";
import { Answer, NodeDetail, type SavedAnswer } from "./Workbench"; import { Answer, NodeDetail, type SavedAnswer } from "./Workbench";
@@ -97,6 +96,8 @@ type Props = {
loading: boolean; loading: boolean;
failed: boolean; failed: boolean;
stale: boolean; stale: boolean;
settledSource?: string;
settledQuery?: string;
/** The source id the SDK uses for "no source given" (shown as 未识别来源). */ /** The source id the SDK uses for "no source given" (shown as 未识别来源). */
unattributedSource?: string; unattributedSource?: string;
onQuery(value: string): void; onQuery(value: string): void;
@@ -213,6 +214,9 @@ export function MemoryMap(props: Props) {
}, [submittedQuery]); }, [submittedQuery]);
const [selectedId, setSelectedId] = useState<string | null>(null); const [selectedId, setSelectedId] = useState<string | null>(null);
const [groupNode, setGroupNode] = useState<MemoryGraphNode | null>(null); const [groupNode, setGroupNode] = useState<MemoryGraphNode | null>(null);
const [pendingSourceSelection, setPendingSourceSelection] = useState<
string | null
>(null);
const [question, setQuestion] = useState(""); const [question, setQuestion] = useState("");
const [inspectorOpen, setInspectorOpen] = useState(false); const [inspectorOpen, setInspectorOpen] = useState(false);
const [filterNotice, setFilterNotice] = useState(""); const [filterNotice, setFilterNotice] = useState("");
@@ -363,6 +367,7 @@ export function MemoryMap(props: Props) {
// Source revocation also clears a suspended preview, without closing shared memory detail. // Source revocation also clears a suspended preview, without closing shared memory detail.
if (groupNode && !visibleGroupNode) { if (groupNode && !visibleGroupNode) {
setGroupNode(null); setGroupNode(null);
setPendingSourceSelection(null);
setInspectorOpen(false); setInspectorOpen(false);
} }
}, [groupNode, visibleGroupNode]); }, [groupNode, visibleGroupNode]);
@@ -389,6 +394,7 @@ export function MemoryMap(props: Props) {
const pick = useCallback( const pick = useCallback(
(memoryId: string, scroll = true) => { (memoryId: string, scroll = true) => {
if (!active.some((memory) => memory.memory_id === memoryId)) return; if (!active.some((memory) => memory.memory_id === memoryId)) return;
setPendingSourceSelection(null);
scrollSelection.current = scroll; scrollSelection.current = scroll;
if (!filtered.some((memory) => memory.memory_id === memoryId)) { if (!filtered.some((memory) => memory.memory_id === memoryId)) {
setGroupKey(""); setGroupKey("");
@@ -402,6 +408,32 @@ export function MemoryMap(props: Props) {
}, },
[active, filtered, onOpenMemory], [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(() => { useEffect(() => {
if (!props.simplified || openedDefault.current || overlaysSuspended) return; if (!props.simplified || openedDefault.current || overlaysSuspended) return;
if (library?.loading && filtered.length === 0) return; if (library?.loading && filtered.length === 0) return;
@@ -452,6 +484,7 @@ export function MemoryMap(props: Props) {
const clear = useCallback(() => { const clear = useCallback(() => {
setSelectedId(null); setSelectedId(null);
setGroupNode(null); setGroupNode(null);
setPendingSourceSelection(null);
setInspectorOpen(false); setInspectorOpen(false);
onCloseDetail(); onCloseDetail();
}, [onCloseDetail]); }, [onCloseDetail]);
@@ -483,6 +516,7 @@ export function MemoryMap(props: Props) {
clear(); clear();
return; return;
} }
setPendingSourceSelection(null);
setInspectorOpen(false); setInspectorOpen(false);
} }
function chooseGroup(key: string) { function chooseGroup(key: string) {
@@ -509,8 +543,17 @@ export function MemoryMap(props: Props) {
setAxis("source"); setAxis("source");
setGroupKey(`source:${node.source_id}`); setGroupKey(`source:${node.source_id}`);
if (library) { if (library) {
setPendingSourceSelection(node.source_id);
library.onQuery("");
library.onSource(node.source_id); library.onSource(node.source_id);
library.resetPage(); 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") { if (node.node_type === "topic") {
@@ -987,11 +1030,13 @@ export function MemoryMap(props: Props) {
<button <button
type="button" type="button"
className="quiet map-tool" className="quiet map-tool"
aria-label={text( aria-label={text("记忆详情")}
selectedSource ? "查看选中来源原文" : "查看选中记忆原文",
)}
title={text("查看原文")} title={text("查看原文")}
onClick={() => setInspectorOpen(true)} onClick={() => {
setInspectorOpen(true);
if (selectedSource && library)
setPendingSourceSelection(selectedSource.source_id);
}}
> >
<FileText size={17} /> <FileText size={17} />
</button> </button>
@@ -1126,7 +1171,7 @@ export function MemoryMap(props: Props) {
{inspectorDetail {inspectorDetail
? text("记忆详情") ? text("记忆详情")
: selectedSource : selectedSource
? text("来源原文") ? text("记忆详情")
: visibleGroupNode : visibleGroupNode
? text("关联节点") ? text("关联节点")
: text("记忆详情")} : text("记忆详情")}
@@ -1145,13 +1190,33 @@ export function MemoryMap(props: Props) {
</header> </header>
<div className="map-inspector-body" ref={inspectorBody}> <div className="map-inspector-body" ref={inspectorBody}>
{selectedSource ? ( {selectedSource ? (
inspectorOpen && <div className="map-inspector-empty map-inspector-loading">
!overlaysSuspended && ( {library?.failed &&
<SourceImportReceipt library.source === selectedSource.source_id ? (
key={selectedSource.source_id} <>
source={selectedSource} <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 ? ( ) : props.simplified && inspectorDetail ? (
<div <div
aria-busy={ aria-busy={
+2
View File
@@ -1027,6 +1027,8 @@ export function MemorySpace({
loading: sdkLibrary.loading, loading: sdkLibrary.loading,
failed: sdkLibrary.failed, failed: sdkLibrary.failed,
stale: false, stale: false,
settledSource: sdkLibrary.settledSource,
settledQuery: sdkLibrary.settledQuery,
unattributedSource: UNATTRIBUTED_SOURCE_ID, unattributedSource: UNATTRIBUTED_SOURCE_ID,
onQuery: setSdkLibraryQuery, onQuery: setSdkLibraryQuery,
onSource: setSdkLibrarySource, onSource: setSdkLibrarySource,
+1 -1
View File
@@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
[project] [project]
name = "hy-memory" name = "hy-memory"
version = "2.0.7" version = "2.0.6"
description = "HY Memory - Industrial-grade dual-system cognitive memory framework" description = "HY Memory - Industrial-grade dual-system cognitive memory framework"
readme = "README.md" readme = "README.md"
requires-python = ">=3.9" 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()