diff --git a/hy_memory/agent/episode_summary.py b/hy_memory/agent/episode_summary.py index 377350e..d48cd08 100644 --- a/hy_memory/agent/episode_summary.py +++ b/hy_memory/agent/episode_summary.py @@ -39,6 +39,7 @@ import logging import hashlib import json import re +from datetime import datetime from typing import Any, Dict, List, Optional import uuid @@ -252,6 +253,17 @@ def _series_episode_id( return str(uuid.uuid5(uuid.NAMESPACE_URL, "hy-memory:series-episode:" + raw)) +def _series_memory_at(times: List[int]) -> Optional[datetime]: + """Earliest member timestamp as the same local wall clock it came from. + + ``_node_timestamp`` produces ``datetime.timestamp()`` values. Reading them + back with ``utcfromtimestamp`` turns them into a naive *UTC* datetime, which + the vector payload later re-interprets as local time and shifts by the + machine's UTC offset. + """ + return datetime.fromtimestamp(min(times)) if times else None + + def _node_timestamp(node: Any) -> Optional[int]: value = getattr(node, "memory_at", None) or getattr(node, "gmt_created", None) if value is None: @@ -974,8 +986,6 @@ async def finalize_series_episode_containers( Stored text is only a title plus a structural intro. Exact L2 bodies remain in their canonical nodes and are referenced by ID from dated timeline segments. """ - from datetime import datetime - from ..models.memory import MemoryLayer, MemoryNode, MemoryStatus, SourceType from ..pipelines._retrieval.session_episode import ( add_parent_episode_membership, @@ -1061,7 +1071,7 @@ async def finalize_series_episode_containers( segment["memory_at"] for segment in plan["timeline"] if segment.get("memory_at") is not None ] - memory_at = datetime.utcfromtimestamp(min(times)) if times else None + memory_at = _series_memory_at(times) content = f"{plan['title']}. {plan['intro']}".strip() series = MemoryNode( node_id=series_id, user_id=user_id, diff --git a/tests/test_episode.py b/tests/test_episode.py index 943e213..5dbebc0 100644 --- a/tests/test_episode.py +++ b/tests/test_episode.py @@ -710,3 +710,20 @@ class TestInstrumentation: assert logged[1]["count"] == 5, "累计条数要落下来" assert logged[2]["reused"] is False assert all(r["gap"] == 1800.0 for r in logged) + + +def test_series_memory_at_round_trips_local_wall_clock(monkeypatch): + """Series timestamps must not shift by the machine's UTC offset.""" + import time as _time + + from hy_memory.agent.episode_summary import _series_memory_at + + monkeypatch.setenv("TZ", "Asia/Shanghai") + _time.tzset() + try: + expected = datetime(2026, 1, 1, 12, 0) + assert _series_memory_at([int(expected.timestamp())]) == expected + assert _series_memory_at([]) is None + finally: + monkeypatch.delenv("TZ", raising=False) + _time.tzset()