From 6e896249c8e6b795b657aafb92b436eb40728a8f Mon Sep 17 00:00:00 2001 From: Xubin Ren Date: Sat, 4 Apr 2026 08:41:46 +0000 Subject: [PATCH] feat(memory): harden legacy history migration and Dream UX --- core_agent_lines.sh | 96 +++++++++++++--- nanobot/agent/memory.py | 127 +++++++++++++++++++++ nanobot/channels/telegram.py | 28 +++-- nanobot/command/builtin.py | 111 +++++++++++++++--- tests/agent/test_memory_store.py | 135 +++++++++++++++++++++- tests/channels/test_telegram_channel.py | 27 +++++ tests/command/test_builtin_dream.py | 143 ++++++++++++++++++++++++ 7 files changed, 629 insertions(+), 38 deletions(-) create mode 100644 tests/command/test_builtin_dream.py diff --git a/core_agent_lines.sh b/core_agent_lines.sh index 0891347d5..94cc854bd 100755 --- a/core_agent_lines.sh +++ b/core_agent_lines.sh @@ -1,22 +1,92 @@ #!/bin/bash -# Count core agent lines (excluding channels/, cli/, api/, providers/ adapters, -# and the high-level Python SDK facade) +set -euo pipefail + cd "$(dirname "$0")" || exit 1 -echo "nanobot core agent line count" -echo "================================" +count_top_level_py_lines() { + local dir="$1" + if [ ! -d "$dir" ]; then + echo 0 + return + fi + find "$dir" -maxdepth 1 -type f -name "*.py" -print0 | xargs -0 cat 2>/dev/null | wc -l | tr -d ' ' +} + +count_recursive_py_lines() { + local dir="$1" + if [ ! -d "$dir" ]; then + echo 0 + return + fi + find "$dir" -type f -name "*.py" -print0 | xargs -0 cat 2>/dev/null | wc -l | tr -d ' ' +} + +count_skill_lines() { + local dir="$1" + if [ ! -d "$dir" ]; then + echo 0 + return + fi + find "$dir" -type f \( -name "*.md" -o -name "*.py" -o -name "*.sh" \) -print0 | xargs -0 cat 2>/dev/null | wc -l | tr -d ' ' +} + +print_row() { + local label="$1" + local count="$2" + printf " %-16s %6s lines\n" "$label" "$count" +} + +echo "nanobot line count" +echo "==================" echo "" -for dir in agent agent/tools bus config cron heartbeat session utils; do - count=$(find "nanobot/$dir" -maxdepth 1 -name "*.py" -exec cat {} + | wc -l) - printf " %-16s %5s lines\n" "$dir/" "$count" -done +echo "Core runtime" +echo "------------" +core_agent=$(count_top_level_py_lines "nanobot/agent") +core_bus=$(count_top_level_py_lines "nanobot/bus") +core_config=$(count_top_level_py_lines "nanobot/config") +core_cron=$(count_top_level_py_lines "nanobot/cron") +core_heartbeat=$(count_top_level_py_lines "nanobot/heartbeat") +core_session=$(count_top_level_py_lines "nanobot/session") -root=$(cat nanobot/__init__.py nanobot/__main__.py | wc -l) -printf " %-16s %5s lines\n" "(root)" "$root" +print_row "agent/" "$core_agent" +print_row "bus/" "$core_bus" +print_row "config/" "$core_config" +print_row "cron/" "$core_cron" +print_row "heartbeat/" "$core_heartbeat" +print_row "session/" "$core_session" + +core_total=$((core_agent + core_bus + core_config + core_cron + core_heartbeat + core_session)) echo "" -total=$(find nanobot -name "*.py" ! -path "*/channels/*" ! -path "*/cli/*" ! -path "*/api/*" ! -path "*/command/*" ! -path "*/providers/*" ! -path "*/skills/*" ! -path "nanobot/nanobot.py" | xargs cat | wc -l) -echo " Core total: $total lines" +echo "Separate buckets" +echo "----------------" +extra_tools=$(count_recursive_py_lines "nanobot/agent/tools") +extra_skills=$(count_skill_lines "nanobot/skills") +extra_api=$(count_recursive_py_lines "nanobot/api") +extra_cli=$(count_recursive_py_lines "nanobot/cli") +extra_channels=$(count_recursive_py_lines "nanobot/channels") +extra_utils=$(count_recursive_py_lines "nanobot/utils") + +print_row "tools/" "$extra_tools" +print_row "skills/" "$extra_skills" +print_row "api/" "$extra_api" +print_row "cli/" "$extra_cli" +print_row "channels/" "$extra_channels" +print_row "utils/" "$extra_utils" + +extra_total=$((extra_tools + extra_skills + extra_api + extra_cli + extra_channels + extra_utils)) + echo "" -echo " (excludes: channels/, cli/, api/, command/, providers/, skills/, nanobot.py)" +echo "Totals" +echo "------" +print_row "core total" "$core_total" +print_row "extra total" "$extra_total" + +echo "" +echo "Notes" +echo "-----" +echo " - agent/ only counts top-level Python files under nanobot/agent" +echo " - tools/ is counted separately from nanobot/agent/tools" +echo " - skills/ counts .md, .py, and .sh files" +echo " - not included here: command/, providers/, security/, templates/, nanobot.py, root files" diff --git a/nanobot/agent/memory.py b/nanobot/agent/memory.py index e2bb9e176..cbaabf752 100644 --- a/nanobot/agent/memory.py +++ b/nanobot/agent/memory.py @@ -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: diff --git a/nanobot/channels/telegram.py b/nanobot/channels/telegram.py index a6bd810f2..3ba84c6c6 100644 --- a/nanobot/channels/telegram.py +++ b/nanobot/channels/telegram.py @@ -19,6 +19,7 @@ from telegram.request import HTTPXRequest from nanobot.bus.events import OutboundMessage from nanobot.bus.queue import MessageBus from nanobot.channels.base import BaseChannel +from nanobot.command.builtin import build_help_text from nanobot.config.paths import get_media_dir from nanobot.config.schema import Base from nanobot.security.network import validate_url_target @@ -196,9 +197,12 @@ class TelegramChannel(BaseChannel): BotCommand("start", "Start the bot"), BotCommand("new", "Start a new conversation"), BotCommand("stop", "Stop the current task"), - BotCommand("help", "Show available commands"), BotCommand("restart", "Restart the bot"), BotCommand("status", "Show bot status"), + BotCommand("dream", "Run Dream memory consolidation now"), + BotCommand("dream-log", "Show the latest Dream memory change"), + BotCommand("dream-restore", "Restore Dream memory to an earlier version"), + BotCommand("help", "Show available commands"), ] @classmethod @@ -277,7 +281,18 @@ class TelegramChannel(BaseChannel): # Add command handlers (using Regex to support @username suffixes before bot initialization) self._app.add_handler(MessageHandler(filters.Regex(r"^/start(?:@\w+)?$"), self._on_start)) - self._app.add_handler(MessageHandler(filters.Regex(r"^/(new|stop|restart|status)(?:@\w+)?$"), self._forward_command)) + self._app.add_handler( + MessageHandler( + filters.Regex(r"^/(new|stop|restart|status|dream)(?:@\w+)?(?:\s+.*)?$"), + self._forward_command, + ) + ) + self._app.add_handler( + MessageHandler( + filters.Regex(r"^/(dream-log|dream-restore)(?:@\w+)?(?:\s+.*)?$"), + self._forward_command, + ) + ) self._app.add_handler(MessageHandler(filters.Regex(r"^/help(?:@\w+)?$"), self._on_help)) # Add message handler for text, photos, voice, documents @@ -599,14 +614,7 @@ class TelegramChannel(BaseChannel): """Handle /help command, bypassing ACL so all users can access it.""" if not update.message: return - await update.message.reply_text( - "🐈 nanobot commands:\n" - "/new — Start a new conversation\n" - "/stop — Stop the current task\n" - "/restart — Restart the bot\n" - "/status — Show bot status\n" - "/help — Show available commands" - ) + await update.message.reply_text(build_help_text()) @staticmethod def _sender_id(user) -> str: diff --git a/nanobot/command/builtin.py b/nanobot/command/builtin.py index 206420145..a5629f66e 100644 --- a/nanobot/command/builtin.py +++ b/nanobot/command/builtin.py @@ -104,6 +104,78 @@ async def cmd_dream(ctx: CommandContext) -> OutboundMessage: ) +def _extract_changed_files(diff: str) -> list[str]: + """Extract changed file paths from a unified diff.""" + files: list[str] = [] + seen: set[str] = set() + for line in diff.splitlines(): + if not line.startswith("diff --git "): + continue + parts = line.split() + if len(parts) < 4: + continue + path = parts[3] + if path.startswith("b/"): + path = path[2:] + if path in seen: + continue + seen.add(path) + files.append(path) + return files + + +def _format_changed_files(diff: str) -> str: + files = _extract_changed_files(diff) + if not files: + return "No tracked memory files changed." + return ", ".join(f"`{path}`" for path in files) + + +def _format_dream_log_content(commit, diff: str, *, requested_sha: str | None = None) -> str: + files_line = _format_changed_files(diff) + lines = [ + "## Dream Update", + "", + "Here is the selected Dream memory change." if requested_sha else "Here is the latest Dream memory change.", + "", + f"- Commit: `{commit.sha}`", + f"- Time: {commit.timestamp}", + f"- Changed files: {files_line}", + ] + if diff: + lines.extend([ + "", + f"Use `/dream-restore {commit.sha}` to undo this change.", + "", + "```diff", + diff.rstrip(), + "```", + ]) + else: + lines.extend([ + "", + "Dream recorded this version, but there is no file diff to display.", + ]) + return "\n".join(lines) + + +def _format_dream_restore_list(commits: list) -> str: + lines = [ + "## Dream Restore", + "", + "Choose a Dream memory version to restore. Latest first:", + "", + ] + for c in commits: + lines.append(f"- `{c.sha}` {c.timestamp} - {c.message.splitlines()[0]}") + lines.extend([ + "", + "Preview a version with `/dream-log ` before restoring it.", + "Restore a version with `/dream-restore `.", + ]) + return "\n".join(lines) + + async def cmd_dream_log(ctx: CommandContext) -> OutboundMessage: """Show what the last Dream changed. @@ -115,9 +187,9 @@ async def cmd_dream_log(ctx: CommandContext) -> OutboundMessage: if not git.is_initialized(): if store.get_last_dream_cursor() == 0: - msg = "Dream has not run yet." + msg = "Dream has not run yet. Run `/dream`, or wait for the next scheduled Dream cycle." else: - msg = "Git not initialized for memory files." + msg = "Dream history is not available because memory versioning is not initialized." return OutboundMessage( channel=ctx.msg.channel, chat_id=ctx.msg.chat_id, content=msg, metadata={"render_as": "text"}, @@ -130,19 +202,23 @@ async def cmd_dream_log(ctx: CommandContext) -> OutboundMessage: sha = args.split()[0] result = git.show_commit_diff(sha) if not result: - content = f"Commit `{sha}` not found." + content = ( + f"Couldn't find Dream change `{sha}`.\n\n" + "Use `/dream-restore` to list recent versions, " + "or `/dream-log` to inspect the latest one." + ) else: commit, diff = result - content = commit.format(diff) + content = _format_dream_log_content(commit, diff, requested_sha=sha) else: # Default: show the latest commit's diff commits = git.log(max_entries=1) result = git.show_commit_diff(commits[0].sha) if commits else None if result: commit, diff = result - content = commit.format(diff) + content = _format_dream_log_content(commit, diff) else: - content = "No commits yet." + content = "Dream memory has no saved versions yet." return OutboundMessage( channel=ctx.msg.channel, chat_id=ctx.msg.chat_id, @@ -162,7 +238,7 @@ async def cmd_dream_restore(ctx: CommandContext) -> OutboundMessage: if not git.is_initialized(): return OutboundMessage( channel=ctx.msg.channel, chat_id=ctx.msg.chat_id, - content="Git not initialized for memory files.", + content="Dream history is not available because memory versioning is not initialized.", ) args = ctx.args.strip() @@ -170,19 +246,26 @@ async def cmd_dream_restore(ctx: CommandContext) -> OutboundMessage: # Show recent commits for the user to pick commits = git.log(max_entries=10) if not commits: - content = "No commits found." + content = "Dream memory has no saved versions to restore yet." else: - lines = ["## Recent Dream Commits\n", "Use `/dream-restore ` to revert a commit.\n"] - for c in commits: - lines.append(f"- `{c.sha}` {c.message.splitlines()[0]} ({c.timestamp})") - content = "\n".join(lines) + content = _format_dream_restore_list(commits) else: sha = args.split()[0] + result = git.show_commit_diff(sha) + changed_files = _format_changed_files(result[1]) if result else "the tracked memory files" new_sha = git.revert(sha) if new_sha: - content = f"Reverted commit `{sha}` → new commit `{new_sha}`." + content = ( + f"Restored Dream memory to the state before `{sha}`.\n\n" + f"- New safety commit: `{new_sha}`\n" + f"- Restored files: {changed_files}\n\n" + f"Use `/dream-log {new_sha}` to inspect the restore diff." + ) else: - content = f"Failed to revert commit `{sha}`. Check if the SHA is correct." + content = ( + f"Couldn't restore Dream change `{sha}`.\n\n" + "It may not exist, or it may be the first saved version with no earlier state to restore." + ) return OutboundMessage( channel=ctx.msg.channel, chat_id=ctx.msg.chat_id, content=content, metadata={"render_as": "text"}, diff --git a/tests/agent/test_memory_store.py b/tests/agent/test_memory_store.py index 21a4bc728..e7a829140 100644 --- a/tests/agent/test_memory_store.py +++ b/tests/agent/test_memory_store.py @@ -1,9 +1,10 @@ """Tests for the restructured MemoryStore — pure file I/O layer.""" +from datetime import datetime import json +from pathlib import Path import pytest -from pathlib import Path from nanobot.agent.memory import MemoryStore @@ -114,3 +115,135 @@ class TestLegacyHistoryMigration: entries = store.read_unprocessed_history(since_cursor=0) assert len(entries) == 1 assert entries[0]["cursor"] == 1 + + def test_migrates_legacy_history_md_preserving_partial_entries(self, tmp_path): + memory_dir = tmp_path / "memory" + memory_dir.mkdir() + legacy_file = memory_dir / "HISTORY.md" + legacy_content = ( + "[2026-04-01 10:00] User prefers dark mode.\n\n" + "[2026-04-01 10:05] [RAW] 2 messages\n" + "[2026-04-01 10:04] USER: hello\n" + "[2026-04-01 10:04] ASSISTANT: hi\n\n" + "Legacy chunk without timestamp.\n" + "Keep whatever content we can recover.\n" + ) + legacy_file.write_text(legacy_content, encoding="utf-8") + + store = MemoryStore(tmp_path) + fallback_timestamp = datetime.fromtimestamp( + (memory_dir / "HISTORY.md.bak").stat().st_mtime, + ).strftime("%Y-%m-%d %H:%M") + + entries = store.read_unprocessed_history(since_cursor=0) + assert [entry["cursor"] for entry in entries] == [1, 2, 3] + assert entries[0]["timestamp"] == "2026-04-01 10:00" + assert entries[0]["content"] == "User prefers dark mode." + assert entries[1]["timestamp"] == "2026-04-01 10:05" + assert entries[1]["content"].startswith("[RAW] 2 messages") + assert "USER: hello" in entries[1]["content"] + assert entries[2]["timestamp"] == fallback_timestamp + assert entries[2]["content"].startswith("Legacy chunk without timestamp.") + assert store.read_file(store._cursor_file).strip() == "3" + assert store.read_file(store._dream_cursor_file).strip() == "3" + assert not legacy_file.exists() + assert (memory_dir / "HISTORY.md.bak").read_text(encoding="utf-8") == legacy_content + + def test_migrates_consecutive_entries_without_blank_lines(self, tmp_path): + memory_dir = tmp_path / "memory" + memory_dir.mkdir() + legacy_file = memory_dir / "HISTORY.md" + legacy_content = ( + "[2026-04-01 10:00] First event.\n" + "[2026-04-01 10:01] Second event.\n" + "[2026-04-01 10:02] Third event.\n" + ) + legacy_file.write_text(legacy_content, encoding="utf-8") + + store = MemoryStore(tmp_path) + + entries = store.read_unprocessed_history(since_cursor=0) + assert len(entries) == 3 + assert [entry["content"] for entry in entries] == [ + "First event.", + "Second event.", + "Third event.", + ] + + def test_raw_archive_stays_single_entry_while_following_events_split(self, tmp_path): + memory_dir = tmp_path / "memory" + memory_dir.mkdir() + legacy_file = memory_dir / "HISTORY.md" + legacy_content = ( + "[2026-04-01 10:05] [RAW] 2 messages\n" + "[2026-04-01 10:04] USER: hello\n" + "[2026-04-01 10:04] ASSISTANT: hi\n" + "[2026-04-01 10:06] Normal event after raw block.\n" + ) + legacy_file.write_text(legacy_content, encoding="utf-8") + + store = MemoryStore(tmp_path) + + entries = store.read_unprocessed_history(since_cursor=0) + assert len(entries) == 2 + assert entries[0]["content"].startswith("[RAW] 2 messages") + assert "USER: hello" in entries[0]["content"] + assert entries[1]["content"] == "Normal event after raw block." + + def test_nonstandard_date_headers_still_start_new_entries(self, tmp_path): + memory_dir = tmp_path / "memory" + memory_dir.mkdir() + legacy_file = memory_dir / "HISTORY.md" + legacy_content = ( + "[2026-03-25–2026-04-02] Multi-day summary.\n" + "[2026-03-26/27] Cross-day summary.\n" + ) + legacy_file.write_text(legacy_content, encoding="utf-8") + + store = MemoryStore(tmp_path) + fallback_timestamp = datetime.fromtimestamp( + (memory_dir / "HISTORY.md.bak").stat().st_mtime, + ).strftime("%Y-%m-%d %H:%M") + + entries = store.read_unprocessed_history(since_cursor=0) + assert len(entries) == 2 + assert entries[0]["timestamp"] == fallback_timestamp + assert entries[0]["content"] == "[2026-03-25–2026-04-02] Multi-day summary." + assert entries[1]["timestamp"] == fallback_timestamp + assert entries[1]["content"] == "[2026-03-26/27] Cross-day summary." + + def test_existing_history_jsonl_skips_legacy_migration(self, tmp_path): + memory_dir = tmp_path / "memory" + memory_dir.mkdir() + history_file = memory_dir / "history.jsonl" + history_file.write_text( + '{"cursor": 7, "timestamp": "2026-04-01 12:00", "content": "existing"}\n', + encoding="utf-8", + ) + legacy_file = memory_dir / "HISTORY.md" + legacy_file.write_text("[2026-04-01 10:00] legacy\n\n", encoding="utf-8") + + store = MemoryStore(tmp_path) + + entries = store.read_unprocessed_history(since_cursor=0) + assert len(entries) == 1 + assert entries[0]["cursor"] == 7 + assert entries[0]["content"] == "existing" + assert legacy_file.exists() + assert not (memory_dir / "HISTORY.md.bak").exists() + + def test_migrates_legacy_history_with_invalid_utf8_bytes(self, tmp_path): + memory_dir = tmp_path / "memory" + memory_dir.mkdir() + legacy_file = memory_dir / "HISTORY.md" + legacy_file.write_bytes( + b"[2026-04-01 10:00] Broken \xff data still needs migration.\n\n" + ) + + store = MemoryStore(tmp_path) + + entries = store.read_unprocessed_history(since_cursor=0) + assert len(entries) == 1 + assert entries[0]["timestamp"] == "2026-04-01 10:00" + assert "Broken" in entries[0]["content"] + assert "migration." in entries[0]["content"] diff --git a/tests/channels/test_telegram_channel.py b/tests/channels/test_telegram_channel.py index c793b1224..b5e74152b 100644 --- a/tests/channels/test_telegram_channel.py +++ b/tests/channels/test_telegram_channel.py @@ -185,6 +185,9 @@ async def test_start_creates_separate_pools_with_proxy(monkeypatch) -> None: assert builder.request_value is api_req assert builder.get_updates_request_value is poll_req assert any(cmd.command == "status" for cmd in app.bot.commands) + assert any(cmd.command == "dream" for cmd in app.bot.commands) + assert any(cmd.command == "dream-log" for cmd in app.bot.commands) + assert any(cmd.command == "dream-restore" for cmd in app.bot.commands) @pytest.mark.asyncio @@ -962,6 +965,27 @@ async def test_forward_command_does_not_inject_reply_context() -> None: assert handled[0]["content"] == "/new" +@pytest.mark.asyncio +async def test_forward_command_preserves_dream_log_args_and_strips_bot_suffix() -> None: + channel = TelegramChannel( + TelegramConfig(enabled=True, token="123:abc", allow_from=["*"], group_policy="open"), + MessageBus(), + ) + channel._app = _FakeApp(lambda: None) + handled = [] + + async def capture_handle(**kwargs) -> None: + handled.append(kwargs) + + channel._handle_message = capture_handle + update = _make_telegram_update(text="/dream-log@nanobot_test deadbeef", reply_to_message=None) + + await channel._forward_command(update, None) + + assert len(handled) == 1 + assert handled[0]["content"] == "/dream-log deadbeef" + + @pytest.mark.asyncio async def test_on_help_includes_restart_command() -> None: channel = TelegramChannel( @@ -977,3 +1001,6 @@ async def test_on_help_includes_restart_command() -> None: help_text = update.message.reply_text.await_args.args[0] assert "/restart" in help_text assert "/status" in help_text + assert "/dream" in help_text + assert "/dream-log" in help_text + assert "/dream-restore" in help_text diff --git a/tests/command/test_builtin_dream.py b/tests/command/test_builtin_dream.py new file mode 100644 index 000000000..215fc7a47 --- /dev/null +++ b/tests/command/test_builtin_dream.py @@ -0,0 +1,143 @@ +from __future__ import annotations + +from types import SimpleNamespace + +import pytest + +from nanobot.bus.events import InboundMessage +from nanobot.command.builtin import cmd_dream_log, cmd_dream_restore +from nanobot.command.router import CommandContext +from nanobot.utils.git_store import CommitInfo + + +class _FakeStore: + def __init__(self, git, last_dream_cursor: int = 1): + self.git = git + self._last_dream_cursor = last_dream_cursor + + def get_last_dream_cursor(self) -> int: + return self._last_dream_cursor + + +class _FakeGit: + def __init__( + self, + *, + initialized: bool = True, + commits: list[CommitInfo] | None = None, + diff_map: dict[str, tuple[CommitInfo, str] | None] | None = None, + revert_result: str | None = None, + ): + self._initialized = initialized + self._commits = commits or [] + self._diff_map = diff_map or {} + self._revert_result = revert_result + + def is_initialized(self) -> bool: + return self._initialized + + def log(self, max_entries: int = 20) -> list[CommitInfo]: + return self._commits[:max_entries] + + def show_commit_diff(self, sha: str, max_entries: int = 20): + return self._diff_map.get(sha) + + def revert(self, sha: str) -> str | None: + return self._revert_result + + +def _make_ctx(raw: str, git: _FakeGit, *, args: str = "", last_dream_cursor: int = 1) -> CommandContext: + msg = InboundMessage(channel="cli", sender_id="u1", chat_id="direct", content=raw) + store = _FakeStore(git, last_dream_cursor=last_dream_cursor) + loop = SimpleNamespace(consolidator=SimpleNamespace(store=store)) + return CommandContext(msg=msg, session=None, key=msg.session_key, raw=raw, args=args, loop=loop) + + +@pytest.mark.asyncio +async def test_dream_log_latest_is_more_user_friendly() -> None: + commit = CommitInfo(sha="abcd1234", message="dream: 2026-04-04, 2 change(s)", timestamp="2026-04-04 12:00") + diff = ( + "diff --git a/SOUL.md b/SOUL.md\n" + "--- a/SOUL.md\n" + "+++ b/SOUL.md\n" + "@@ -1 +1 @@\n" + "-old\n" + "+new\n" + ) + git = _FakeGit(commits=[commit], diff_map={commit.sha: (commit, diff)}) + + out = await cmd_dream_log(_make_ctx("/dream-log", git)) + + assert "## Dream Update" in out.content + assert "Here is the latest Dream memory change." in out.content + assert "- Commit: `abcd1234`" in out.content + assert "- Changed files: `SOUL.md`" in out.content + assert "Use `/dream-restore abcd1234` to undo this change." in out.content + assert "```diff" in out.content + + +@pytest.mark.asyncio +async def test_dream_log_missing_commit_guides_user() -> None: + git = _FakeGit(diff_map={}) + + out = await cmd_dream_log(_make_ctx("/dream-log deadbeef", git, args="deadbeef")) + + assert "Couldn't find Dream change `deadbeef`." in out.content + assert "Use `/dream-restore` to list recent versions" in out.content + + +@pytest.mark.asyncio +async def test_dream_log_before_first_run_is_clear() -> None: + git = _FakeGit(initialized=False) + + out = await cmd_dream_log(_make_ctx("/dream-log", git, last_dream_cursor=0)) + + assert "Dream has not run yet." in out.content + assert "Run `/dream`" in out.content + + +@pytest.mark.asyncio +async def test_dream_restore_lists_versions_with_next_steps() -> None: + commits = [ + CommitInfo(sha="abcd1234", message="dream: latest", timestamp="2026-04-04 12:00"), + CommitInfo(sha="bbbb2222", message="dream: older", timestamp="2026-04-04 08:00"), + ] + git = _FakeGit(commits=commits) + + out = await cmd_dream_restore(_make_ctx("/dream-restore", git)) + + assert "## Dream Restore" in out.content + assert "Choose a Dream memory version to restore." in out.content + assert "`abcd1234` 2026-04-04 12:00 - dream: latest" in out.content + assert "Preview a version with `/dream-log `" in out.content + assert "Restore a version with `/dream-restore `." in out.content + + +@pytest.mark.asyncio +async def test_dream_restore_success_mentions_files_and_followup() -> None: + commit = CommitInfo(sha="abcd1234", message="dream: latest", timestamp="2026-04-04 12:00") + diff = ( + "diff --git a/SOUL.md b/SOUL.md\n" + "--- a/SOUL.md\n" + "+++ b/SOUL.md\n" + "@@ -1 +1 @@\n" + "-old\n" + "+new\n" + "diff --git a/memory/MEMORY.md b/memory/MEMORY.md\n" + "--- a/memory/MEMORY.md\n" + "+++ b/memory/MEMORY.md\n" + "@@ -1 +1 @@\n" + "-old\n" + "+new\n" + ) + git = _FakeGit( + diff_map={commit.sha: (commit, diff)}, + revert_result="eeee9999", + ) + + out = await cmd_dream_restore(_make_ctx("/dream-restore abcd1234", git, args="abcd1234")) + + assert "Restored Dream memory to the state before `abcd1234`." in out.content + assert "- New safety commit: `eeee9999`" in out.content + assert "- Restored files: `SOUL.md`, `memory/MEMORY.md`" in out.content + assert "Use `/dream-log eeee9999` to inspect the restore diff." in out.content