Skip to content

Instantly share code, notes, and snippets.

@fizz
Created March 13, 2026 04:11
Show Gist options
  • Select an option

  • Save fizz/089ec45e0bdc94f4f97de514a73f0709 to your computer and use it in GitHub Desktop.

Select an option

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
#!/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