From bddba59ae3ffe1fab6a281b7090f9945287a98d6 Mon Sep 17 00:00:00 2001
From: MillaJ <232237854+milla-jovovich@users.noreply.github.com>
Date: Wed, 6 May 2026 12:35:01 -0700
Subject: [PATCH 1/3] docs: add 30-day expiry callout + ship 4 auto-save tools
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
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.
---
README.md | 4 +
tools/backup_claude_jsonls.sh | 39 ++++++++++
tools/find_orphan_claude_jsonls.sh | 115 +++++++++++++++++++++++++++++
tools/render_jsonl.py | 71 ++++++++++++++++++
tools/save.md | 26 +++++++
5 files changed, 255 insertions(+)
create mode 100755 tools/backup_claude_jsonls.sh
create mode 100755 tools/find_orphan_claude_jsonls.sh
create mode 100755 tools/render_jsonl.py
create mode 100644 tools/save.md
diff --git a/README.md b/README.md
index 8157fca..d82bcd2 100644
--- a/README.md
+++ b/README.md
@@ -6,6 +6,10 @@
> domain — including `mempalace.tech` — is an impostor and may distribute
> malware. Details and timeline: [docs/HISTORY.md](docs/HISTORY.md).
+> [!IMPORTANT]
+> **🚨 Claude Code sessions expire in 30 days w/out auto-save hooks wired!** **[Read this →](https://github.com/MemPalace/mempalace/discussions/1388)**
+
+

diff --git a/tools/backup_claude_jsonls.sh b/tools/backup_claude_jsonls.sh
new file mode 100755
index 0000000..f252de0
--- /dev/null
+++ b/tools/backup_claude_jsonls.sh
@@ -0,0 +1,39 @@
+#!/usr/bin/env bash
+# backup_claude_jsonls.sh
+#
+# Claude Code stores every conversation as a JSONL transcript at
+# ~/.claude/projects/
/.jsonl
+# Anthropic auto-deletes those files after 30 DAYS:
+# https://docs.claude.com/en/docs/claude-code/data-usage
+#
+# This script copies them, read-only, into ~/Documents/Claude_JSONL_Backup/
+# so the 30-day clock no longer applies. Re-run any time — rsync is incremental.
+# It NEVER deletes, modifies, or touches files inside ~/.claude/.
+
+set -eu
+
+SRC="${HOME}/.claude/projects/"
+DST="${HOME}/Documents/Claude_JSONL_Backup/"
+
+[ -d "$SRC" ] || { echo "ERROR: $SRC does not exist."; exit 1; }
+mkdir -p "$DST"
+
+echo "Backing up $SRC -> $DST"
+rsync -a --times "$SRC" "$DST"
+
+src_count=$(find "$SRC" -type f -name '*.jsonl' | wc -l | tr -d ' ')
+dst_count=$(find "$DST" -type f -name '*.jsonl' | wc -l | tr -d ' ')
+oldest=$(find "$DST" -type f -name '*.jsonl' -exec stat -f '%Sm %N' -t '%Y-%m-%d' {} \; 2>/dev/null \
+ || find "$DST" -type f -name '*.jsonl' -printf '%TY-%Tm-%Td %p\n' 2>/dev/null)
+oldest_date=$(echo "$oldest" | sort | head -n 1 | awk '{print $1}')
+newest_date=$(echo "$oldest" | sort | tail -n 1 | awk '{print $1}')
+
+echo "Source JSONL count : $src_count"
+echo "Backup JSONL count : $dst_count"
+echo "Oldest backup file : ${oldest_date:-n/a}"
+echo "Newest backup file : ${newest_date:-n/a}"
+
+if [ "$src_count" -ne "$dst_count" ]; then
+ echo "FAIL: count mismatch ($src_count vs $dst_count)"; exit 2
+fi
+echo "OK: backup verified."
diff --git a/tools/find_orphan_claude_jsonls.sh b/tools/find_orphan_claude_jsonls.sh
new file mode 100755
index 0000000..43523f5
--- /dev/null
+++ b/tools/find_orphan_claude_jsonls.sh
@@ -0,0 +1,115 @@
+#!/usr/bin/env bash
+# find_orphan_claude_jsonls.sh — v3 (multi-line shape + verb-aware preview)
+# -----------------------------------------------------------------------------
+# Finds Claude Code conversation transcripts (.jsonl) that may have survived in
+# backup/sync locations. Claude Code stores transcripts at
+# ~/.claude/projects//.jsonl and auto-deletes them locally
+# after 30 days. If your machine syncs to iCloud, Dropbox, Google Drive,
+# OneDrive, Time Machine, or you copied transcripts elsewhere manually, those
+# copies still exist. This script finds them and shows a topic preview from
+# the first substantive user message — strips leading filler interjections
+# ("ok so", "oh", "well", "hey") so previews surface the actual content.
+#
+# Read-only. Safe to re-run.
+# -----------------------------------------------------------------------------
+set -eu
+
+LOCATIONS=(
+ "$HOME/Library/Mobile Documents" "$HOME/Dropbox" "$HOME/Google Drive"
+ "$HOME/OneDrive" "$HOME/Documents" "$HOME/Desktop" "/Volumes"
+)
+
+TMP="$(mktemp)"; trap 'rm -f "$TMP" "$TMP.s"' EXIT
+
+printf "Scanning backup locations" >&2
+for loc in "${LOCATIONS[@]}"; do
+ [ -d "$loc" ] || continue
+ printf "." >&2
+ while IFS= read -r -d '' f; do
+ # Combined: shape detection (multi-line) + verb-aware topic preview
+ if preview="$(python3 - "$f" 2>/dev/null <<'PYEOF'
+import json, sys, re
+
+# Single-word/short greetings — message gets skipped entirely if it is just one of these
+GREETINGS = {'hi','hey','hello','thanks','thank you','ok','okay','yes','no',
+ 'sure','cool','great','good','done','yep','nope','perfect','copy'}
+
+# Leading filler — interjections that get STRIPPED from the start of a message
+# before the preview is taken. Iterative — handles "ok so well, then..." → "then..."
+LEADING_FILLER = re.compile(
+ r'^(?:ok(?:ay)?|so|oh|well|anyway|btw|hmm+|um+|uh+|hey|hi|hello|right|'
+ r'yes|no|sure|cool|great|good|listen|look|wait|actually|alright|gotcha|'
+ r'yeah|yep|nope|nah)\b[\s,!.?:;-]*',
+ re.IGNORECASE
+)
+
+path = sys.argv[1]
+shape_ok = False
+preview = ""
+try:
+ with open(path, 'r', errors='replace') as fh:
+ for i, line in enumerate(fh):
+ if i >= 30: break
+ try:
+ d = json.loads(line)
+ except Exception:
+ continue
+ if not isinstance(d, dict): continue
+ # Shape check — accept if any line in first 30 has session fields
+ if not shape_ok and 'sessionId' in d and 'timestamp' in d and 'message' in d:
+ shape_ok = True
+ # Preview — first user message after stripping leading filler
+ if not preview:
+ role = d.get('type', '') or d.get('message', {}).get('role', '')
+ if role == 'user':
+ content = d.get('message', {}).get('content', '')
+ if isinstance(content, list):
+ text = ' '.join(
+ c.get('text', '') for c in content
+ if isinstance(c, dict) and c.get('type') == 'text'
+ )
+ elif isinstance(content, str):
+ text = content
+ else:
+ text = ''
+ text = re.sub(r'\s+', ' ', text).strip()
+ # Skip messages that are pure greetings
+ if text.lower() in GREETINGS:
+ continue
+ # Iteratively strip leading filler tokens until stable
+ prev_text = None
+ while prev_text != text:
+ prev_text = text
+ text = LEADING_FILLER.sub('', text).strip()
+ # Skip if what remains is too short
+ if len(text) < 20:
+ continue
+ preview = text[:80] + ('...' if len(text) > 80 else '')
+ if shape_ok and preview: break
+except Exception:
+ pass
+if shape_ok:
+ print(preview if preview else "(no preview — first 30 lines were greetings or short)")
+ sys.exit(0)
+sys.exit(1)
+PYEOF
+)"; then
+ mtime="$(stat -f '%Sm' -t '%Y-%m-%d' "$f" 2>/dev/null || stat -c '%y' "$f" 2>/dev/null | cut -d' ' -f1)"
+ size="$(stat -f '%z' "$f" 2>/dev/null || stat -c '%s' "$f" 2>/dev/null)"
+ printf '%s\t%s\t%s\t%s\n' "$mtime" "$size" "$f" "$preview" >>"$TMP"
+ fi
+ done < <(find "$loc" -type f -name '*.jsonl' -print0 2>/dev/null)
+done
+printf "\n" >&2
+
+count=$(wc -l <"$TMP" | tr -d ' ')
+if [ "$count" -eq 0 ]; then
+ echo "No orphan Claude Code transcripts found in scanned backup locations."
+ exit 0
+fi
+sort -k1,1 "$TMP" >"$TMP.s"
+oldest="$(head -n 1 "$TMP.s" | cut -f1)"
+newest="$(tail -n 1 "$TMP.s" | cut -f1)"
+echo "Found $count orphan Claude Code transcript(s). Oldest: $oldest Newest: $newest"
+echo "----------------------------------------------------------------------"
+awk -F'\t' '{ printf "%s %10s %s\n \"%s\"\n\n", $1, $2, $3, $4 }' "$TMP.s"
diff --git a/tools/render_jsonl.py b/tools/render_jsonl.py
new file mode 100755
index 0000000..3d74c00
--- /dev/null
+++ b/tools/render_jsonl.py
@@ -0,0 +1,71 @@
+#!/usr/bin/env python3
+"""render_jsonl.py — turn one Claude Code JSONL transcript into readable text.
+
+Claude Code stores conversations at ~/.claude/projects//.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 [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()
diff --git a/tools/save.md b/tools/save.md
new file mode 100644
index 0000000..914156b
--- /dev/null
+++ b/tools/save.md
@@ -0,0 +1,26 @@
+---
+description: Save the current Claude Code session into MemPalace. Idempotent — won't dupe.
+---
+
+# /save
+
+Save the current Claude Code session into MemPalace. Run this when you
+want a checkpoint. Safe to run repeatedly — drawer IDs are content-hashed
+so re-running on the same session overwrites in place, no duplicates.
+
+Behavior:
+
+1. Find the current session's JSONL transcript path (Claude Code passes
+ it via the conversation context — look for `~/.claude/projects/` paths).
+2. Run via bash:
+
+ ```
+ mempalace mine "" --mode convos --wing claude_imports
+ ```
+
+3. If the user supplied an argument after `/save`, use it as the wing name
+ instead of `claude_imports` (e.g. `/save my_research` →
+ `--wing my_research`).
+4. Report back: how many drawers were filed, into which wing/room.
+
+Requires `mempalace` to be installed (`pip install mempalace`).
From 921ff5a6faf753130ee5b6e9666daf6eec2bfc65 Mon Sep 17 00:00:00 2001
From: MillaJ <232237854+milla-jovovich@users.noreply.github.com>
Date: Wed, 6 May 2026 15:39:08 -0700
Subject: [PATCH 2/3] fix(tools/render_jsonl): split chained statements per
ruff 0.4.x
Addresses CI lint feedback on PR #1391. No behavior change.
- Split `import json, sys` into separate lines (E401)
- Split chained `print(...); sys.exit(1)` into two lines (E702, two occurrences)
- Split inline `if ts: stamps.append(ts)` into two lines (E701)
Verified: `ruff check tools/render_jsonl.py` reports "All checks passed!"
Tool still renders correctly (3 turns from a real JSONL test, identical output to pre-fix).
---
tools/render_jsonl.py | 12 ++++++++----
1 file changed, 8 insertions(+), 4 deletions(-)
diff --git a/tools/render_jsonl.py b/tools/render_jsonl.py
index 3d74c00..3372ee1 100755
--- a/tools/render_jsonl.py
+++ b/tools/render_jsonl.py
@@ -11,7 +11,8 @@ Usage:
Stdlib only. Python 3.9+. Read-only on the input.
"""
-import json, sys
+import json
+import sys
from pathlib import Path
def extract_text(content):
@@ -29,10 +30,12 @@ def extract_text(content):
def main():
if len(sys.argv) < 2:
- print(__doc__); sys.exit(1)
+ 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)
+ 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 = [], []
@@ -51,7 +54,8 @@ def main():
if not text:
continue
ts = obj.get("timestamp") or ""
- if ts: stamps.append(ts)
+ if ts:
+ stamps.append(ts)
turns.append((ts, role, text))
header = [
From 7c679ba6250fd8cc57af24a22ce9e19b67b20429 Mon Sep 17 00:00:00 2001
From: MillaJ <232237854+milla-jovovich@users.noreply.github.com>
Date: Wed, 6 May 2026 16:12:34 -0700
Subject: [PATCH 3/3] fix(tools/render_jsonl): apply ruff format
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
Earlier commit fixed ruff lint but missed the formatter check.
This applies `ruff format` — adds standard PEP8 blank lines between
functions, splits one inline list. No behavior change.
Verified: both `ruff format --check` and `ruff check` pass cleanly.
Tool still renders correctly.
---
tools/render_jsonl.py | 7 ++++++-
1 file changed, 6 insertions(+), 1 deletion(-)
diff --git a/tools/render_jsonl.py b/tools/render_jsonl.py
index 3372ee1..2bec0da 100755
--- a/tools/render_jsonl.py
+++ b/tools/render_jsonl.py
@@ -11,10 +11,12 @@ Usage:
Stdlib only. Python 3.9+. Read-only on the input.
"""
+
import json
import sys
from pathlib import Path
+
def extract_text(content):
if isinstance(content, str):
return content.strip()
@@ -28,6 +30,7 @@ def extract_text(content):
return "\n".join(parts)
return ""
+
def main():
if len(sys.argv) < 2:
print(__doc__)
@@ -62,7 +65,8 @@ def main():
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, "",
+ "#" + "-" * 70,
+ "",
]
out.write("\n".join(header))
for ts, role, text in turns:
@@ -71,5 +75,6 @@ def main():
out.close()
print(f"Wrote {len(turns)} turns to {sys.argv[2]}")
+
if __name__ == "__main__":
main()