Compare commits
2
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
fc2e51a8cc | ||
|
|
d4fca0639e |
No files matched your search
@@ -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
@@ -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,
|
||||||
|
|||||||
@@ -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 [])}
|
||||||
|
|||||||
@@ -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,
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -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(),
|
||||||
),
|
),
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -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,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -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
@@ -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"
|
||||||
|
|||||||
@@ -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()
|
||||||
Reference in new issue
Block a user