Created
March 13, 2026 04:11
-
-
Save fizz/089ec45e0bdc94f4f97de514a73f0709 to your computer and use it in GitHub Desktop.
Best-effort synthetic restore of Claude Code project sessions from ~/.claude/history.jsonl when per-session JSONL files are lost
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| #!/usr/bin/env python3 | |
| from __future__ import annotations | |
| import argparse | |
| import datetime as dt | |
| import hashlib | |
| import json | |
| import uuid | |
| from pathlib import Path | |
| def iso_from_ms(ms: int) -> str: | |
| return dt.datetime.fromtimestamp(ms / 1000, tz=dt.timezone.utc).isoformat(timespec='milliseconds').replace('+00:00', 'Z') | |
| def stable_sid(project: str, start_ms: int, idx: int) -> str: | |
| seed = f"{project}|{start_ms}|{idx}".encode('utf-8') | |
| digest = hashlib.sha1(seed).hexdigest()[:12] | |
| # deterministic uuid-ish string | |
| u = uuid.uuid5(uuid.NAMESPACE_URL, f"claude-synth:{digest}:{project}:{start_ms}:{idx}") | |
| return str(u) | |
| def parse_args() -> argparse.Namespace: | |
| p = argparse.ArgumentParser(description='Best-effort synthetic Claude project restore from ~/.claude/history.jsonl') | |
| p.add_argument('--history', default=str(Path.home() / '.claude' / 'history.jsonl')) | |
| p.add_argument('--project-path', required=True) | |
| p.add_argument('--project-dir', required=True) | |
| p.add_argument('--gap-minutes', type=int, default=180, help='New synthetic session when time gap exceeds this') | |
| p.add_argument('--dry-run', action='store_true') | |
| return p.parse_args() | |
| def main() -> None: | |
| args = parse_args() | |
| history = Path(args.history).expanduser().resolve() | |
| project_dir = Path(args.project_dir).expanduser().resolve() | |
| project_dir.mkdir(parents=True, exist_ok=True) | |
| rows: list[dict] = [] | |
| with history.open('r', encoding='utf-8', errors='ignore') as fh: | |
| for line in fh: | |
| line = line.strip() | |
| if not line: | |
| continue | |
| try: | |
| obj = json.loads(line) | |
| except Exception: | |
| continue | |
| if obj.get('project') != args.project_path: | |
| continue | |
| ts = obj.get('timestamp') | |
| if not isinstance(ts, int): | |
| continue | |
| text = (obj.get('display') or '').strip() | |
| if not text: | |
| continue | |
| rows.append({'ts': ts, 'text': text}) | |
| rows.sort(key=lambda r: r['ts']) | |
| if not rows: | |
| print(json.dumps({'project': args.project_path, 'messages': 0, 'sessions': 0, 'written': 0})) | |
| return | |
| gap_ms = args.gap_minutes * 60 * 1000 | |
| sessions: list[list[dict]] = [] | |
| cur: list[dict] = [] | |
| prev_ts = None | |
| for r in rows: | |
| if prev_ts is None or (r['ts'] - prev_ts) <= gap_ms: | |
| cur.append(r) | |
| else: | |
| sessions.append(cur) | |
| cur = [r] | |
| prev_ts = r['ts'] | |
| if cur: | |
| sessions.append(cur) | |
| written = 0 | |
| for i, sess in enumerate(sessions, 1): | |
| sid = stable_sid(args.project_path, sess[0]['ts'], i) | |
| out = project_dir / f"{sid}.jsonl" | |
| payload = [] | |
| # synthetic summary record first | |
| first = sess[0]['text'] | |
| payload.append({ | |
| 'type': 'summary', | |
| 'summary': first[:300], | |
| 'sessionId': sid, | |
| 'cwd': args.project_path, | |
| 'gitBranch': '', | |
| 'version': 'synthetic-history-v1', | |
| 'timestamp': iso_from_ms(sess[0]['ts']), | |
| }) | |
| for r in sess: | |
| payload.append({ | |
| 'type': 'user', | |
| 'sessionId': sid, | |
| 'cwd': args.project_path, | |
| 'gitBranch': '', | |
| 'version': 'synthetic-history-v1', | |
| 'timestamp': iso_from_ms(r['ts']), | |
| 'message': { | |
| 'role': 'user', | |
| 'content': [{'type': 'text', 'text': r['text']}], | |
| }, | |
| }) | |
| if not args.dry_run: | |
| with out.open('w', encoding='utf-8') as fh: | |
| for rec in payload: | |
| fh.write(json.dumps(rec, ensure_ascii=False) + '\n') | |
| written += 1 | |
| print(json.dumps({ | |
| 'project': args.project_path, | |
| 'messages': len(rows), | |
| 'sessions': len(sessions), | |
| 'written': written, | |
| 'project_dir': str(project_dir), | |
| })) | |
| if __name__ == '__main__': | |
| main() |
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment