tankslab.com/recall/source

bin/capture.py

500 lines, exactly as they ship in the download.

#!/usr/bin/env python3 """ capture.py - turn a Claude Code transcript into Recall rows. DESIGN DECISION THAT MATTERS ---------------------------- The predecessor generated observations by spawning a hosted-model subprocess per session - roughly 450MB of memory each, and it shipped the transcript off the machine to do it. When that path failed it captured NOTHING, which is why "capture is broken" went unnoticed for hours. Recall does the whole job the other way, with one lane: FACT LANE (the only lane: deterministic, no model, no network) prompts, tool calls, files read/modified, commands, session boundaries. Titles and narratives are derived from those facts by the code in this file. Nothing is sent anywhere to produce them, so there is no second path that can be down, slow, cost money, or quietly fail. That is the whole trade: descriptions are plainer than a model would write, and in exchange capture cannot fail, cannot bill you, and cannot leak. Everything crosses redact.scrub() before it touches disk. """ from __future__ import annotations import argparse import hashlib import json import os import re import sys from datetime import datetime, timezone from pathlib import Path sys.path.insert(0, str(Path(__file__).resolve().parent)) import time as _time # noqa: E402 import store # noqa: E402 from redact import scrub, selftest # noqa: E402 # Tools whose results we deliberately never store the body of. The old store # kept everything a PostToolUse "*" hook saw; that is how 9 chat-bot tokens and # a provider API key ended up in a 203MB file with a cloud outbox attached. _NEVER_BODY = {"Read", "NotebookRead"} _WRITE_TOOLS = {"Edit", "Write", "NotebookEdit", "MultiEdit"} # In auto mode most file work happens through Bash, not Edit/Write. A capture # system that only watches the Edit tool reports "0 files modified" for a session # that rewrote a service - the same class of blind spot as a check that cannot # fire. These patterns recover the paths Bash actually touched. _BASH_WRITE = re.compile( r"""(?x) (?:^|[|;&]|then|do)\s* (?: (?:cat|tee|printf|echo)\s[^|;&]*?>{1,2}\s*(?P<redir>[^\s|;&<>]+) | sed\s+-[a-zA-Z]*i[a-zA-Z]*\s[^|;&]*?\s(?P<sed>[^\s|;&<>]+)\s*$ | (?:cp|mv|install)\s+(?:-[^\s]+\s+)*[^\s]+\s+(?P<dest>[^\s|;&<>]+) | (?:mkdir|touch)\s+(?:-[^\s]+\s+)*(?P<touch>[^\s|;&<>]+) | tee\s+(?:-a\s+)?(?P<tee>[^\s|;&<>]+) )""") def _bash_writes(cmd: str) -> set[str]: """Best-effort recovery of paths a shell command wrote to.""" out = set() for m in _BASH_WRITE.finditer(cmd): for g in ("redir", "sed", "dest", "touch", "tee"): v = m.group(g) if not v: continue v = v.strip("\"'") # /dev/null and friends are not artifacts if v.startswith("/dev/") or v in ("-", "") or v.startswith("$"): continue out.add(v) return out _READ_TOOLS = {"Read", "Grep", "Glob", "NotebookRead"} def _iso(ts: str | None) -> tuple[str, int]: if not ts: d = datetime.now(timezone.utc) else: try: d = datetime.fromisoformat(ts.replace("Z", "+00:00")) except Exception: d = datetime.now(timezone.utc) return d.isoformat(), int(d.timestamp() * 1000) # Harness-injected text arrives as type "user" but is not the user talking. # Storing it means the context block fills with <task-notification> noise # instead of what was actually asked for. _NOISE_PREFIX = ( "<system-reminder>", "<task-notification>", "<local-command-stdout>", "<local-command-stderr>", "<command-name>", "<command-message>", "<command-args>", "[Request interrupted", "Caveat: The messages below", "<user-prompt-submit-hook>", ) def _is_noise(txt: str) -> bool: t = txt.lstrip() return (not t) or t.startswith(_NOISE_PREFIX) def _blocks(msg) -> list: if isinstance(msg, dict): c = msg.get("content") if isinstance(c, list): return c if isinstance(c, str): return [{"type": "text", "text": c}] return [] _REDACTION_TAG = re.compile(r"\[REDACTED:[a-z0-9-]+\]") def _redactions_in(*values: str) -> int: """How many redactions are visible in the text actually stored. Counted from the written value, never from how many times scrub() ran. The boundary scrub runs over every file path and every command in a transcript, and those collapse into sets and per-turn digests before they reach a row -- so counting calls reported 87 redactions for a store containing 3. A number a user can verify by eye in their own store is the only honest one to print. """ return sum(len(_REDACTION_TAG.findall(v)) for v in values if isinstance(v, str)) def parse_transcript(path: Path) -> dict: """Deterministic extraction. No model involved.""" prompts, tools = [], [] files_read, files_mod, commands = set(), set(), [] # Per-turn attribution. Session-wide sets are still kept for the session row, # but an OBSERVATION must only claim what ITS turn touched — otherwise every # observation in a session inherits the whole session's file list, and # `recall-digest --file X` returns every turn of every session that ever # touched X. Observed 2026-08-20: one session had 30 observations all # claiming the same 21 files. per_turn_read: dict[int, set] = {} per_turn_mod: dict[int, set] = {} started = ended = None session_uid = path.stem title = None pn = 0 red_total = 0 # scrub() calls made at the boundary; NOT what a row # reports - see _redactions_in for why those differ def _clean(v): """Redact BEFORE anything downstream truncates or stores this. Two real leaks, both found 2026-08-20 against this build with planted values, are closed by scrubbing here instead of further down: * TRUNCATE-BEFORE-SCRUB. _title_for cuts the title to 120 chars, and ingest scrubbed only afterwards. A cut landing inside a token left a prefix too short for its own pattern to match, so it survived the scrub: 'ghp_<36 chars>' was stored as 'ghp_C'. Slicing raw text can only ever make a secret harder to recognise, never safer. * FILE PATHS WERE NEVER SCRUBBED AT ALL. files_read / files_modified went into the row raw, so a path like /tmp/AKIA<16>/out.txt stored a complete, working credential. Redacting once at the boundary makes both structural: nothing raw reaches a truncation, and no column can leak what never entered. """ nonlocal red_total if not v: return v out, hits, _ = scrub(v) red_total += hits return out # encoding NAMED, not left to the platform. Without it, Windows reads the # transcript in the machine's legacy code page and errors="replace" then # SILENTLY substitutes every character it cannot map - so a conversation in # any language but English would be stored corrupted, with no error and no # way to tell afterwards. Quiet damage to the memory itself. for line in path.read_text(encoding="utf-8", errors="replace").splitlines(): if not line.strip(): continue try: d = json.loads(line) except Exception: continue t = d.get("type") ts = d.get("timestamp") if ts: started = started or ts ended = ts if t == "ai-title" and not title: title = _clean(d.get("title") or d.get("aiTitle")) elif t == "user": for b in _blocks(d.get("message")): if b.get("type") == "text": raw = (b.get("text") or "").strip() txt, hits, _ = scrub(raw) if raw else ("", 0, None) red_total += hits if _is_noise(txt): continue pn += 1 prompts.append({"n": pn, "text": txt, "ts": ts}) elif t == "assistant": for b in _blocks(d.get("message")): if b.get("type") != "tool_use": continue name = b.get("name", "?") inp = b.get("input") or {} tools.append({"name": name, "ts": ts, "prompt_number": pn}) tmod = per_turn_mod.setdefault(pn, set()) tread = per_turn_read.setdefault(pn, set()) fp = _clean(inp.get("file_path") or inp.get("notebook_path")) if fp: if name in _WRITE_TOOLS: files_mod.add(fp); tmod.add(fp) else: files_read.add(fp); tread.add(fp) elif name in _READ_TOOLS and inp.get("path"): _p = _clean(inp["path"]) files_read.add(_p); tread.add(_p) if name == "Bash" and inp.get("command"): _cmd = _clean(inp["command"]) w = _bash_writes(_cmd) files_mod.update(w); tmod.update(w) commands.append({ "cmd": _cmd[:2000], "desc": _clean(inp.get("description", "")), "ts": ts, "prompt_number": pn, }) return { "session_uid": session_uid, "title": title, "prompts": prompts, "tools": tools, "files_read": sorted(files_read), "files_modified": sorted(files_mod), "commands": commands, "started_at": started, "ended_at": ended, "per_turn_read": per_turn_read, "per_turn_mod": per_turn_mod, "redactions": red_total, } # --- title construction ----------------------------------------------------- # The first version used the raw first line of the prompt. That reads like a chat # log ("run it all you pick order"), not a work log, and six months later it tells # you nothing about what actually happened. A title should survive the loss of its # context: intent first, outcome appended when the intent alone is not self-carrying. _STOPWORDS = re.compile( r"(?i)^(?:ok(?:ay)?|now|so|and|also|then|can you|could you|please|pls|" r"lets?|let's|go ahead and|i want (?:you )?to|i need (?:you )?to|" r"we need to|you (?:can|should)|hey|yo)\s+") def _clean_intent(text: str) -> str: line = "" for raw in text.strip().splitlines(): raw = raw.strip() if raw and not raw.startswith(("<", "#", "```")): line = raw break if not line: line = " ".join(text.split()) prev = None while prev != line: # strip stacked lead-ins prev = line line = _STOPWORDS.sub("", line).strip() line = " ".join(line.split()).rstrip(" .,:;!?-") return line[:1].upper() + line[1:] if line else "" def _outcome(files_mod: list[str], cmds: list[dict]) -> str: bits = [] if files_mod: names = [Path(f).name for f in files_mod] head = ", ".join(names[:3]) bits.append(head + (f" +{len(names)-3} more" if len(names) > 3 else "")) if cmds: bits.append(f"{len(cmds)} command{'s' if len(cmds) != 1 else ''}") return " | ".join(bits) def _title_for(prompt: str, files_mod: list[str], cmds: list[dict]) -> str: intent = _clean_intent(prompt) out = _outcome(files_mod, cmds) # A short or vague intent cannot stand alone - graft the outcome onto it. if out and (len(intent) < 34 or len(intent.split()) < 5): combined = f"{intent} - {out}" if intent else out return combined[:120] return (intent or out or "(no description)")[:120] def _classify(cmds: list[dict], files_mod: list[str]) -> str: joined = " ".join(c["cmd"] for c in cmds).lower() if any(k in joined for k in ("pytest", "selftest", "--test", "npm test")): return "verification" if any(k in joined for k in ("git commit", "git push")): return "change" if any(k in joined for k in ("systemctl", "service ", "restart")): return "operation" if files_mod: return "change" return "discovery" def build_observations(p: dict) -> list[dict]: """One observation per prompt turn - the unit the user actually thinks in.""" obs = [] by_pn: dict[int, dict] = {} for c in p["commands"]: by_pn.setdefault(c["prompt_number"], {"cmds": [], "tools": []})["cmds"].append(c) for t in p["tools"]: by_pn.setdefault(t["prompt_number"], {"cmds": [], "tools": []})["tools"].append(t) for pr in p["prompts"]: n = pr["n"] bucket = by_pn.get(n, {"cmds": [], "tools": []}) if not bucket["tools"]: continue # pure conversation turn, nothing done tool_names = [t["name"] for t in bucket["tools"]] counts: dict[str, int] = {} for tn in tool_names: counts[tn] = counts.get(tn, 0) + 1 mod = sorted(p["per_turn_mod"].get(n, set())) read = sorted(p["per_turn_read"].get(n, set())) title = _title_for(pr["text"], mod, bucket["cmds"]) obs.append({ "prompt_number": n, "type": _classify(bucket["cmds"], mod), "title": title, "subtitle": _outcome(mod, bucket["cmds"]) or ", ".join(f"{k}x{v}" for k, v in sorted(counts.items())), "body": pr["text"][:4000], "facts": json.dumps({ "tools": counts, "commands": [c["desc"] or c["cmd"][:120] for c in bucket["cmds"]][:20], }), "files_modified": json.dumps(mod[:60]), "files_read": json.dumps(read[:60]), "ts": pr["ts"], }) return obs def import_cutover(con) -> int: """Newest moment already covered by imported history. An earlier tool summarised everything up to its cutover; those rows live here as source='legacy-import' with better, model-written titles. Re-deriving the same turns from the transcripts would double every entry. This guard used to live ONLY in rebuild.sh, so `capture.py --all` bypassed it and silently re-added 3,229 duplicates. A rule that governs what may be written belongs where the writing happens, not in one script that happens to call it. """ r = con.execute( "SELECT MAX(created_at_epoch) FROM observations WHERE source='legacy-import'" ).fetchone() return int(r[0]) if r and r[0] else 0 def ingest(path: Path, project: str, con=None) -> dict: if selftest(verbose=False) != 0: raise SystemExit("ABORT: redaction selftest failed - refusing to capture") p = parse_transcript(path) own = con is None con = con or store.connect() cutover = import_cutover(con) s_iso, s_ep = _iso(p["started_at"]) e_iso, e_ep = _iso(p["ended_at"]) title, _, _ = scrub(p["title"] or "") # scrub-then-cut, never cut-then-scrub (see _clean in parse_transcript) opening = (scrub(p["prompts"][0]["text"])[0][:500]) if p["prompts"] else "" con.execute("""INSERT INTO sessions (session_uid,content_session_id,project,platform,title,opening_prompt, started_at,started_at_epoch,ended_at,ended_at_epoch,status,prompt_count) VALUES (?,?,?,'claude',?,?,?,?,?,?,'completed',?) ON CONFLICT(session_uid) DO UPDATE SET ended_at=excluded.ended_at, ended_at_epoch=excluded.ended_at_epoch, prompt_count=excluded.prompt_count, title=COALESCE(sessions.title,excluded.title)""", (p["session_uid"], p["session_uid"], project, title or None, opening, s_iso, s_ep, e_iso, e_ep, len(p["prompts"]))) # Redaction happens once, at the parse boundary; this is that count. # The scrubs below are idempotent re-checks and add 0 on clean input. n_p = n_o = 0 red = _redactions_in(title or "", opening) for pr in p["prompts"]: if cutover and _iso(pr["ts"])[1] <= cutover: continue body, _, _ = scrub(pr["text"]) # idempotent re-check on boundary-clean text h = _redactions_in(body) i_iso, i_ep = _iso(pr["ts"]) key = int(hashlib.sha1( f"{p['session_uid']}:{pr['n']}".encode()).hexdigest()[:12], 16) con.execute("""INSERT OR IGNORE INTO prompts (session_uid,project,prompt_number,body,created_at,created_at_epoch, redactions,source,legacy_id) VALUES (?,?,?,?,?,?,?,'transcript',?)""", (p["session_uid"], project, pr["n"], body, i_iso, i_ep, h, -key)) red += h # rowcount, not +1. INSERT OR IGNORE silently stores nothing when the row is # already there, so counting attempts made a re-ingest of the same transcript # report rows it did not write. That number is what `recall import` prints # back as proof the import landed - a count that overstates its work is the # one number here that must never be generous. n_p += con.execute("SELECT changes()").fetchone()[0] for o in build_observations(p): if cutover and _iso(o["ts"])[1] <= cutover: continue parts = {} for f in ("title", "subtitle", "body", "facts"): v, _, _ = scrub(o[f] or "") parts[f] = v # Every redaction visible in this row. The old code stored only the LAST # field's count, so a secret in the title vanished from the tally. h = _redactions_in(*parts.values(), o["files_read"], o["files_modified"]) i_iso, i_ep = _iso(o["ts"]) key = int(hashlib.sha1( f"{p['session_uid']}:obs:{o['prompt_number']}".encode()).hexdigest()[:12], 16) con.execute("""INSERT OR IGNORE INTO observations (session_uid,project,type,title,subtitle,body,facts,files_read, files_modified,prompt_number,created_at,created_at_epoch,redactions, source,legacy_id) VALUES (?,?,?,?,?,?,?,?,?,?,?,?,?,'transcript',?)""", (p["session_uid"], project, o["type"], parts["title"], parts["subtitle"], parts["body"], parts["facts"], o["files_read"], o["files_modified"], o["prompt_number"], i_iso, i_ep, h, -key)) red += h n_o += con.execute("SELECT changes()").fetchone()[0] # see the note above con.commit() if own: con.close() # Report what was STORED, not what was parsed. Returning len(p["prompts"]) # made a run that wrote 42 rows print "prompts: 7256" — a counter that # overstates its work is how people stop believing the output. return {"session": p["session_uid"], "prompts_seen": len(p["prompts"]), "prompts_stored": n_p, "observations_stored": n_o, "files_modified": len(p["files_modified"]), "redactions": red} def main() -> int: ap = argparse.ArgumentParser() ap.add_argument("transcript", nargs="?") ap.add_argument("--project", default=None) ap.add_argument("--all", action="store_true", help="ingest every transcript for the project") a = ap.parse_args() proj = a.project or Path.cwd().name or "home" if a.all: sys.path.insert(0, str(Path(__file__).resolve().parent)) from recall import transcript_files con = store.connect() tot = {"prompts_seen": 0, "prompts_stored": 0, "observations_stored": 0, "redactions": 0} files = transcript_files() for i, f in enumerate(files, 1): try: r = ingest(f, proj, con) for k in tot: tot[k] += r[k] except Exception as e: print(f" skip {f.name}: {e}", file=sys.stderr) if i % 25 == 0: print(f" {i}/{len(files)}...", flush=True) con.close() print(json.dumps({"transcripts": len(files), **tot}, indent=2)) return 0 if not a.transcript: ap.error("give a transcript path or --all") print(json.dumps(ingest(Path(a.transcript), proj), indent=2)) return 0 if __name__ == "__main__": sys.exit(main())