tankslab.com/recall/source
bin/archive.py
173 lines, exactly as they ship in the download.
#!/usr/bin/env python3
"""
archive.py - durable, redacted export of the Recall store.
WHY
---
Harvested history may originally exist only inside the previous tool's database.
Losing that
would leave recall.db as a single point of failure with no way to rebuild.
This writes our own copy, in our own format, already redacted: gzipped JSONL,
one object per line, no vendor schema and nothing to interpret.
recall-archive export write archive/recall-<date>.jsonl.gz
recall-archive verify <f> prove the archive can be read back and counted
"""
from __future__ import annotations
import argparse
import gzip
import json
import sys
from datetime import datetime, timezone
from pathlib import Path
sys.path.insert(0, str(Path(__file__).resolve().parent))
import store # noqa: E402
from paths import archive_dir
ARCHIVE_DIR = archive_dir()
TABLES = ("sessions", "observations", "summaries", "prompts")
def cmd_export(a) -> int:
con = store.connect(readonly=True)
ARCHIVE_DIR.mkdir(parents=True, exist_ok=True)
stamp = datetime.now(timezone.utc).strftime("%Y%m%d")
out = Path(a.out) if a.out else ARCHIVE_DIR / f"recall-{stamp}.jsonl.gz"
counts = {}
with gzip.open(out, "wt", encoding="utf-8") as f:
f.write(json.dumps({
"_meta": "friday-recall-archive", "version": 1,
"exported_at": datetime.now(timezone.utc).isoformat(),
"tables": list(TABLES),
}) + "\n")
for t in TABLES:
n = 0
for row in con.execute(f"SELECT * FROM {t}"):
d = dict(row)
d["_t"] = t
f.write(json.dumps(d, ensure_ascii=False) + "\n")
n += 1
counts[t] = n
size = out.stat().st_size
print(f"archive {out}")
print(f"size {size/1e6:.1f} MB")
for k, v in counts.items():
print(f" {k:14}{v:>9,}")
return 0
def _pick_archive(name: str | None) -> Path:
"""Resolve an archive path, or say plainly that there is none. A missing backup
is a real state to report, not a stack trace to decode."""
if name:
p = Path(name)
if not p.is_file():
raise SystemExit(f"no archive at {p}")
return p
found = sorted(ARCHIVE_DIR.glob("recall-*.jsonl.gz"))
if not found:
raise SystemExit(f"no archives in {ARCHIVE_DIR} - run: recall-archive export")
return found[-1]
def cmd_verify(a) -> int:
"""An archive nobody has read back is a guess, not a backup."""
p = _pick_archive(a.file)
counts: dict[str, int] = {}
meta = None
bad = 0
with gzip.open(p, "rt", encoding="utf-8") as f:
for i, line in enumerate(f):
try:
d = json.loads(line)
except Exception:
bad += 1
continue
if i == 0 and d.get("_meta"):
meta = d
continue
t = d.get("_t")
if not t:
bad += 1
continue
counts[t] = counts.get(t, 0) + 1
con = store.connect(readonly=True)
live = {t: con.execute(f"SELECT COUNT(*) FROM {t}").fetchone()[0] for t in TABLES}
print(f"archive {p}")
print(f"meta {meta.get('exported_at') if meta else 'MISSING'}")
print(f"{'table':16}{'archive':>10}{'store':>10} {'delta':>8}")
ok = bad == 0 and meta is not None
drift = 0
for t in TABLES:
a_n, l_n = counts.get(t, 0), live[t]
d = l_n - a_n
drift += max(d, 0)
print(f"{t:16}{a_n:>10,}{l_n:>10,} {d:>+8,}")
if bad:
print(f"unparseable lines: {bad}")
if meta is None:
print("archive header MISSING - not a recall archive")
# Two different questions. Corruption is always a failure. Drift only matters
# when the archive is about to become the ONLY copy - that is --strict, and
# it is what the pre-burn gate uses.
if drift:
print(f"\nDRIFT: store has {drift:,} rows the archive does not "
f"(live capture ran after export)")
if a.strict and drift:
print("ARCHIVE VERIFY: FAILED (--strict: archive does not cover the store)")
return 1
print("\nARCHIVE VERIFY:", "OK" if ok else "FAILED")
return 0 if ok else 1
def cmd_restore(a) -> int:
"""Rebuild the store from an archive alone - the path that makes the burn safe."""
p = _pick_archive(a.file)
con = store.connect()
cols = {t: [r[1] for r in con.execute(f"PRAGMA table_info({t})")] for t in TABLES}
n = 0
with gzip.open(p, "rt", encoding="utf-8") as f:
for line in f:
try:
d = json.loads(line)
except Exception:
continue
t = d.pop("_t", None)
if t not in TABLES:
continue
d.pop("id", None) if t != "sessions" else None
keys = [k for k in d if k in cols[t]]
if not keys:
continue
q = (f"INSERT OR IGNORE INTO {t} ({','.join(keys)}) "
f"VALUES ({','.join('?' * len(keys))})")
con.execute(q, [d[k] for k in keys])
n += 1
if n % 5000 == 0:
con.commit()
con.commit()
print(f"restored {n:,} rows from {p.name}")
return 0
def main() -> int:
ap = argparse.ArgumentParser(prog="recall-archive")
s = ap.add_subparsers(dest="cmd", required=True)
e = s.add_parser("export"); e.add_argument("--out"); e.set_defaults(fn=cmd_export)
v = s.add_parser("verify"); v.add_argument("file", nargs="?")
v.add_argument("--strict", action="store_true",
help="fail if the store holds rows the archive does not")
v.set_defaults(fn=cmd_verify)
r = s.add_parser("restore"); r.add_argument("file", nargs="?"); r.set_defaults(fn=cmd_restore)
a = ap.parse_args()
return a.fn(a)
if __name__ == "__main__":
sys.exit(main())