fix(dream): restore cursor with memory state

Track the Dream cursor in memory versioning so restores do not skip history after rolling back Dream commits.

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
Jefsky 2026-05-06 23:18:41 +08:00 committed by Xubin Ren
parent ac18a8baad
commit 44a341335a
2 changed files with 35 additions and 10 deletions

View File

@ -8,19 +8,24 @@ import os
import re
import weakref
from contextlib import suppress
import tiktoken
from datetime import datetime
from pathlib import Path
from typing import TYPE_CHECKING, Any, Callable, Iterator
import tiktoken
from loguru import logger
from nanobot.utils.prompt_templates import render_template
from nanobot.utils.helpers import ensure_dir, estimate_message_tokens, estimate_prompt_tokens_chain, strip_think, truncate_text
from nanobot.agent.runner import AgentRunSpec, AgentRunner
from nanobot.agent.runner import AgentRunner, AgentRunSpec
from nanobot.agent.tools.registry import ToolRegistry
from nanobot.utils.gitstore import GitStore
from nanobot.utils.helpers import (
ensure_dir,
estimate_message_tokens,
estimate_prompt_tokens_chain,
strip_think,
truncate_text,
)
from nanobot.utils.prompt_templates import render_template
if TYPE_CHECKING:
from nanobot.providers.base import LLMProvider
@ -55,7 +60,7 @@ class MemoryStore:
self._corruption_logged = False # rate-limit non-int cursor warning
self._oversize_logged = False # rate-limit oversized-entry warning
self._git = GitStore(workspace, tracked_files=[
"SOUL.md", "USER.md", "memory/MEMORY.md",
"SOUL.md", "USER.md", "memory/MEMORY.md", "memory/.dream_cursor",
])
self._maybe_migrate_legacy_history()
@ -350,7 +355,7 @@ class MemoryStore:
read_size = min(size, 4096)
f.seek(size - read_size)
data = f.read().decode("utf-8")
lines = [l for l in data.split("\n") if l.strip()]
lines = [line for line in data.split("\n") if line.strip()]
if not lines:
return None
return json.loads(lines[-1])
@ -780,7 +785,7 @@ class Dream:
from nanobot.agent.skills import BUILTIN_SKILLS_DIR
_DESC_RE = _re.compile(r"^description:\s*(.+)$", _re.MULTILINE | _re.IGNORECASE)
desc_re = _re.compile(r"^description:\s*(.+)$", _re.MULTILINE | _re.IGNORECASE)
entries: dict[str, str] = {}
for base in (self.store.workspace / "skills", BUILTIN_SKILLS_DIR):
if not base.exists():
@ -795,7 +800,7 @@ class Dream:
if d.name in entries and base == BUILTIN_SKILLS_DIR:
continue
content = skill_md.read_text(encoding="utf-8")[:500]
m = _DESC_RE.search(content)
m = desc_re.search(content)
desc = m.group(1).strip() if m else "(no description)"
entries[d.name] = desc
return [f"{name}{desc}" for name, desc in sorted(entries.items())]

View File

@ -5,7 +5,7 @@ from datetime import datetime
import pytest
from nanobot.agent.memory import MemoryStore, _HISTORY_ENTRY_HARD_CAP
from nanobot.agent.memory import _HISTORY_ENTRY_HARD_CAP, MemoryStore
@pytest.fixture
@ -241,6 +241,26 @@ class TestDreamCursor:
store2 = MemoryStore(store.workspace)
assert store2.get_last_dream_cursor() == 3
def test_git_restore_rolls_back_dream_cursor(self, tmp_path):
store = MemoryStore(tmp_path)
store.write_memory("before")
store.set_last_dream_cursor(1)
assert store.git.init() is True
store.write_memory("after")
store.set_last_dream_cursor(2)
dream_sha = store.git.auto_commit("dream: update")
assert dream_sha is not None
store.write_memory("newer")
store.set_last_dream_cursor(3)
restore_sha = store.git.revert(dream_sha)
assert restore_sha is not None
assert store.read_memory() == "before"
assert store.get_last_dream_cursor() == 1
class TestLegacyHistoryMigration:
def test_read_unprocessed_history_handles_entries_without_cursor(self, store):