From 44a341335abd964f12e2f73be39b7cd19d617124 Mon Sep 17 00:00:00 2001 From: Jefsky Date: Wed, 6 May 2026 23:18:41 +0800 Subject: [PATCH] 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 --- nanobot/agent/memory.py | 23 ++++++++++++++--------- tests/agent/test_memory_store.py | 22 +++++++++++++++++++++- 2 files changed, 35 insertions(+), 10 deletions(-) diff --git a/nanobot/agent/memory.py b/nanobot/agent/memory.py index 7794af5c2..6d31f6e62 100644 --- a/nanobot/agent/memory.py +++ b/nanobot/agent/memory.py @@ -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())] diff --git a/tests/agent/test_memory_store.py b/tests/agent/test_memory_store.py index 9113437fd..4f58e9e37 100644 --- a/tests/agent/test_memory_store.py +++ b/tests/agent/test_memory_store.py @@ -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):