mirror of
https://github.com/zhayujie/chatgpt-on-wechat.git
synced 2026-07-17 11:07:11 +08:00
Add a self-evolution subsystem that reviews idle conversations in an isolated agent and durably learns from them — patching/creating skills, finishing unfinished tasks, and backfilling missed memory. - Trigger: background idle scan, fires when a session is idle >= N min AND (>= N turns OR context usage > 80%). In-memory cursor reviews only new messages so a session never re-learns old content. - Isolated review agent: same model, restricted toolset, hard write-guard confining edits to the workspace (built-in skills are protected). - Safety: file-level backup before edits + evolution_undo tool; notify the user ONLY when a workspace file actually changed (no-nag rule); capped concurrency. - Records to memory/evolution/<date>.md, surfaced in the memory UI's renamed "Self-Evolution" tab (merged with dream diaries). - Hide internal [SCHEDULED]/[EVOLUTION]/backup_id markers from chat history display (also fixes scheduler marker leakage) while keeping them in stored content for undo. - Flat config: self_evolution_enabled (default off until release), self_evolution_idle_minutes (15), self_evolution_min_turns (6). - Tests: tests/test_evolution.py (stub + real model modes, 7 scenarios).
103 lines
3.6 KiB
Python
103 lines
3.6 KiB
Python
"""File backup / rollback support for self-evolution.
|
|
|
|
Before the evolution agent edits MEMORY.md or a skill file, we snapshot the
|
|
current state into ``memory/.evolution_backups/<backup_id>/`` so a later "undo"
|
|
can restore it. File-level restore only — simple and reliable.
|
|
"""
|
|
|
|
from __future__ import annotations
|
|
|
|
import json
|
|
import shutil
|
|
import time
|
|
from datetime import datetime
|
|
from pathlib import Path
|
|
from typing import List, Optional
|
|
|
|
from common.log import logger
|
|
|
|
_BACKUP_DIRNAME = ".evolution_backups"
|
|
_MANIFEST_NAME = "manifest.json"
|
|
# Keep only the most recent N backups to bound disk usage.
|
|
_MAX_BACKUPS = 10
|
|
|
|
|
|
def _backups_root(workspace_dir: Path) -> Path:
|
|
return Path(workspace_dir) / "memory" / _BACKUP_DIRNAME
|
|
|
|
|
|
def create_backup(workspace_dir: Path, files: List[Path]) -> Optional[str]:
|
|
"""Snapshot ``files`` (those that exist) under a new backup id.
|
|
|
|
Returns the backup_id, or None when there is nothing to back up.
|
|
"""
|
|
existing = [Path(f) for f in files if Path(f).exists()]
|
|
if not existing:
|
|
return None
|
|
|
|
backup_id = datetime.now().strftime("%Y%m%d-%H%M%S-") + str(int(time.time() * 1000) % 1000)
|
|
root = _backups_root(workspace_dir)
|
|
target = root / backup_id
|
|
try:
|
|
target.mkdir(parents=True, exist_ok=True)
|
|
ws = Path(workspace_dir)
|
|
manifest = []
|
|
for idx, src in enumerate(existing):
|
|
# Store under a flat index plus the relative path so restore knows
|
|
# where it came from, even for nested skill files.
|
|
try:
|
|
rel = str(src.relative_to(ws))
|
|
except ValueError:
|
|
rel = src.name
|
|
dst = target / f"{idx}.bak"
|
|
shutil.copy2(src, dst)
|
|
manifest.append({"rel": rel, "bak": f"{idx}.bak"})
|
|
(target / _MANIFEST_NAME).write_text(
|
|
json.dumps(manifest, ensure_ascii=False, indent=2), encoding="utf-8"
|
|
)
|
|
_prune_old_backups(root)
|
|
# Caller logs a combined backup+review line; keep this at debug.
|
|
logger.debug(f"[Evolution] Created backup {backup_id} ({len(manifest)} file(s))")
|
|
return backup_id
|
|
except Exception as e:
|
|
logger.warning(f"[Evolution] Failed to create backup: {e}")
|
|
return None
|
|
|
|
|
|
def restore_backup(workspace_dir: Path, backup_id: str) -> bool:
|
|
"""Restore all files captured under ``backup_id``. Returns success."""
|
|
if not backup_id:
|
|
return False
|
|
target = _backups_root(workspace_dir) / backup_id
|
|
manifest_path = target / _MANIFEST_NAME
|
|
if not manifest_path.exists():
|
|
logger.warning(f"[Evolution] Backup not found: {backup_id}")
|
|
return False
|
|
try:
|
|
manifest = json.loads(manifest_path.read_text(encoding="utf-8"))
|
|
ws = Path(workspace_dir)
|
|
for entry in manifest:
|
|
bak = target / entry["bak"]
|
|
dst = ws / entry["rel"]
|
|
if bak.exists():
|
|
dst.parent.mkdir(parents=True, exist_ok=True)
|
|
shutil.copy2(bak, dst)
|
|
logger.info(f"[Evolution] Restored backup {backup_id} ({len(manifest)} file(s))")
|
|
return True
|
|
except Exception as e:
|
|
logger.warning(f"[Evolution] Failed to restore backup {backup_id}: {e}")
|
|
return False
|
|
|
|
|
|
def _prune_old_backups(root: Path) -> None:
|
|
"""Drop the oldest backups beyond _MAX_BACKUPS (sorted by name = chronological)."""
|
|
try:
|
|
dirs = sorted(
|
|
[d for d in root.iterdir() if d.is_dir()],
|
|
key=lambda p: p.name,
|
|
)
|
|
for old in dirs[:-_MAX_BACKUPS]:
|
|
shutil.rmtree(old, ignore_errors=True)
|
|
except Exception as e:
|
|
logger.debug(f"[Evolution] Backup prune skipped: {e}")
|