tankslab.com/recall/source
install.py
232 lines, exactly as they ship in the download.
#!/usr/bin/env python3
"""
install.py - set up Logbook on Windows, Linux or macOS.
Python is the only dependency, which is why this is a Python script and not a shell
script: one installer that behaves identically everywhere beats a .sh plus a .ps1
that drift apart.
It refuses to install if the redaction selftest fails. That is deliberate — the whole
claim of this tool is that it removes secrets before writing them down, and a build
whose redactor cannot fire would report "clean" forever while storing everything.
"""
from __future__ import annotations
import json
import sqlite3
import subprocess
import sys
from pathlib import Path
HERE = Path(__file__).resolve().parent
SRC = HERE / "bin" if (HERE / "bin").is_dir() else HERE
def fail(msg: str) -> None:
print(f"\nINSTALL REFUSED: {msg}", file=sys.stderr)
sys.exit(2)
def main() -> int:
print("Logbook installer")
print(f" platform : {sys.platform}")
print(f" python : {sys.version.split()[0]}")
if sys.version_info < (3, 10):
fail(f"needs Python 3.10 or newer, found {sys.version.split()[0]}")
# FTS5 does the searching. Without it there is no product.
try:
c = sqlite3.connect(":memory:")
c.execute("create virtual table t using fts5(x)")
print(f" sqlite : {sqlite3.sqlite_version} (FTS5 present)")
except Exception:
fail("this Python's SQLite has no FTS5. Recall needs it for search.")
# No network code may be present. Checked at install, not just claimed in a README.
# The needle is assembled rather than written out, for the same reason the
# redactor's test vector is not an http URL: this package tells people to verify
# it with `grep -rE "https?://"`, and a checker that matches ITSELF makes that
# command return a hit and teaches the reader to ignore it. The verifier must not
# trip its own verification.
_scheme = "htt" + "p"
needles = (_scheme + "://", _scheme + "s://")
hits = []
for f in list(SRC.glob("*.py")) + [Path(__file__)]:
for i, line in enumerate(f.read_text(encoding="utf-8", errors="replace").splitlines(), 1):
if any(n in line for n in needles):
hits.append(f"{f.name}:{i}")
if hits:
fail("found URLs in the package, which should contain none: " + ", ".join(hits[:5]))
print(" network : no URLs anywhere in the package")
sys.path.insert(0, str(SRC))
from redact import selftest
if selftest(verbose=False) != 0:
selftest(verbose=True)
fail("the redaction selftest did not pass. Nothing was installed.")
print(" redactor : selftest passed, every rule proven able to fire")
import paths
root = paths.ensure_root()
print(f" store : {root}")
subprocess.run([sys.executable, str(SRC / "store.py")], check=True,
stdout=subprocess.DEVNULL)
if "--no-hooks" in sys.argv:
print(" hooks : skipped (--no-hooks)")
elif looks_temporary(SRC) and "--force-hooks" not in sys.argv:
print(f" hooks : NOT wired - this copy is running from {SRC}")
print(" That path looks temporary. Wiring hooks to a directory that")
print(" gets deleted leaves every session capturing NOTHING, silently.")
print(" Install from a permanent location, or pass --force-hooks.")
else:
print(f" hooks : {wire_hooks()}")
offer_import(SRC)
print("\nInstalled. Nothing leaves this machine.")
print(f" run: {sys.executable} {SRC / 'recall.py'} health")
print(f" search: {sys.executable} {SRC / 'recall.py'} search \"<query>\"")
print(f" delete: {sys.executable} {SRC / 'wipe.py'} --hooks")
print("\nPut them on your PATH if you like - see README.md.")
return 0
def offer_import(src: Path) -> None:
"""Tell people their existing history can come across.
The users most likely to want this tool are the ones already running another one.
If importing means reading a README and finding a path, most will not do it — and
then leaving costs them their history. That turns this into "your
privacy or your memory", which is the exact choice the release exists to remove.
So the installer looks and says what it found. It does not import anything.
"""
try:
sys.path.insert(0, str(src))
import sources
found = next((e for e in sources.detect_all()
if e['id'] == 'legacy-store'), None)
except Exception:
return
if not found:
return
n = sum(found["found"]["counts"].values())
print("\n -- you already have another memory tool --")
print(sources.describe(found))
print(f"\n Those {n:,} entries can come across, redacted on the way in:")
print(f" {sys.executable} {src / 'recall.py'} import")
print(" It reads that file and does not modify, move or delete it.")
print(" Nothing has been imported. That is your call.")
def looks_temporary(path: Path) -> bool:
"""Is this copy running from somewhere that will not exist tomorrow?
This exists because it already happened. A verification run extracted the
tarball to a scratch directory, ran this installer from it, and the installer
dutifully pointed the LIVE hooks at that scratch path. The directory was then
deleted, so both hooks pointed at nothing and automatic capture died silently
for over two hours on a machine that was not even the one being tested.
An installer that rewires live configuration to wherever it happens to be sitting
is a foot-gun. Verifying a package should not be able to break the working one.
"""
parts = {p.lower() for p in path.parts}
marker = {"tmp", "temp", "verify", "verification", "scratch", "scratchpad",
"staging", "unpack", "extract", "test", "tests"}
if parts & marker:
return True
s = str(path).lower()
return s.startswith(("/tmp/", "/var/tmp/", "/private/tmp/"))
EVENTS = ("session-start", "stop", "pre-compact", "session-end")
def _is_ours(h: dict) -> bool:
"""Recognise a hook entry this installer wrote, however it was written.
Matching on the word "recall" in the command was the old test, and it fails the
moment somebody renames the folder they installed from - then every re-install
appends a second copy instead of replacing the first, and the events fire twice.
Match on what is actually invariant: our hook file plus one of our event names,
in either the command string or the argument list.
"""
blob = (h.get("command", "") or "") + " " + " ".join(str(a) for a in h.get("args", []) or [])
return "hook.py" in blob and any(e in blob for e in EVENTS)
def wire_hooks() -> str:
"""SessionStart, Stop, PreCompact, SessionEnd only. Never PostToolUse, PreToolUse
or UserPromptSubmit - see the table in README.md for what those would expose.
⚠️ WHY THESE ARE WRITTEN IN EXEC FORM (command + args) AND NOT AS ONE STRING
A hook written as a single command string is handed to a shell: bash on Linux and
macOS, and PowerShell on Windows when Git Bash is absent. PowerShell does not
execute a quoted path sitting at the start of a line - it parses it as a string
expression. The form this installer used to write:
"C:\\Python\\python.exe" "C:\\...\\hook.py" stop
is a PowerShell PARSER ERROR, measured on Windows, not inferred:
Unexpected token '"arg1"' in expression or statement.
So on any Windows machine without Git Bash, every hook this tool installed did
nothing at all. No capture, no handoff, no help at compaction - on a platform the
README promises support for. Nothing about it looks broken from inside the tool.
The exec form takes no shell at all. The executable is spawned directly and each
argument is passed as a separate string, so it behaves identically on every
platform AND survives spaces in paths - which on Windows is the normal case, not
an edge case ("C:\\Users\\First Last", "Program Files").
"""
s = Path.home() / ".claude" / "settings.json"
s.parent.mkdir(parents=True, exist_ok=True)
try:
d = json.loads(s.read_text()) if s.exists() else {}
except Exception:
return "left alone (settings.json is not valid JSON - wire it yourself)"
hook = str(SRC / "hook.py")
hooks = d.setdefault("hooks", {})
removed = 0
def put(event, matcher, arg, timeout, is_async=False):
nonlocal removed
entry = {"type": "command", "command": sys.executable,
"args": [hook, arg], "timeout": timeout}
if is_async:
entry["async"] = True
block = {"hooks": [entry]}
if matcher:
block["matcher"] = matcher
lst = hooks.setdefault(event, [])
before = len(lst)
lst[:] = [b for b in lst if not any(_is_ours(h) for h in b.get("hooks", []))]
removed += before - len(lst)
lst.append(block)
put("SessionStart", "startup|clear|compact", "session-start", 20)
# Stop may run in the background: the session continues afterwards, so the process
# is still there to finish the work.
put("Stop", None, "stop", 60, is_async=True)
put("PreCompact", None, "pre-compact", 25)
# ⚠️ SessionEnd is deliberately NOT async. "async" means the hook runs in the
# background without blocking - and this hook's entire job is to write the handoff
# BEFORE the session goes away. Backgrounding it puts that write in a race with
# process exit, so the handoff would go missing exactly on the sessions that ended
# abruptly, which are the ones somebody most wanted it for. It costs a fraction of
# a second of local text processing on the way out.
put("SessionEnd", None, "session-end", 60)
s.write_text(json.dumps(d, indent=2) + "\n")
note = f", replaced {removed} previous entr{'y' if removed == 1 else 'ies'}" if removed else ""
return ("SessionStart, Stop, PreCompact, SessionEnd wired (and only those)"
f", shell-less exec form{note}")
if __name__ == "__main__":
sys.exit(main())