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())