/**
* Rewind — checkpoint tracking and file revert for conversation rewind
*
* After each assistant turn, a checkpoint is recorded with:
* - The turn index and message count at that point
* - A summary of what happened (first 80 chars of assistant text)
* - File changes made during that turn (with backup paths from file-history)
*
* On rewind, files are restored from their backups in reverse chronological
* order. Files that were CREATED (not edited) during reverted turns are deleted.
*
* Integrates with the existing file-history.ts backup system.
*/
import { existsSync, readFileSync, writeFileSync, unlinkSync } from "fs";
// ============================================================================
// TYPES
// ============================================================================
export interface FileChange {
filePath: string;
backupPath: string; // Path to the backup in file-history (empty if file was created new)
operation: "write" | "edit" | "multi_edit";
isNewFile: boolean; // True if the file did not exist before this operation
}
export interface RewindCheckpoint {
turnIndex: number; // Which assistant turn this represents (0-based)
messageCount: number; // messages.length at this point
timestamp: number; // Date.now()
summary: string; // Brief description (first 80 chars of assistant text)
fileChanges: FileChange[]; // Files modified during this turn
}
export interface RewindResult {
messageCount: number; // The message count to truncate to
filesReverted: string[]; // File paths that were reverted
filesDeleted: string[]; // File paths that were deleted (created during reverted turns)
errors: string[]; // Any errors during revert
}
// ============================================================================
// REWIND MANAGER
// ============================================================================
export class RewindManager {
private checkpoints: RewindCheckpoint[] = [];
private pendingFileChanges: FileChange[] = [];
/**
* Record a checkpoint after an assistant turn completes.
*/
addCheckpoint(
turnIndex: number,
messageCount: number,
summary: string,
fileChanges: FileChange[],
): void {
this.checkpoints.push({
turnIndex,
messageCount,
timestamp: Date.now(),
summary: summary.slice(0, 80),
fileChanges,
});
}
/**
* Get all checkpoints for display.
*/
getCheckpoints(): RewindCheckpoint[] {
return [...this.checkpoints];
}
/**
* Get the number of recorded checkpoints.
*/
getCheckpointCount(): number {
return this.checkpoints.length;
}
/**
* Rewind to a specific checkpoint index. All checkpoints AFTER this index
* are removed, and their file changes are reverted.
*
* Returns the message count to truncate to, plus lists of reverted/deleted files.
*/
rewindTo(checkpointIndex: number): RewindResult {
if (checkpointIndex < 0 || checkpointIndex >= this.checkpoints.length) {
return {
messageCount: 0,
filesReverted: [],
filesDeleted: [],
errors: [`Invalid checkpoint index: ${checkpointIndex}`],
};
}
const targetCheckpoint = this.checkpoints[checkpointIndex];
const removedCheckpoints = this.checkpoints.slice(checkpointIndex + 1);
const filesReverted: string[] = [];
const filesDeleted: string[] = [];
const errors: string[] = [];
// Revert file changes in reverse chronological order
// Process checkpoints from newest to oldest
for (let i = removedCheckpoints.length - 1; i >= 0; i--) {
const cp = removedCheckpoints[i];
// Process file changes within each checkpoint in reverse order
for (let j = cp.fileChanges.length - 1; j >= 0; j--) {
const change = cp.fileChanges[j];
try {
if (change.isNewFile) {
// File was created during this turn — delete it
if (existsSync(change.filePath)) {
unlinkSync(change.filePath);
filesDeleted.push(change.filePath);
}
} else if (change.backupPath && existsSync(change.backupPath)) {
// File was edited — restore from backup
const backupContent = readFileSync(change.backupPath);
writeFileSync(change.filePath, backupContent);
filesReverted.push(change.filePath);
} else {
errors.push(`No backup found for ${change.filePath}`);
}
} catch (err) {
const msg = err instanceof Error ? err.message : String(err);
errors.push(`Failed to revert ${change.filePath}: ${msg}`);
}
}
}
// Truncate checkpoints to the target
this.checkpoints = this.checkpoints.slice(0, checkpointIndex + 1);
// Clear any pending file changes
this.pendingFileChanges = [];
return {
messageCount: targetCheckpoint.messageCount,
filesReverted: [...new Set(filesReverted)],
filesDeleted: [...new Set(filesDeleted)],
errors,
};
}
/**
* Revert only file changes (without truncating conversation).
* Used when user wants to undo file changes but keep conversation history.
*/
revertFilesFrom(checkpointIndex: number): Omit<RewindResult, "messageCount"> {
if (checkpointIndex < 0 || checkpointIndex >= this.checkpoints.length) {
return {
filesReverted: [],
filesDeleted: [],
errors: [`Invalid checkpoint index: ${checkpointIndex}`],
};
}
const removedCheckpoints = this.checkpoints.slice(checkpointIndex + 1);
const filesReverted: string[] = [];
const filesDeleted: string[] = [];
const errors: string[] = [];
for (let i = removedCheckpoints.length - 1; i >= 0; i--) {
const cp = removedCheckpoints[i];
for (let j = cp.fileChanges.length - 1; j >= 0; j--) {
const change = cp.fileChanges[j];
try {
if (change.isNewFile) {
if (existsSync(change.filePath)) {
unlinkSync(change.filePath);
filesDeleted.push(change.filePath);
}
} else if (change.backupPath && existsSync(change.backupPath)) {
const backupContent = readFileSync(change.backupPath);
writeFileSync(change.filePath, backupContent);
filesReverted.push(change.filePath);
} else {
errors.push(`No backup found for ${change.filePath}`);
}
} catch (err) {
const msg = err instanceof Error ? err.message : String(err);
errors.push(`Failed to revert ${change.filePath}: ${msg}`);
}
}
}
return {
filesReverted: [...new Set(filesReverted)],
filesDeleted: [...new Set(filesDeleted)],
errors,
};
}
/**
* Track a file change for the current (uncommitted) turn.
*/
trackFileChange(change: FileChange): void {
this.pendingFileChanges.push(change);
}
/**
* Get the current turn's pending file changes.
*/
getCurrentFileChanges(): FileChange[] {
return [...this.pendingFileChanges];
}
/**
* Clear current turn tracking (called when checkpoint is committed).
*/
commitTurn(): void {
this.pendingFileChanges = [];
}
/**
* Check if there are any checkpoints with file changes that could be reverted.
*/
hasFileChanges(): boolean {
return this.checkpoints.some(cp => cp.fileChanges.length > 0);
}
/**
* Get the total number of file changes across all checkpoints after a given index.
*/
getFileChangeCountAfter(checkpointIndex: number): number {
return this.checkpoints
.slice(checkpointIndex + 1)
.reduce((sum, cp) => sum + cp.fileChanges.length, 0);
}
/**
* Reset all state.
*/
reset(): void {
this.checkpoints = [];
this.pendingFileChanges = [];
}
}