mirror of
https://github.com/HKUDS/nanobot.git
synced 2026-04-25 20:36:00 +00:00
feat(memory): harden legacy history migration and Dream UX
This commit is contained in:
parent
7e0c196797
commit
6e896249c8
@ -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"
|
||||||
|
|||||||
@ -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:
|
||||||
|
|||||||
@ -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:
|
||||||
|
|||||||
@ -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"},
|
||||||
|
|||||||
@ -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-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"]
|
||||||
|
|||||||
@ -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
|
||||||
|
|||||||
143
tests/command/test_builtin_dream.py
Normal file
143
tests/command/test_builtin_dream.py
Normal 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
|
||||||
Loading…
x
Reference in New Issue
Block a user