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 re
import weakref import weakref
from contextlib import suppress from contextlib import suppress
import tiktoken
from datetime import datetime from datetime import datetime
from pathlib import Path from pathlib import Path
from typing import TYPE_CHECKING, Any, Callable, Iterator from typing import TYPE_CHECKING, Any, Callable, Iterator
import tiktoken
from loguru import logger from loguru import logger
from nanobot.utils.prompt_templates import render_template from nanobot.agent.runner import AgentRunner, AgentRunSpec
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.tools.registry import ToolRegistry from nanobot.agent.tools.registry import ToolRegistry
from nanobot.utils.gitstore import GitStore 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: if TYPE_CHECKING:
from nanobot.providers.base import LLMProvider from nanobot.providers.base import LLMProvider
@ -55,7 +60,7 @@ class MemoryStore:
self._corruption_logged = False # rate-limit non-int cursor warning self._corruption_logged = False # rate-limit non-int cursor warning
self._oversize_logged = False # rate-limit oversized-entry warning self._oversize_logged = False # rate-limit oversized-entry warning
self._git = GitStore(workspace, tracked_files=[ 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() self._maybe_migrate_legacy_history()
@ -350,7 +355,7 @@ class MemoryStore:
read_size = min(size, 4096) read_size = min(size, 4096)
f.seek(size - read_size) f.seek(size - read_size)
data = f.read().decode("utf-8") 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: if not lines:
return None return None
return json.loads(lines[-1]) return json.loads(lines[-1])
@ -780,7 +785,7 @@ class Dream:
from nanobot.agent.skills import BUILTIN_SKILLS_DIR 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] = {} entries: dict[str, str] = {}
for base in (self.store.workspace / "skills", BUILTIN_SKILLS_DIR): for base in (self.store.workspace / "skills", BUILTIN_SKILLS_DIR):
if not base.exists(): if not base.exists():
@ -795,7 +800,7 @@ class Dream:
if d.name in entries and base == BUILTIN_SKILLS_DIR: if d.name in entries and base == BUILTIN_SKILLS_DIR:
continue continue
content = skill_md.read_text(encoding="utf-8")[:500] 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)" desc = m.group(1).strip() if m else "(no description)"
entries[d.name] = desc entries[d.name] = desc
return [f"{name}{desc}" for name, desc in sorted(entries.items())] return [f"{name}{desc}" for name, desc in sorted(entries.items())]

View File

@ -5,7 +5,7 @@ from datetime import datetime
import pytest import pytest
from nanobot.agent.memory import MemoryStore, _HISTORY_ENTRY_HARD_CAP from nanobot.agent.memory import _HISTORY_ENTRY_HARD_CAP, MemoryStore
@pytest.fixture @pytest.fixture
@ -241,6 +241,26 @@ class TestDreamCursor:
store2 = MemoryStore(store.workspace) store2 = MemoryStore(store.workspace)
assert store2.get_last_dream_cursor() == 3 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: class TestLegacyHistoryMigration:
def test_read_unprocessed_history_handles_entries_without_cursor(self, store): def test_read_unprocessed_history_handles_entries_without_cursor(self, store):