DEV Community

Krish Verma
Krish Verma

Posted on

How I built undo for my desktop AI companion: a backup before every destructive write

An AI companion that can edit your files will eventually delete the wrong one. That is not a hypothetical for me — it is the whole reason Ankita, my open-source desktop companion, now has undo.

Approval prompts were my first answer, and they are not enough. A prompt says "delete this file?" and a tired human clicks yes. The mistake is already made by the time anyone realises it. So I stopped trying to make the approval smarter and made the destructive operation itself reversible instead: every tool that can destroy data copies the original first, and a separate tool puts it back.

The decision: backup before mutate, restore as a tool

The rule lives in one small module, tools/shared/_backup.mjs (128 lines). Before delete_file, write_file, edit_file, edit_lines, move_file, or apply_patch touches anything, it calls backupPath(). Only after the copy succeeds does the mutation run. Undo is not a feature bolted onto one tool — it is the precondition for all of them.

Backups live inside the workspace, in .ankita_backups, so undo works with no config and never writes outside the workspace boundary. Each original path gets its own directory, named by the first 32 hex characters of the SHA-256 of its workspace-relative path. That hashing is there because of a real bug: the first version grouped backups by filename prefix, so foo and foo.txt could list — and prune — each other's backups. One hashed directory per path fixed it, and it bounds the name for deep paths at the same time.

Each backup file is named with a sortable timestamp plus a short random suffix, like 20251025-081512-123.a1b2c3d4.bak. Per path, the store keeps at most 20 backups and prunes the oldest; files over 5 MB are not copied at all. Symlinks are skipped too — copying what a link points to would be a different kind of destructive surprise.

What the user actually sees

The approval prompt for a delete already tells you the escape hatch exists:

delete  /workspace/notes.txt
A backup is kept; restore with restore_file.
Enter fullscreen mode Exit fullscreen mode

And the result of the delete tells you exactly how to use it:

Deleted /workspace/notes.txt
Backup: /workspace/.ankita_backups/<hash>/20251025-081512-123.a1b2c3d4.bak
Restore with restore_file {"path": "notes.txt"}
Enter fullscreen mode Exit fullscreen mode

restore_file restores the most recent backup by default, lists all backups for a path with {"list": true}, and takes an index (0 = oldest) for anything older. The 5 MB cap has a deliberate carve-out: a file too big to copy still shows up in the delete preview (directory listings are capped at 20 entries, files show their size), so you are warned about what you are losing even when there is no backup to fall back on.

Alongside the backups sits an audit log, .ankita_backups/audit.jsonl — one JSON record per mutation, scrubbed of secrets, capped per field, owner-only permissions (0o600), and written best-effort: the audit function never throws, because a logging failure must never break the tool it is logging. The backup tells you what you can get back; the audit tells you what happened.

The honest limits

Restore is blunt. It deletes whatever is currently at the path and copies the backup over it. There is no check for whether you recreated or edited the file after the destructive operation — a restore will clobber that newer work without asking. Backups also live inside the workspace, so anything that wipes the workspace wipes the undo history with it. And a file over 5 MB, or a symlink, simply has no backup; the preview is the only warning you get.

Those limits are the trade I made: undo that works with zero setup and zero new concepts, in exchange for a restore that trusts the user to mean it.

Tell me I'm wrong

Here is the part I am least sure about: should restore_file refuse — or at least warn — when the current content at a path differs from what the destructive operation left behind? The case that worries me is delete, recreate with new content, then restore the old file: silent most-recent-wins destroys the recreation. The counter-argument is that a companion should just fix the mistake when asked, and a refusal turns undo into a second approval prompt — the exact friction this design was built to escape.

Tell me I'm wrong, and where exactly it would break first: is silent most-recent-wins the right call, or should restore check before it clobbers?

Ankita is open source (MIT) at https://github.com/akyourowngames/A.N.K.I.T.A — a little companion, a lot more possible — and the backup module is small enough to read in one sitting. If you have built undo for an agent before, I would genuinely like to hear how you handled the restore-clobbers-new-work case.

Top comments (0)