bddba59ae3
Adds a brief [!IMPORTANT] callout at the top of the README pointing users to the urgent announcement at #1388. Claude Code auto-deletes local JSONL transcripts after 30 days; users without the auto-save hooks wired are losing transcript data off the rolling window. Ships 4 small standalone tools at tools/: - backup_claude_jsonls.sh — rsync ~/.claude/projects/ to a safe folder - render_jsonl.py — convert JSONL transcripts to readable text - find_orphan_claude_jsonls.sh — scan backup locations for orphan Claude Code transcripts (multi-line shape detection + topic preview) - save.md — Claude Code slash command for manual /save into MemPalace Tools verified by independent agent against v3.3.4 source. Read-only on user data. POSIX bash + Python stdlib only.
72 lines
2.3 KiB
Python
Executable File
72 lines
2.3 KiB
Python
Executable File
#!/usr/bin/env python3
|
|
"""render_jsonl.py — turn one Claude Code JSONL transcript into readable text.
|
|
|
|
Claude Code stores conversations at ~/.claude/projects/<proj>/<uuid>.jsonl and
|
|
Anthropic auto-deletes them after 30 days
|
|
(https://docs.claude.com/en/docs/claude-code/data-usage). This script renders a
|
|
JSONL into a clean .txt so you can keep / read / share it without the tooling.
|
|
|
|
Usage:
|
|
python3 render_jsonl.py <input.jsonl> [output.txt]
|
|
|
|
Stdlib only. Python 3.9+. Read-only on the input.
|
|
"""
|
|
import json, sys
|
|
from pathlib import Path
|
|
|
|
def extract_text(content):
|
|
if isinstance(content, str):
|
|
return content.strip()
|
|
if isinstance(content, list):
|
|
parts = []
|
|
for blk in content:
|
|
if isinstance(blk, dict) and blk.get("type") == "text":
|
|
t = (blk.get("text") or "").strip()
|
|
if t:
|
|
parts.append(t)
|
|
return "\n".join(parts)
|
|
return ""
|
|
|
|
def main():
|
|
if len(sys.argv) < 2:
|
|
print(__doc__); sys.exit(1)
|
|
src = Path(sys.argv[1])
|
|
if not src.is_file():
|
|
print(f"ERROR: not a file: {src}"); sys.exit(1)
|
|
out = open(sys.argv[2], "w", encoding="utf-8") if len(sys.argv) > 2 else sys.stdout
|
|
|
|
turns, stamps = [], []
|
|
for raw in src.read_text(encoding="utf-8", errors="replace").splitlines():
|
|
if not raw.strip():
|
|
continue
|
|
try:
|
|
obj = json.loads(raw)
|
|
except json.JSONDecodeError:
|
|
continue
|
|
role = obj.get("type") or (obj.get("message") or {}).get("role")
|
|
if role not in ("user", "assistant"):
|
|
continue
|
|
msg = obj.get("message") or obj
|
|
text = extract_text(msg.get("content"))
|
|
if not text:
|
|
continue
|
|
ts = obj.get("timestamp") or ""
|
|
if ts: stamps.append(ts)
|
|
turns.append((ts, role, text))
|
|
|
|
header = [
|
|
f"# Claude Code transcript: {src}",
|
|
f"# Total turns: {len(turns)}",
|
|
f"# Date range : {min(stamps) if stamps else 'n/a'} -> {max(stamps) if stamps else 'n/a'}",
|
|
"#" + "-" * 70, "",
|
|
]
|
|
out.write("\n".join(header))
|
|
for ts, role, text in turns:
|
|
out.write(f"\n[{ts}] {role.upper()}\n{text}\n\n{'-'*72}\n")
|
|
if out is not sys.stdout:
|
|
out.close()
|
|
print(f"Wrote {len(turns)} turns to {sys.argv[2]}")
|
|
|
|
if __name__ == "__main__":
|
|
main()
|