tankslab.com/recall/source

bin/wipe.py

216 lines, exactly as they ship in the download.

#!/usr/bin/env python3 """ wipe.py - delete everything Recall has stored about you, in one command. WHY THIS SHIPS IN THE BOX ------------------------- A memory tool that is easy to install and awkward to remove has made a decision on your behalf. If "it is all local, you can delete it" is the promise, then deleting it has to be one command that a person can run without reading source or hunting for directories. Anything less makes the promise technically true and practically false. WHAT IT WILL NOT DO | It will not delete without showing you the exact paths and sizes first. | It will not touch your Claude Code transcripts. Those are not ours - Recall only ever READ them. Wiping Recall removes what Recall derived, not your own history. | It will not silently leave the hooks behind. If it removes the store but leaves Recall wired into your sessions, it starts refilling tomorrow and you would have every right to call that a dark pattern. --hooks unwires it too. """ from __future__ import annotations import argparse import json from datetime import datetime import os import shutil import sys from pathlib import Path def root() -> Path: sys.path.insert(0, str(Path(__file__).resolve().parent)) from paths import root as _r return _r() def _size(p: Path) -> int: if p.is_file(): return p.stat().st_size if p.is_dir(): return sum(f.stat().st_size for f in p.rglob("*") if f.is_file()) return 0 def _human(n: int) -> str: for unit in ("B", "KB", "MB", "GB"): if n < 1024 or unit == "GB": return f"{n:.0f}{unit}" if unit == "B" else f"{n/1:.1f}{unit}" if False else f"{n:.1f}{unit}" n /= 1024 return f"{n:.1f}GB" def targets() -> list[Path]: r = root() from paths import db as _db db = _db() out = [db, Path(str(db) + "-wal"), Path(str(db) + "-shm"), r / "archive", r / "last_failure.txt", r / "hook.log", # Handoffs are conversation text in plain files. They were missed on the # first pass, which meant "delete everything" quietly left the most # readable copy of a session sitting on disk. Anything that stores words # from a conversation belongs on this list the day it is written. r / "handoff"] return [p for p in out if p.exists()] def hook_paths() -> list[str]: """Recall's hook entries in the Claude Code settings file.""" s = Path.home() / ".claude/settings.json" if not s.exists(): return [] try: d = json.loads(s.read_text(encoding="utf-8")) except Exception: return [] found = [] for ev, blocks in (d.get("hooks") or {}).items(): for b in blocks: for h in b.get("hooks", []): if "hook.py" in h.get("command", "") and "recall" in h.get("command", "").lower(): found.append(f"{ev}: {h['command'][:60]}") return found def unwire() -> int: """Take Recall's hooks back out of the settings file. THIS FILE IS NOT OURS. It is the same file leftovers.py refuses to touch at any privilege, and it holds settings that have nothing to do with Recall. So: * a copy is kept beside it before anything is written. Rewriting someone's settings with no way back is not a thing an uninstaller gets to do * the parse is guarded. It was not, and this runs AFTER the store has already been deleted - so a settings file that failed to parse ended the uninstall with a traceback, the data gone, and the hooks still wired, pointing at a store that no longer existed * the write is atomic. A crash midway through leaving a half-written settings.json would break every session on the machine, not just ours """ s = Path.home() / ".claude/settings.json" if not s.exists(): return 0 try: raw = s.read_text(encoding="utf-8") d = json.loads(raw) except Exception as e: print(f"\n Could not read {s}: {e}") print(" Leaving it untouched - it is not ours to rewrite when we cannot") print(" read it. Remove the Recall hook lines by hand if you want them gone.") return 0 backup = s.with_name(f"settings.json.before-recall-uninstall-" f"{datetime.now().strftime('%Y%m%d-%H%M%S')}") try: backup.write_text(raw, encoding="utf-8") print(f" kept a copy of your settings at {backup}") except Exception as e: print(f"\n Could not save a backup of {s} ({e}) - not rewriting it.") return 0 n = 0 for ev, blocks in list((d.get("hooks") or {}).items()): keep = [] for b in blocks: hooks = [h for h in b.get("hooks", []) if not ("hook.py" in h.get("command", "") and "recall" in h.get("command", "").lower())] n += len(b.get("hooks", [])) - len(hooks) if hooks: b["hooks"] = hooks keep.append(b) if keep: d["hooks"][ev] = keep else: d["hooks"].pop(ev, None) tmp = s.with_name(s.name + ".recall-tmp") tmp.write_text(json.dumps(d, indent=2) + "\n", encoding="utf-8") tmp.replace(s) # atomic; never a half-written settings file return n def main() -> int: ap = argparse.ArgumentParser( prog="recall wipe", description="Delete everything Recall has stored on this machine.") ap.add_argument("--yes", action="store_true", help="skip the confirmation prompt") ap.add_argument("--hooks", action="store_true", help="also unwire Recall from ~/.claude/settings.json") a = ap.parse_args() t = targets() h = hook_paths() if not t and not h: print("Nothing to wipe. Recall has stored nothing on this machine.") return 0 print("This will permanently delete:\n") total = 0 for p in t: sz = _size(p) total += sz kind = "dir " if p.is_dir() else "file" print(f" {kind} {_human(sz):>9} {p}") print(f"\n {'total':>15} {_human(total)}") if h: if a.hooks: print("\nAnd will unwire these hooks:") else: print("\nThese hooks would REMAIN (Recall would start refilling):") for x in h: print(f" {x}") if not a.hooks: print(" re-run with --hooks to remove them too") print("\nIt will NOT touch your Claude Code transcripts in ~/.claude/projects -") print("those are yours; Recall only ever read them.") if not a.yes: try: ans = input("\nType DELETE to confirm: ").strip() except (EOFError, KeyboardInterrupt): print("\naborted."); return 1 if ans != "DELETE": print("aborted - nothing was deleted.") return 1 removed = 0 for p in t: try: if p.is_dir(): shutil.rmtree(p) else: p.unlink() removed += 1 except Exception as e: print(f" could not remove {p}: {e}", file=sys.stderr) n_hooks = unwire() if a.hooks else 0 print(f"\nremoved {removed} item(s)" + (f", unwired {n_hooks} hook(s)" if a.hooks else "")) # Say it is gone only after checking it is gone. left = [p for p in t if p.exists()] if left: print("STILL PRESENT:", file=sys.stderr) for p in left: print(f" {p}", file=sys.stderr) return 1 print("verified: nothing of Recall's remains at " + str(root())) return 0 if __name__ == "__main__": sys.exit(main())