Compare commits

...
Author SHA1 Message Date
alvinfeiandClaude Opus 5.5 fc2e51a8cc release: hy-memory 2.0.7
Coding and working experiences and facts keep their conversation time
(memory_at) and return it from list, get and search. Not yet published to
PyPI.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-10-28 11:09:54 +08:00
alvinfeiandClaude Opus 5.5 d4fca0639e feat(sdk): coding and working experiences keep their conversation time
client.add passed memory_at only to the chat path; the productivity writer
dropped it and coding_memory_meta / working_fact had no column for it, so
every experience had only its write time and MemHub showed it as 时间未知.

Both tables gain a nullable memory_at column (epoch seconds), added to
existing coding/working databases on open. The writer stamps it on drafts
and new facts, defaulting to the request time as chat does; an UPDATE keeps
the later of the stored and incoming times. list_memories (coding, working,
all), get and search return memory_at in chat's format.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-10-28 11:09:54 +08:00
9 changed files with 323 additions and 9 deletions

No files matched your search

+16
View File
@@ -68,6 +68,22 @@ 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
+8 -1
View File
@@ -1544,6 +1544,7 @@ 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,
@@ -1652,6 +1653,8 @@ 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,
@@ -1953,6 +1956,7 @@ 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 路
内部名次:事实与经验各按原顺序交替(事实在前),两层谁也不压谁。 内部名次:事实与经验各按原顺序交替(事实在前),两层谁也不压谁。
""" """
@@ -2144,7 +2148,8 @@ 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(
@@ -2967,6 +2972,7 @@ 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
@@ -5920,6 +5926,7 @@ 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,
+5 -1
View File
@@ -454,9 +454,10 @@ 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 + 持久化。 执行 reconcile + 持久化。memory_at 是这段对话的时间,记到新事实上。
Returns: Returns:
(真正落库的新事实, 执行过的 ops)。LLM 挂掉时退化为全 INSERT 不覆盖, (真正落库的新事实, 执行过的 ops)。LLM 挂掉时退化为全 INSERT 不覆盖,
@@ -493,6 +494,7 @@ 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)} "
@@ -781,6 +783,7 @@ 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] = []
@@ -801,6 +804,7 @@ 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,6 +577,7 @@ 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,
) )
+41 -5
View File
@@ -86,6 +86,7 @@ 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
); );
@@ -109,6 +110,7 @@ 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 指向我)走这个索引,写演变总结时要顺着链往回走
@@ -164,6 +166,8 @@ _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 反查就有。老库里那一列留着不管,有默认值不影响插入。
@@ -177,6 +181,7 @@ _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",
}, },
} }
@@ -290,6 +295,29 @@ 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(
@@ -308,6 +336,7 @@ 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"]),
) )
@@ -334,6 +363,7 @@ 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"]),
) )
@@ -597,6 +627,8 @@ 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)
@@ -1199,8 +1231,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,
created_at, updated_at memory_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,
@@ -1216,6 +1248,7 @@ 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
""", """,
( (
@@ -1226,6 +1259,7 @@ 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(),
), ),
@@ -1366,8 +1400,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, created_at status, fact_chain_summary, memory_at, 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,
@@ -1382,7 +1416,8 @@ 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,
@@ -1395,6 +1430,7 @@ 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,6 +25,11 @@ 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 阶段输出)
# ================================================================ # ================================================================
@@ -53,6 +58,8 @@ 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
@@ -73,6 +80,7 @@ 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),
} }
@@ -111,6 +119,9 @@ 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
@@ -135,6 +146,7 @@ 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,
} }
@@ -249,6 +261,7 @@ 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:
@@ -272,6 +285,7 @@ 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,
} }
+8 -1
View File
@@ -18,6 +18,7 @@ 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
@@ -79,9 +80,11 @@ 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 段写入端到端。返回响应 dict(兼容 client.add 返回结构): working/coding 段写入端到端。memory_at 是对话时间,记到这次写出的经验和
事实上;不传则用收到请求的时间(与 chat 链路一致)。返回响应 dict(兼容 client.add 返回结构):
{ {
"success": True, "success": True,
"scene": "productivity", "scene": "productivity",
@@ -101,6 +104,8 @@ 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)
@@ -172,6 +177,7 @@ 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] = (
@@ -197,6 +203,7 @@ 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
+1 -1
View File
@@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
[project] [project]
name = "hy-memory" name = "hy-memory"
version = "2.0.6" version = "2.0.7"
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
@@ -0,0 +1,229 @@
# -*- 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()