@brandry/claude-jsonl-compressor 1.0.0-rc.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,215 @@
1
+ #!/usr/bin/env python3
2
+ """Utilities for locating and backing up Claude Code session JSONL files.
3
+
4
+ This helper is intentionally generic. It can:
5
+ - list session files under a .claude/projects tree
6
+ - match a session by exact path, file name, or session id without reading files
7
+ - optionally scan titles when --scan-titles is explicitly provided
8
+ - create numbered .backup copies before modification
9
+
10
+ It does not compress JSONL itself; it only prepares a single target file safely.
11
+ """
12
+
13
+ from __future__ import annotations
14
+
15
+ import argparse
16
+ import json
17
+ import os
18
+ import pathlib
19
+ import sys
20
+ from typing import Any, Dict, List, Optional, Sequence
21
+
22
+
23
+ def configure_stdio() -> None:
24
+ for stream in (sys.stdout, sys.stderr):
25
+ reconfigure = getattr(stream, "reconfigure", None)
26
+ if callable(reconfigure):
27
+ try:
28
+ reconfigure(encoding="utf-8", errors="replace")
29
+ except Exception:
30
+ pass
31
+
32
+
33
+ def eprint(*parts: object) -> None:
34
+ print(*parts, file=sys.stderr)
35
+
36
+
37
+ configure_stdio()
38
+
39
+
40
+ def read_jsonl_records(path: pathlib.Path) -> List[Dict[str, Any]]:
41
+ records: List[Dict[str, Any]] = []
42
+ with path.open("r", encoding="utf-8-sig", errors="replace") as f:
43
+ for line in f:
44
+ line = line.strip()
45
+ if not line:
46
+ continue
47
+ obj = json.loads(line)
48
+ if isinstance(obj, dict):
49
+ records.append(obj)
50
+ return records
51
+
52
+
53
+ def list_session_files(root: pathlib.Path) -> List[pathlib.Path]:
54
+ if not root.exists():
55
+ return []
56
+ root_resolved = root.resolve()
57
+ found: List[pathlib.Path] = []
58
+ for dir_path, dir_names, file_names in os.walk(root, topdown=True, followlinks=False):
59
+ directory = pathlib.Path(dir_path)
60
+ safe_dirs: List[str] = []
61
+ for name in dir_names:
62
+ child = directory / name
63
+ try:
64
+ resolved = child.resolve()
65
+ lexical = pathlib.Path(os.path.abspath(str(child)))
66
+ if resolved != lexical or not is_same_or_inside(resolved, root_resolved):
67
+ continue
68
+ except OSError:
69
+ continue
70
+ safe_dirs.append(name)
71
+ dir_names[:] = safe_dirs
72
+ for name in file_names:
73
+ if not name.lower().endswith(".jsonl"):
74
+ continue
75
+ candidate = directory / name
76
+ try:
77
+ resolved = candidate.resolve()
78
+ lexical = pathlib.Path(os.path.abspath(str(candidate)))
79
+ if resolved != lexical or not is_same_or_inside(resolved, root_resolved):
80
+ continue
81
+ if not candidate.is_file():
82
+ continue
83
+ except OSError:
84
+ continue
85
+ found.append(candidate)
86
+ return sorted(found)
87
+
88
+
89
+ def extract_hint(path: pathlib.Path) -> str:
90
+ try:
91
+ records = read_jsonl_records(path)
92
+ except Exception:
93
+ return ""
94
+ for obj in records:
95
+ for key in ("custom-title", "ai-title"):
96
+ if obj.get("type") == key:
97
+ if isinstance(obj.get("title"), str) and obj["title"].strip():
98
+ return obj["title"].strip()
99
+ msg = obj.get("message")
100
+ if isinstance(msg, dict):
101
+ content = msg.get("content")
102
+ if isinstance(content, str) and content.strip():
103
+ return content.strip().splitlines()[0][:120]
104
+ return ""
105
+
106
+
107
+ def numbered_backup_path(path: pathlib.Path) -> pathlib.Path:
108
+ base = path.with_suffix(path.suffix + ".backup")
109
+ if not base.exists():
110
+ return base
111
+ for i in range(1, 1000):
112
+ candidate = path.with_suffix(path.suffix + f".backup{i}")
113
+ if not candidate.exists():
114
+ return candidate
115
+ raise RuntimeError(f"could not find free backup name for {path}")
116
+
117
+
118
+ def create_backup(path: pathlib.Path) -> pathlib.Path:
119
+ source_bytes = path.read_bytes()
120
+ for _attempt in range(1000):
121
+ backup = numbered_backup_path(path)
122
+ backup.parent.mkdir(parents=True, exist_ok=True)
123
+ try:
124
+ with backup.open("xb") as stream:
125
+ stream.write(source_bytes)
126
+ stream.flush()
127
+ os.fsync(stream.fileno())
128
+ except FileExistsError:
129
+ continue
130
+ if backup.read_bytes() == source_bytes:
131
+ return backup
132
+ # Keep a concurrently replaced path untouched and try the next number.
133
+ raise RuntimeError(f"could not create and verify a numbered backup for {path}")
134
+
135
+
136
+ def is_same_or_inside(path: pathlib.Path, root: pathlib.Path) -> bool:
137
+ try:
138
+ path.resolve().relative_to(root.resolve())
139
+ return True
140
+ except (ValueError, OSError):
141
+ return False
142
+
143
+
144
+ def _candidate_label(root: pathlib.Path, path: pathlib.Path) -> str:
145
+ try:
146
+ return str(path.resolve().relative_to(root.resolve()))
147
+ except ValueError:
148
+ return path.name
149
+
150
+
151
+ def _path_query_match(root: pathlib.Path, path: pathlib.Path, query: str) -> bool:
152
+ q = query.lower()
153
+ name = path.name.lower()
154
+ stem = path.stem.lower()
155
+ if q == name or q == stem:
156
+ return True
157
+ if any(sep in query for sep in ("\\", "/")):
158
+ query_path = pathlib.Path(query)
159
+ if query_path.is_absolute():
160
+ try:
161
+ return path.resolve() == query_path.resolve()
162
+ except OSError:
163
+ return False
164
+ normalized_query = query.replace("\\", "/").lower()
165
+ relative = _candidate_label(root, path).replace("\\", "/").lower()
166
+ return normalized_query == relative
167
+ return False
168
+
169
+
170
+ def find_unique_session(root: pathlib.Path, query: str, scan_titles: bool = False) -> pathlib.Path:
171
+ candidates: List[pathlib.Path] = []
172
+ for path in list_session_files(root):
173
+ if _path_query_match(root, path, query):
174
+ candidates.append(path)
175
+ continue
176
+ if scan_titles:
177
+ hint = extract_hint(path).lower()
178
+ if hint and query.lower() in hint:
179
+ candidates.append(path)
180
+ if not candidates:
181
+ raise FileNotFoundError(f"no session matches {query!r} under {root}")
182
+ if len(candidates) > 1:
183
+ lines = [f"- {_candidate_label(root, p)}" for p in candidates[:20]]
184
+ raise RuntimeError("query matched multiple sessions:\n" + "\n".join(lines))
185
+ return candidates[0]
186
+
187
+
188
+ def main(argv: Optional[Sequence[str]] = None) -> int:
189
+ parser = argparse.ArgumentParser(
190
+ prog="claude-session-tools",
191
+ description="Locate exactly one Claude Code session JSONL and optionally create a verified numbered backup.",
192
+ )
193
+ parser.add_argument("--root", type=pathlib.Path, required=True, help="Root .claude/projects directory")
194
+ parser.add_argument("--query", required=True, help="Exact session id, filename, relative/absolute path, or title substring when --scan-titles is set")
195
+ parser.add_argument(
196
+ "--scan-titles",
197
+ action="store_true",
198
+ help="Read candidate JSONL files to match custom-title/ai-title. Omit this for privacy-sensitive exact path/id/filename lookup.",
199
+ )
200
+ parser.add_argument("--backup", action="store_true", help="Create a numbered .backup copy of the matched file")
201
+ args = parser.parse_args(argv)
202
+
203
+ try:
204
+ target = find_unique_session(args.root, args.query, scan_titles=args.scan_titles)
205
+ print(str(target))
206
+ if args.backup:
207
+ print(str(create_backup(target)))
208
+ return 0
209
+ except Exception as exc:
210
+ eprint(f"ERROR: {exc}")
211
+ return 1
212
+
213
+
214
+ if __name__ == "__main__":
215
+ raise SystemExit(main())