diff --git a/.gitignore b/.gitignore index 74a7a94e..d0a0e6c1 100644 --- a/.gitignore +++ b/.gitignore @@ -5,3 +5,4 @@ logs/ data/ *.log .DS_Store +.self-evolve-backups/ diff --git a/src/tools/SelfEvolveTool.js b/src/tools/SelfEvolveTool.js index 669766e1..681143a6 100644 --- a/src/tools/SelfEvolveTool.js +++ b/src/tools/SelfEvolveTool.js @@ -21,12 +21,13 @@ */ import { execSync } from 'child_process'; -import { writeFileSync, readFileSync, existsSync, statSync } from 'fs'; +import { writeFileSync, readFileSync, existsSync, statSync, mkdirSync, readdirSync, copyFileSync } from 'fs'; import path from 'path'; const REPO_ROOT = '/home/uroma2/zcode-cli-x'; +const BACKUP_DIR = '/home/uroma2/zcode-cli-x/.self-evolve-backups'; const HEALTH_URL = 'https://zcode-bot.95-216-124-247.sslip.io/health'; -const MAX_FILE_SIZE = 50 * 1024; // 50KB +const MAX_FILE_SIZE = 80 * 1024; // 80KB — main bot file is ~55KB const RATE_LIMIT_MS = 60_000; // 1 minute between evolves // Files that CANNOT be self-modified (safety critical) @@ -40,11 +41,11 @@ let lastEvolveTime = 0; export class SelfEvolveTool { constructor() { this.name = 'self_evolve'; - this.description = 'Read and modify zCode\'s own source code with automatic rollback on failure. The bot can improve itself — but safely.'; + this.description = 'Read and modify zCode\'s own source code with automatic rollback on failure. Every change creates a file-level backup.'; this.parameters = { action: { type: 'string', - description: 'read | patch | list_files | git_log | diff', + description: 'read | patch | list_files | git_log | diff | backups | restore', }, file: { type: 'string', @@ -62,6 +63,10 @@ export class SelfEvolveTool { type: 'string', description: 'For patch: git commit message describing the change', }, + backup_id: { + type: 'string', + description: 'For restore: timestamp ID from backups list (e.g. 2026-05-05T18-30-00)', + }, }; } @@ -74,8 +79,10 @@ export class SelfEvolveTool { case 'patch': return this.patch(file, old_code, new_code, message); case 'git_log': return this.gitLog(); case 'diff': return this.diff(file); + case 'backups': return this.listBackups(file); + case 'restore': return this.restore(backup_id, file); default: - return `❌ Unknown action: ${action}. Use: read, patch, list_files, git_log, diff`; + return `❌ Unknown action: ${action}. Use: read, patch, list_files, git_log, diff, backups, restore`; } } @@ -180,7 +187,11 @@ export class SelfEvolveTool { this._exec(`cd ${REPO_ROOT} && git stash push -m "${checkpoint}" --quiet 2>/dev/null || true`); this._exec(`cd ${REPO_ROOT} && git add -A && git commit -m "checkpoint: before self-evolve ${checkpoint}" --allow-empty --quiet 2>/dev/null || true`); - // ── STEP 2: Apply patch ── + // ── STEP 2: File-level backup (survives even git corruption) ── + const backupId = this._backupFile(absPath, relPath, checkpoint); + logger.info(`💾 File backup: ${backupId}`); + + // ── STEP 3: Apply patch ── const newContent = content.replace(oldCode, newCode); writeFileSync(absPath, newContent, 'utf-8'); @@ -223,7 +234,7 @@ export class SelfEvolveTool { // ── SUCCESS — drop stash (we don't need the pre-stash state) ── this._exec(`cd ${REPO_ROOT} && git stash drop --quiet 2>/dev/null || true`); - return `✅ **Self-evolve succeeded!**\n\n📄 File: ${relPath}\n📝 Commit: ${msg}\n\n🩺 Health: OK\n🧪 Smoke test: PASS\n\nThe bot is running with your improvement. Rollback: \`git revert HEAD\` if needed.`; + return `✅ **Self-evolve succeeded!**\n\n📄 File: ${relPath}\n📝 Commit: ${msg}\n💾 Backup: ${backupId}\n\n🩺 Health: OK\n🧪 Smoke test: PASS\n\nTo rollback: \`self_evolve action=restore backup_id=${backupId} file=${relPath}\``; } catch (err) { // ── ROLLBACK — restore everything ── @@ -295,4 +306,124 @@ export class SelfEvolveTool { return true; } } + + // ── File-level backup ── + _backupFile(absPath, relPath, checkpoint) { + // Create backup dir structure: .self-evolve-backups/src/bot/2026-05-05T18-30-00__index.js + const timestamp = new Date().toISOString().replace(/[:.]/g, '-'); + const fileName = path.basename(relPath); + const dirPart = path.dirname(relPath); + const backupSubDir = path.join(BACKUP_DIR, dirPart); + const backupId = `${timestamp}__${fileName}`; + const backupPath = path.join(backupSubDir, backupId); + + mkdirSync(backupSubDir, { recursive: true }); + copyFileSync(absPath, backupPath); + + // Also write a metadata file with the checkpoint info + writeFileSync(backupPath + '.meta', JSON.stringify({ + id: backupId, + file: relPath, + checkpoint, + timestamp: new Date().toISOString(), + size: statSync(absPath).size, + }, null, 2)); + + return backupId; + } + + // ── List backups ── + listBackups(relPath) { + if (!existsSync(BACKUP_DIR)) { + return '📦 No backups yet. Backups are created automatically on every self-evolve patch.'; + } + + const searchDir = relPath + ? path.join(BACKUP_DIR, path.dirname(relPath)) + : BACKUP_DIR; + + if (!existsSync(searchDir)) { + return `📦 No backups found for ${relPath || 'any file'}.`; + } + + try { + // Find all .meta files recursively + const findCmd = `find ${BACKUP_DIR} -name '*.meta' -type f | sort -r | head -30`; + const metaFiles = execSync(findCmd, { encoding: 'utf-8', timeout: 5000 }).trim().split('\n').filter(Boolean); + + if (metaFiles.length === 0) { + return '📦 No backups found.'; + } + + const entries = metaFiles.map(mf => { + try { + const meta = JSON.parse(readFileSync(mf, 'utf-8')); + const size = (meta.size / 1024).toFixed(1); + return `| ${meta.id} | ${meta.file} | ${size}KB | ${meta.timestamp.slice(0, 19)} |`; + } catch { + return null; + } + }).filter(Boolean); + + return `📦 **File-level backups** (newest first):\n\n| ID | File | Size | Time |\n|---|---|---|---|\n${entries.join('\n')}\n\nTo restore: \`self_evolve action=restore backup_id= file=\``; + } catch (e) { + return `❌ Backup list error: ${e.message}`; + } + } + + // ── Restore from backup ── + restore(backupId, relPath) { + if (!backupId) return '❌ Specify backup_id from the backups list.'; + + // Find the backup file + try { + const findCmd = `find ${BACKUP_DIR} -name '${backupId}' -type f | head -1`; + const backupPath = execSync(findCmd, { encoding: 'utf-8', timeout: 5000 }).trim(); + + if (!backupPath || !existsSync(backupPath)) { + return `❌ Backup not found: ${backupId}. Run 'backups' to see available backups.`; + } + + // Read metadata to get original file path + const metaPath = backupPath + '.meta'; + let targetRelPath = relPath; + if (existsSync(metaPath)) { + const meta = JSON.parse(readFileSync(metaPath, 'utf-8')); + if (!targetRelPath) targetRelPath = meta.file; + } + + if (!targetRelPath) return '❌ Specify file path to restore to.'; + + const absPath = this._resolve(targetRelPath); + if (!absPath) return `❌ Invalid path: ${targetRelPath}`; + + // Backup current state before overwriting + if (existsSync(absPath)) { + const restoreCheckpoint = `pre-restore-${Date.now()}`; + this._backupFile(absPath, targetRelPath, restoreCheckpoint); + } + + // Copy backup to original location + copyFileSync(backupPath, absPath); + + // Syntax check + if (absPath.endsWith('.js')) { + this._exec(`node --check ${absPath}`); + } + + // Commit, push, restart + this._exec(`cd ${REPO_ROOT} && git add ${targetRelPath} && git commit -m "restore: ${backupId}" --quiet`); + this._exec(`cd ${REPO_ROOT} && git push origin main --quiet 2>&1`); + this._exec(`systemctl --user restart zcode`); + + const healthOk = this._waitForHealth(15000); + if (!healthOk) { + throw new Error('Health check failed after restore'); + } + + return `✅ **Restored from backup!**\n\n📄 File: ${targetRelPath}\n💾 Backup: ${backupId}\n🩺 Health: OK\n\nBot is running with the restored version.`; + } catch (err) { + return `❌ **Restore failed:** ${err.message}\n\nThe backup file still exists at ${BACKUP_DIR}. Manual restore: copy the backup file to the target path.`; + } + } }