feat(memory): harden legacy history migration and Dream UX

This commit is contained in:
Xubin Ren
2026-04-04 08:41:46 +00:00
parent 7e0c196797
commit 6e896249c8
7 changed files with 629 additions and 38 deletions
+127
View File
@@ -4,6 +4,7 @@ from __future__ import annotations
import asyncio
import json
import re
import weakref
from datetime import datetime
from pathlib import Path
@@ -30,6 +31,11 @@ class MemoryStore:
"""Pure file I/O for memory files: MEMORY.md, history.jsonl, SOUL.md, USER.md."""
_DEFAULT_MAX_HISTORY = 1000
_LEGACY_ENTRY_START_RE = re.compile(r"^\[(\d{4}-\d{2}-\d{2}[^\]]*)\]\s*")
_LEGACY_TIMESTAMP_RE = re.compile(r"^\[(\d{4}-\d{2}-\d{2} \d{2}:\d{2})\]\s*")
_LEGACY_RAW_MESSAGE_RE = re.compile(
r"^\[\d{4}-\d{2}-\d{2}[^\]]*\]\s+[A-Z][A-Z0-9_]*(?:\s+\[tools:\s*[^\]]+\])?:"
)
def __init__(self, workspace: Path, max_history_entries: int = _DEFAULT_MAX_HISTORY):
self.workspace = workspace
@@ -37,6 +43,7 @@ class MemoryStore:
self.memory_dir = ensure_dir(workspace / "memory")
self.memory_file = self.memory_dir / "MEMORY.md"
self.history_file = self.memory_dir / "history.jsonl"
self.legacy_history_file = self.memory_dir / "HISTORY.md"
self.soul_file = workspace / "SOUL.md"
self.user_file = workspace / "USER.md"
self._cursor_file = self.memory_dir / ".cursor"
@@ -44,6 +51,7 @@ class MemoryStore:
self._git = GitStore(workspace, tracked_files=[
"SOUL.md", "USER.md", "memory/MEMORY.md",
])
self._maybe_migrate_legacy_history()
@property
def git(self) -> GitStore:
@@ -58,6 +66,125 @@ class MemoryStore:
except FileNotFoundError:
return ""
def _maybe_migrate_legacy_history(self) -> None:
"""One-time upgrade from legacy HISTORY.md to history.jsonl.
The migration is best-effort and prioritizes preserving as much content
as possible over perfect parsing.
"""
if self.history_file.exists() or not self.legacy_history_file.exists():
return
try:
legacy_text = self.legacy_history_file.read_text(
encoding="utf-8",
errors="replace",
)
except OSError:
logger.exception("Failed to read legacy HISTORY.md for migration")
return
entries = self._parse_legacy_history(legacy_text)
try:
if entries:
self._write_entries(entries)
last_cursor = entries[-1]["cursor"]
self._cursor_file.write_text(str(last_cursor), encoding="utf-8")
# Default to "already processed" so upgrades do not replay the
# user's entire historical archive into Dream on first start.
self._dream_cursor_file.write_text(str(last_cursor), encoding="utf-8")
backup_path = self._next_legacy_backup_path()
self.legacy_history_file.replace(backup_path)
logger.info(
"Migrated legacy HISTORY.md to history.jsonl ({} entries)",
len(entries),
)
except Exception:
logger.exception("Failed to migrate legacy HISTORY.md")
def _parse_legacy_history(self, text: str) -> list[dict[str, Any]]:
normalized = text.replace("\r\n", "\n").replace("\r", "\n").strip()
if not normalized:
return []
fallback_timestamp = self._legacy_fallback_timestamp()
entries: list[dict[str, Any]] = []
chunks = self._split_legacy_history_chunks(normalized)
for cursor, chunk in enumerate(chunks, start=1):
timestamp = fallback_timestamp
content = chunk
match = self._LEGACY_TIMESTAMP_RE.match(chunk)
if match:
timestamp = match.group(1)
remainder = chunk[match.end():].lstrip()
if remainder:
content = remainder
entries.append({
"cursor": cursor,
"timestamp": timestamp,
"content": content,
})
return entries
def _split_legacy_history_chunks(self, text: str) -> list[str]:
lines = text.split("\n")
chunks: list[str] = []
current: list[str] = []
saw_blank_separator = False
for line in lines:
if saw_blank_separator and line.strip() and current:
chunks.append("\n".join(current).strip())
current = [line]
saw_blank_separator = False
continue
if self._should_start_new_legacy_chunk(line, current):
chunks.append("\n".join(current).strip())
current = [line]
saw_blank_separator = False
continue
current.append(line)
saw_blank_separator = not line.strip()
if current:
chunks.append("\n".join(current).strip())
return [chunk for chunk in chunks if chunk]
def _should_start_new_legacy_chunk(self, line: str, current: list[str]) -> bool:
if not current:
return False
if not self._LEGACY_ENTRY_START_RE.match(line):
return False
if self._is_raw_legacy_chunk(current) and self._LEGACY_RAW_MESSAGE_RE.match(line):
return False
return True
def _is_raw_legacy_chunk(self, lines: list[str]) -> bool:
first_nonempty = next((line for line in lines if line.strip()), "")
match = self._LEGACY_TIMESTAMP_RE.match(first_nonempty)
if not match:
return False
return first_nonempty[match.end():].lstrip().startswith("[RAW]")
def _legacy_fallback_timestamp(self) -> str:
try:
return datetime.fromtimestamp(
self.legacy_history_file.stat().st_mtime,
).strftime("%Y-%m-%d %H:%M")
except OSError:
return datetime.now().strftime("%Y-%m-%d %H:%M")
def _next_legacy_backup_path(self) -> Path:
candidate = self.memory_dir / "HISTORY.md.bak"
suffix = 2
while candidate.exists():
candidate = self.memory_dir / f"HISTORY.md.bak.{suffix}"
suffix += 1
return candidate
# -- MEMORY.md (long-term facts) -----------------------------------------
def read_memory(self) -> str: