// This file implements FileSnapshotRecorder, the edit-rewind journal backing the // /rewind command. Before the write and edit tools mutate a file they call // Record(absPath), which captures the file's prior content (or notes that it did // not exist). Snapshots accumulate per turn; Commit groups the turn's snapshots // into a RestorePoint tagged with the conversation leaf that preceded the turn. // Restore replays a suffix of the restore points in reverse to roll the working // tree back to an earlier state, mirroring Claude Code's Esc-Esc rewind. The // journal is in-memory and scoped to the running session; only pigo's own // write/edit tools are captured (arbitrary bash edits are not). package agenttool import ( "fmt" "os" "sync" "time" ) // snapshotMaxBytes caps how large a file may be for its prior content to be held // in the rewind journal. A file above this is still recorded (so rewind knows it // changed) but its content is not retained, and rewind reports it as skipped // rather than clobbering it with stale bytes. const snapshotMaxBytes = 16 * 1024 * 1024 // fileSnapshot is the pre-mutation state of a single file: its content before the // first write/edit of a turn, or a marker that it did not yet exist (so rewind // deletes it). TooLarge marks a file that exceeded snapshotMaxBytes, whose // content was not retained. type fileSnapshot struct { Path string Existed bool TooLarge bool Content []byte } // RestorePoint is one turn's worth of file snapshots plus the conversation leaf // that preceded the turn. Rewinding to it restores every file to its Snapshots // state and moves the active conversation leaf back to LeafID. type RestorePoint struct { Seq int Time time.Time LeafID string Label string Snapshots []fileSnapshot } // FileSnapshotRecorder captures prior file content before write/edit mutations // and groups it into per-turn RestorePoints. Its methods are safe for concurrent // use so parallel tool calls within a turn can record without racing. type FileSnapshotRecorder struct { mu sync.Mutex pending map[string]fileSnapshot // absolute path -> first snapshot this turn order []string // first-touch order within the turn points []RestorePoint nextSeq int } // NewFileSnapshotRecorder returns an empty recorder ready to record the first // turn's mutations. func NewFileSnapshotRecorder() *FileSnapshotRecorder { return &FileSnapshotRecorder{pending: map[string]fileSnapshot{}, nextSeq: 1} } // Record captures the current on-disk state of absPath before it is mutated. Only // the first call for a given path within a turn is retained, so the snapshot // reflects the state at the turn's start (later mutations in the same turn are // rolled back to that same baseline). A nil recorder is a no-op, so tools can // hold an always-safe optional handle. func (r *FileSnapshotRecorder) Record(absPath string) { if r == nil { return } r.mu.Lock() defer r.mu.Unlock() if _, seen := r.pending[absPath]; seen { return } snap := fileSnapshot{Path: absPath} info, err := os.Stat(absPath) switch { case err != nil: // Treat any stat error (including not-exist) as "did not exist": rewind will // delete the file created this turn. snap.Existed = false case info.IsDir(): // A directory is never written by the file tools; skip content capture. snap.Existed = true snap.TooLarge = true case info.Size() > snapshotMaxBytes: snap.Existed = true snap.TooLarge = true default: data, readErr := os.ReadFile(absPath) if readErr != nil { snap.Existed = true snap.TooLarge = true } else { snap.Existed = true snap.Content = data } } r.pending[absPath] = snap r.order = append(r.order, absPath) } // Commit closes the current turn: if any files were recorded it appends a // RestorePoint tagged with leafID (the conversation leaf before the turn) and // label (a short description, e.g. the user prompt), then clears the pending // buffer. A turn that mutated no files creates no restore point. It reports // whether a restore point was created. func (r *FileSnapshotRecorder) Commit(leafID, label string) bool { if r == nil { return false } r.mu.Lock() defer r.mu.Unlock() if len(r.order) == 0 { return false } snaps := make([]fileSnapshot, 0, len(r.order)) for _, p := range r.order { snaps = append(snaps, r.pending[p]) } r.points = append(r.points, RestorePoint{ Seq: r.nextSeq, Time: time.Now().UTC(), LeafID: leafID, Label: label, Snapshots: snaps, }) r.nextSeq++ r.pending = map[string]fileSnapshot{} r.order = nil return true } // Points returns a copy of the committed restore points, oldest first. func (r *FileSnapshotRecorder) Points() []RestorePoint { if r == nil { return nil } r.mu.Lock() defer r.mu.Unlock() out := make([]RestorePoint, len(r.points)) copy(out, r.points) return out } // Restore rolls the working tree back to the state before the restore point at // index idx (0-based into the Points slice). It replays that point and every // later point in reverse, restoring each file's prior content (or deleting files // that did not exist), then drops those points from the journal so the next // rewind starts from the new tip. It returns the conversation leaf to switch to // (the target point's LeafID), the list of restored file paths, and any // non-fatal warnings (e.g. files skipped because they were too large or a // restore write failed). func (r *FileSnapshotRecorder) Restore(idx int) (leafID string, restored []string, warnings []string, err error) { if r == nil { return "", nil, nil, fmt.Errorf("no restore points") } r.mu.Lock() defer r.mu.Unlock() if idx < 0 || idx >= len(r.points) { return "", nil, nil, fmt.Errorf("restore point %d out of range (have %d)", idx+1, len(r.points)) } leafID = r.points[idx].LeafID // A file touched across several turns must end at its OLDEST (pre-target) // baseline. Iterate points oldest→newest and keep only the first snapshot seen // for each path, so the earliest baseline is the one applied. applied := map[string]bool{} for i := idx; i < len(r.points); i++ { for _, s := range r.points[i].Snapshots { if applied[s.Path] { continue } applied[s.Path] = true if w := applySnapshot(s); w != "" { warnings = append(warnings, w) continue } restored = append(restored, s.Path) } } r.points = r.points[:idx] if len(r.points) > 0 { r.nextSeq = r.points[len(r.points)-1].Seq + 1 } else { r.nextSeq = 1 } return leafID, restored, warnings, nil } // applySnapshot restores one file to its recorded prior state: rewrite the prior // content, or delete the file if it did not exist before. It returns a warning // string when the file cannot be safely restored (too large to have retained // content, or a filesystem error), or "" on success. func applySnapshot(s fileSnapshot) string { if s.TooLarge { return fmt.Sprintf("%s: skipped (too large to snapshot; left unchanged)", s.Path) } if !s.Existed { if err := os.Remove(s.Path); err != nil && !os.IsNotExist(err) { return fmt.Sprintf("%s: could not delete: %v", s.Path, err) } return "" } if err := os.WriteFile(s.Path, s.Content, filePerm); err != nil { return fmt.Sprintf("%s: could not restore: %v", s.Path, err) } return "" }