tankslab.com/recall/source

bin/sources.py

205 lines, exactly as they ship in the download.

""" sources.py - everywhere a history can be imported from, in one table. WHY ONE TABLE ------------- This started as two files named after a particular product. That worked and it was wrong: anyone who downloaded the package read a competitor's name in a filename, so a write-up that had been carefully de-identified for readers was re-identified for downloaders. Hiding the name would have been worse - dishonest and still there. The fix is to stop needing it. A source is a ROW here: an id, a label, a detector, a reader and a mapper. Adding support for another tool is a table entry. The architecture stops producing accusations, which is a better outcome than being careful about wording. TRANSCRIPTS ARE ROW ONE, AND THE DEFAULT ---------------------------------------- Claude Code writes ~/.claude/projects/*.jsonl for everybody - whatever memory tool they run, or none at all. That is the universal source. It names nobody, it needs no other software present, and it serves the much larger group who never installed a memory tool and simply want their last two months back. THE REASON ANY OF THIS EXISTS ----------------------------- The people most likely to want this tool are the ones already running the other one. If importing their history means reading a README, finding a path and running a script with an env var, most will not - and then leaving costs them two months of memory. That turns "your privacy or your history" into a real choice, which is exactly the choice this release exists to remove. You do not have to choose between your privacy and your history. """ from __future__ import annotations import os import sqlite3 import sys from datetime import datetime, timezone from pathlib import Path sys.path.insert(0, str(Path(__file__).resolve().parent)) # ── detectors ─────────────────────────────────────────────────────────────────── def _detect_transcripts() -> dict | None: root = Path.home() / ".claude" / "projects" if not root.is_dir(): return None files = [f for d in root.iterdir() if d.is_dir() for f in d.glob("*.jsonl")] if not files: return None size = sum(f.stat().st_size for f in files) oldest = min(f.stat().st_mtime for f in files) return {"path": root, "size": size, "items": len(files), "since": datetime.fromtimestamp(oldest, timezone.utc), "counts": {"transcripts": len(files)}} # A legacy store is identified by its TABLES, not by its filename or folder - a # directory called ".something" proves nothing, and guessing from a name is how a # tool ends up touching a file it did not understand. _LEGACY_TABLES = {"observations", "sdk_sessions"} _LEGACY_EXTRA = {"session_summaries", "user_prompts"} _LEGACY_PATHS = ( Path.home() / ".claude-mem" / "claude-mem.db", Path.home() / ".claude-mem" / "claude-mem.sqlite", ) # Directory names the old tool created beside its database. These are DATA - strings # matched against a disk - and they live here, in the source table, for the same # reason the store paths do: a literal name an importer must match is allowed, and # everywhere else in the package is not. leftovers.py reads this rather than carrying # the names itself. LEGACY_SIBLINGS = { "logs": "its log files", "observer-sessions": "its saved observer sessions", "chroma": "its search index", "archive": "its own archives", "handoff": "session handoffs (plain text from your conversations)", "backups": "its backups", } def dedicated_store_dirs() -> tuple[Path, ...]: """The directories a legacy store is known to live in ALONE. This matters for cleanup, not for import. When the database sits in a folder that belongs to nothing else, the files beside it can be attributed to the same tool. When the user points --path at a database sitting in their home directory or Downloads, the folder is theirs, and a directory in it named "archive" or "backups" is THEIR archive - not a leftover. Identifying it by name alone would be a guess, and this package does not act on guesses. """ return tuple({p.parent.resolve() for p in _LEGACY_PATHS}) def _open_ro(db: Path) -> sqlite3.Connection | None: try: return sqlite3.connect(f"file:{db}?mode=ro", uri=True) except Exception: return None def _detect_legacy_store(explicit: Path | None = None) -> dict | None: cands = [explicit] if explicit else [] env = os.environ.get("RECALL_IMPORT_DB") if env and not explicit: cands.append(Path(env).expanduser()) cands += list(_LEGACY_PATHS) for db in cands: if not db or not Path(db).is_file(): continue con = _open_ro(Path(db)) if not con: continue try: names = {r[0] for r in con.execute( "SELECT name FROM sqlite_master WHERE type='table'")} if not _LEGACY_TABLES.issubset(names): continue counts = {} for t in sorted(_LEGACY_TABLES | (_LEGACY_EXTRA & names)): try: counts[t] = con.execute(f"SELECT COUNT(*) FROM {t}").fetchone()[0] except Exception: counts[t] = 0 st = Path(db).stat() return {"path": Path(db), "size": st.st_size, "items": sum(counts.values()), "since": datetime.fromtimestamp(st.st_mtime, timezone.utc), "counts": counts} finally: con.close() return None # ── readers: turn a detected source into rows the store understands ───────────── def _read_transcripts(found: dict, project: str) -> dict: import capture return capture.ingest_all(project) if hasattr(capture, "ingest_all") \ else _read_transcripts_fallback(project) def _read_transcripts_fallback(project: str) -> dict: import subprocess, json as _json r = subprocess.run([sys.executable, str(Path(__file__).resolve().parent / "capture.py"), "--all", "--project", project], capture_output=True, text=True) try: return _json.loads(r.stdout[r.stdout.index("{"):]) except Exception: return {} def _read_legacy_store(found: dict, project: str) -> dict: import legacy_store return legacy_store.import_from(found["path"]) # ── the table ─────────────────────────────────────────────────────────────────── SOURCES = [ { "id": "transcripts", "label": "Claude Code transcripts", "note": "the universal source - present for everyone, needs no other tool", "primary": True, "removable": False, # never offered for cleanup; they are not ours "detect": _detect_transcripts, "read": _read_transcripts, }, { "id": "legacy-store", "label": "a previous memory tool's database", "note": "detected by its tables, not its name", "primary": False, "removable": True, "detect": _detect_legacy_store, "read": _read_legacy_store, }, ] def detect_all(explicit: Path | None = None) -> list[dict]: out = [] for s in SOURCES: try: found = s["detect"](explicit) if s["id"] == "legacy-store" else s["detect"]() except TypeError: found = s["detect"]() except Exception: found = None if found: out.append({**s, "found": found}) return out def describe(entry: dict) -> str: f = entry["found"] lines = [f" {entry['label']}", f" at {f['path']}", f" size {f['size']/1e6:.1f} MB", f" since {f['since'].strftime('%d %B %Y')}"] for k, v in f["counts"].items(): lines.append(f" {k:20} {v:,}") return "\n".join(lines)