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

View File

@ -1,22 +1,92 @@
#!/bin/bash #!/bin/bash
# Count core agent lines (excluding channels/, cli/, api/, providers/ adapters, set -euo pipefail
# and the high-level Python SDK facade)
cd "$(dirname "$0")" || exit 1 cd "$(dirname "$0")" || exit 1
echo "nanobot core agent line count" count_top_level_py_lines() {
echo "================================" 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 "" echo ""
for dir in agent agent/tools bus config cron heartbeat session utils; do echo "Core runtime"
count=$(find "nanobot/$dir" -maxdepth 1 -name "*.py" -exec cat {} + | wc -l) echo "------------"
printf " %-16s %5s lines\n" "$dir/" "$count" core_agent=$(count_top_level_py_lines "nanobot/agent")
done 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) print_row "agent/" "$core_agent"
printf " %-16s %5s lines\n" "(root)" "$root" 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 "" 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 "Separate buckets"
echo " Core total: $total lines" 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 ""
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"

View File

@ -4,6 +4,7 @@ from __future__ import annotations
import asyncio import asyncio
import json import json
import re
import weakref import weakref
from datetime import datetime from datetime import datetime
from pathlib import Path 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.""" """Pure file I/O for memory files: MEMORY.md, history.jsonl, SOUL.md, USER.md."""
_DEFAULT_MAX_HISTORY = 1000 _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): def __init__(self, workspace: Path, max_history_entries: int = _DEFAULT_MAX_HISTORY):
self.workspace = workspace self.workspace = workspace
@ -37,6 +43,7 @@ class MemoryStore:
self.memory_dir = ensure_dir(workspace / "memory") self.memory_dir = ensure_dir(workspace / "memory")
self.memory_file = self.memory_dir / "MEMORY.md" self.memory_file = self.memory_dir / "MEMORY.md"
self.history_file = self.memory_dir / "history.jsonl" self.history_file = self.memory_dir / "history.jsonl"
self.legacy_history_file = self.memory_dir / "HISTORY.md"
self.soul_file = workspace / "SOUL.md" self.soul_file = workspace / "SOUL.md"
self.user_file = workspace / "USER.md" self.user_file = workspace / "USER.md"
self._cursor_file = self.memory_dir / ".cursor" self._cursor_file = self.memory_dir / ".cursor"
@ -44,6 +51,7 @@ class MemoryStore:
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",
]) ])
self._maybe_migrate_legacy_history()
@property @property
def git(self) -> GitStore: def git(self) -> GitStore:
@ -58,6 +66,125 @@ class MemoryStore:
except FileNotFoundError: except FileNotFoundError:
return "" 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) ----------------------------------------- # -- MEMORY.md (long-term facts) -----------------------------------------
def read_memory(self) -> str: def read_memory(self) -> str:

View File

@ -19,6 +19,7 @@ from telegram.request import HTTPXRequest
from nanobot.bus.events import OutboundMessage from nanobot.bus.events import OutboundMessage
from nanobot.bus.queue import MessageBus from nanobot.bus.queue import MessageBus
from nanobot.channels.base import BaseChannel 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.paths import get_media_dir
from nanobot.config.schema import Base from nanobot.config.schema import Base
from nanobot.security.network import validate_url_target from nanobot.security.network import validate_url_target
@ -196,9 +197,12 @@ class TelegramChannel(BaseChannel):
BotCommand("start", "Start the bot"), BotCommand("start", "Start the bot"),
BotCommand("new", "Start a new conversation"), BotCommand("new", "Start a new conversation"),
BotCommand("stop", "Stop the current task"), BotCommand("stop", "Stop the current task"),
BotCommand("help", "Show available commands"),
BotCommand("restart", "Restart the bot"), BotCommand("restart", "Restart the bot"),
BotCommand("status", "Show bot status"), 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 @classmethod
@ -277,7 +281,18 @@ class TelegramChannel(BaseChannel):
# Add command handlers (using Regex to support @username suffixes before bot initialization) # 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"^/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)) self._app.add_handler(MessageHandler(filters.Regex(r"^/help(?:@\w+)?$"), self._on_help))
# Add message handler for text, photos, voice, documents # 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.""" """Handle /help command, bypassing ACL so all users can access it."""
if not update.message: if not update.message:
return return
await update.message.reply_text( await update.message.reply_text(build_help_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"
)
@staticmethod @staticmethod
def _sender_id(user) -> str: def _sender_id(user) -> str:

View File

@ -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 <sha>` before restoring it.",
"Restore a version with `/dream-restore <sha>`.",
])
return "\n".join(lines)
async def cmd_dream_log(ctx: CommandContext) -> OutboundMessage: async def cmd_dream_log(ctx: CommandContext) -> OutboundMessage:
"""Show what the last Dream changed. """Show what the last Dream changed.
@ -115,9 +187,9 @@ async def cmd_dream_log(ctx: CommandContext) -> OutboundMessage:
if not git.is_initialized(): if not git.is_initialized():
if store.get_last_dream_cursor() == 0: 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: else:
msg = "Git not initialized for memory files." msg = "Dream history is not available because memory versioning is not initialized."
return OutboundMessage( return OutboundMessage(
channel=ctx.msg.channel, chat_id=ctx.msg.chat_id, channel=ctx.msg.channel, chat_id=ctx.msg.chat_id,
content=msg, metadata={"render_as": "text"}, content=msg, metadata={"render_as": "text"},
@ -130,19 +202,23 @@ async def cmd_dream_log(ctx: CommandContext) -> OutboundMessage:
sha = args.split()[0] sha = args.split()[0]
result = git.show_commit_diff(sha) result = git.show_commit_diff(sha)
if not result: 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: else:
commit, diff = result commit, diff = result
content = commit.format(diff) content = _format_dream_log_content(commit, diff, requested_sha=sha)
else: else:
# Default: show the latest commit's diff # Default: show the latest commit's diff
commits = git.log(max_entries=1) commits = git.log(max_entries=1)
result = git.show_commit_diff(commits[0].sha) if commits else None result = git.show_commit_diff(commits[0].sha) if commits else None
if result: if result:
commit, diff = result commit, diff = result
content = commit.format(diff) content = _format_dream_log_content(commit, diff)
else: else:
content = "No commits yet." content = "Dream memory has no saved versions yet."
return OutboundMessage( return OutboundMessage(
channel=ctx.msg.channel, chat_id=ctx.msg.chat_id, 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(): if not git.is_initialized():
return OutboundMessage( return OutboundMessage(
channel=ctx.msg.channel, chat_id=ctx.msg.chat_id, 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() args = ctx.args.strip()
@ -170,19 +246,26 @@ async def cmd_dream_restore(ctx: CommandContext) -> OutboundMessage:
# Show recent commits for the user to pick # Show recent commits for the user to pick
commits = git.log(max_entries=10) commits = git.log(max_entries=10)
if not commits: if not commits:
content = "No commits found." content = "Dream memory has no saved versions to restore yet."
else: else:
lines = ["## Recent Dream Commits\n", "Use `/dream-restore <sha>` to revert a commit.\n"] content = _format_dream_restore_list(commits)
for c in commits:
lines.append(f"- `{c.sha}` {c.message.splitlines()[0]} ({c.timestamp})")
content = "\n".join(lines)
else: else:
sha = args.split()[0] 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) new_sha = git.revert(sha)
if new_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: 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( return OutboundMessage(
channel=ctx.msg.channel, chat_id=ctx.msg.chat_id, channel=ctx.msg.channel, chat_id=ctx.msg.chat_id,
content=content, metadata={"render_as": "text"}, content=content, metadata={"render_as": "text"},

View File

@ -1,9 +1,10 @@
"""Tests for the restructured MemoryStore — pure file I/O layer.""" """Tests for the restructured MemoryStore — pure file I/O layer."""
from datetime import datetime
import json import json
from pathlib import Path
import pytest import pytest
from pathlib import Path
from nanobot.agent.memory import MemoryStore from nanobot.agent.memory import MemoryStore
@ -114,3 +115,135 @@ class TestLegacyHistoryMigration:
entries = store.read_unprocessed_history(since_cursor=0) entries = store.read_unprocessed_history(since_cursor=0)
assert len(entries) == 1 assert len(entries) == 1
assert entries[0]["cursor"] == 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-252026-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-252026-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"]

View File

@ -185,6 +185,9 @@ async def test_start_creates_separate_pools_with_proxy(monkeypatch) -> None:
assert builder.request_value is api_req assert builder.request_value is api_req
assert builder.get_updates_request_value is poll_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 == "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 @pytest.mark.asyncio
@ -962,6 +965,27 @@ async def test_forward_command_does_not_inject_reply_context() -> None:
assert handled[0]["content"] == "/new" 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 @pytest.mark.asyncio
async def test_on_help_includes_restart_command() -> None: async def test_on_help_includes_restart_command() -> None:
channel = TelegramChannel( 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] help_text = update.message.reply_text.await_args.args[0]
assert "/restart" in help_text assert "/restart" in help_text
assert "/status" 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

View File

@ -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 <sha>`" in out.content
assert "Restore a version with `/dream-restore <sha>`." 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