@brandry/claude-jsonl-compressor 1.0.0 → 1.1.0

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.
@@ -1,245 +1,304 @@
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
- MIN_SUPPORTED_PYTHON = (3, 10)
40
-
41
-
42
- def warn_if_python_too_old() -> Optional[str]:
43
- """Warn on an unsupported interpreter without blocking the run."""
44
- if sys.version_info >= MIN_SUPPORTED_PYTHON:
45
- return None
46
- running = ".".join(str(part) for part in sys.version_info[:3])
47
- required = ".".join(str(part) for part in MIN_SUPPORTED_PYTHON)
48
- message = (
49
- f"WARNING: running on Python {running}; this project documents Python {required} or newer. "
50
- "Continuing anyway. Unexpected errors may be caused by the interpreter version."
51
- )
52
- eprint(message)
53
- return message
54
-
55
-
56
- warn_if_python_too_old()
57
-
58
-
59
- def read_jsonl_records(path: pathlib.Path) -> List[Dict[str, Any]]:
60
- records: List[Dict[str, Any]] = []
61
- with path.open("r", encoding="utf-8-sig", errors="replace") as f:
62
- for line in f:
63
- line = line.strip()
64
- if not line:
65
- continue
66
- obj = json.loads(line)
67
- if isinstance(obj, dict):
68
- records.append(obj)
69
- return records
70
-
71
-
72
- def list_session_files(root: pathlib.Path) -> List[pathlib.Path]:
73
- if not root.exists():
74
- return []
75
- root_resolved = root.resolve()
76
- found: List[pathlib.Path] = []
77
- for dir_path, dir_names, file_names in os.walk(root, topdown=True, followlinks=False):
78
- directory = pathlib.Path(dir_path)
79
- # Resolve the containing directory once and compare each child against
80
- # `resolved_directory / name`. Comparing child.resolve() against
81
- # os.path.abspath(child) instead would reject every entry whenever any
82
- # component of the root is a Windows 8.3 short name, because resolve()
83
- # expands short names to their long form and abspath() leaves them as
84
- # written. A symlink or junction that leaves the directory still
85
- # resolves somewhere other than resolved_directory / name, so the
86
- # escape guard is unchanged.
87
- try:
88
- resolved_directory = directory.resolve()
89
- except OSError:
90
- dir_names[:] = []
91
- continue
92
- safe_dirs: List[str] = []
93
- for name in dir_names:
94
- child = directory / name
95
- try:
96
- resolved = child.resolve()
97
- if resolved != resolved_directory / name or not is_same_or_inside(resolved, root_resolved):
98
- continue
99
- except OSError:
100
- continue
101
- safe_dirs.append(name)
102
- dir_names[:] = safe_dirs
103
- for name in file_names:
104
- if not name.lower().endswith(".jsonl"):
105
- continue
106
- candidate = directory / name
107
- try:
108
- resolved = candidate.resolve()
109
- if resolved != resolved_directory / name or not is_same_or_inside(resolved, root_resolved):
110
- continue
111
- if not candidate.is_file():
112
- continue
113
- except OSError:
114
- continue
115
- found.append(candidate)
116
- return sorted(found)
117
-
118
-
119
- def extract_hint(path: pathlib.Path) -> str:
120
- try:
121
- records = read_jsonl_records(path)
122
- except Exception:
123
- return ""
124
- for obj in records:
125
- for key in ("custom-title", "ai-title"):
126
- if obj.get("type") == key:
127
- if isinstance(obj.get("title"), str) and obj["title"].strip():
128
- return obj["title"].strip()
129
- msg = obj.get("message")
130
- if isinstance(msg, dict):
131
- content = msg.get("content")
132
- if isinstance(content, str) and content.strip():
133
- return content.strip().splitlines()[0][:120]
134
- return ""
135
-
136
-
137
- def numbered_backup_path(path: pathlib.Path) -> pathlib.Path:
138
- base = path.with_suffix(path.suffix + ".backup")
139
- if not base.exists():
140
- return base
141
- for i in range(1, 1000):
142
- candidate = path.with_suffix(path.suffix + f".backup{i}")
143
- if not candidate.exists():
144
- return candidate
145
- raise RuntimeError(f"could not find free backup name for {path}")
146
-
147
-
148
- def create_backup(path: pathlib.Path) -> pathlib.Path:
149
- source_bytes = path.read_bytes()
150
- for _attempt in range(1000):
151
- backup = numbered_backup_path(path)
152
- backup.parent.mkdir(parents=True, exist_ok=True)
153
- try:
154
- with backup.open("xb") as stream:
155
- stream.write(source_bytes)
156
- stream.flush()
157
- os.fsync(stream.fileno())
158
- except FileExistsError:
159
- continue
160
- if backup.read_bytes() == source_bytes:
161
- return backup
162
- # Keep a concurrently replaced path untouched and try the next number.
163
- raise RuntimeError(f"could not create and verify a numbered backup for {path}")
164
-
165
-
166
- def is_same_or_inside(path: pathlib.Path, root: pathlib.Path) -> bool:
167
- try:
168
- path.resolve().relative_to(root.resolve())
169
- return True
170
- except (ValueError, OSError):
171
- return False
172
-
173
-
174
- def _candidate_label(root: pathlib.Path, path: pathlib.Path) -> str:
175
- try:
176
- return str(path.resolve().relative_to(root.resolve()))
177
- except ValueError:
178
- return path.name
179
-
180
-
181
- def _path_query_match(root: pathlib.Path, path: pathlib.Path, query: str) -> bool:
182
- q = query.lower()
183
- name = path.name.lower()
184
- stem = path.stem.lower()
185
- if q == name or q == stem:
186
- return True
187
- if any(sep in query for sep in ("\\", "/")):
188
- query_path = pathlib.Path(query)
189
- if query_path.is_absolute():
190
- try:
191
- return path.resolve() == query_path.resolve()
192
- except OSError:
193
- return False
194
- normalized_query = query.replace("\\", "/").lower()
195
- relative = _candidate_label(root, path).replace("\\", "/").lower()
196
- return normalized_query == relative
197
- return False
198
-
199
-
200
- def find_unique_session(root: pathlib.Path, query: str, scan_titles: bool = False) -> pathlib.Path:
201
- candidates: List[pathlib.Path] = []
202
- for path in list_session_files(root):
203
- if _path_query_match(root, path, query):
204
- candidates.append(path)
205
- continue
206
- if scan_titles:
207
- hint = extract_hint(path).lower()
208
- if hint and query.lower() in hint:
209
- candidates.append(path)
210
- if not candidates:
211
- raise FileNotFoundError(f"no session matches {query!r} under {root}")
212
- if len(candidates) > 1:
213
- lines = [f"- {_candidate_label(root, p)}" for p in candidates[:20]]
214
- raise RuntimeError("query matched multiple sessions:\n" + "\n".join(lines))
215
- return candidates[0]
216
-
217
-
218
- def main(argv: Optional[Sequence[str]] = None) -> int:
219
- parser = argparse.ArgumentParser(
220
- prog="claude-session-tools",
221
- description="Locate exactly one Claude Code session JSONL and optionally create a verified numbered backup.",
222
- )
223
- parser.add_argument("--root", type=pathlib.Path, required=True, help="Root .claude/projects directory")
224
- parser.add_argument("--query", required=True, help="Exact session id, filename, relative/absolute path, or title substring when --scan-titles is set")
225
- parser.add_argument(
226
- "--scan-titles",
227
- action="store_true",
228
- help="Read candidate JSONL files to match custom-title/ai-title. Omit this for privacy-sensitive exact path/id/filename lookup.",
229
- )
230
- parser.add_argument("--backup", action="store_true", help="Create a numbered .backup copy of the matched file")
231
- args = parser.parse_args(argv)
232
-
233
- try:
234
- target = find_unique_session(args.root, args.query, scan_titles=args.scan_titles)
235
- print(str(target))
236
- if args.backup:
237
- print(str(create_backup(target)))
238
- return 0
239
- except Exception as exc:
240
- eprint(f"ERROR: {exc}")
241
- return 1
242
-
243
-
244
- if __name__ == "__main__":
245
- raise SystemExit(main())
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, Tuple
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
+ MIN_SUPPORTED_PYTHON = (3, 10)
40
+
41
+
42
+ def warn_if_python_too_old() -> Optional[str]:
43
+ """Warn on an unsupported interpreter without blocking the run."""
44
+ if sys.version_info >= MIN_SUPPORTED_PYTHON:
45
+ return None
46
+ running = ".".join(str(part) for part in sys.version_info[:3])
47
+ required = ".".join(str(part) for part in MIN_SUPPORTED_PYTHON)
48
+ message = (
49
+ f"WARNING: running on Python {running}; this project documents Python {required} or newer. "
50
+ "Continuing anyway. Unexpected errors may be caused by the interpreter version."
51
+ )
52
+ eprint(message)
53
+ return message
54
+
55
+
56
+ warn_if_python_too_old()
57
+
58
+
59
+ def read_jsonl_records(path: pathlib.Path) -> List[Dict[str, Any]]:
60
+ records: List[Dict[str, Any]] = []
61
+ with path.open("r", encoding="utf-8-sig", errors="replace") as f:
62
+ for line in f:
63
+ line = line.strip()
64
+ if not line:
65
+ continue
66
+ obj = json.loads(line)
67
+ if isinstance(obj, dict):
68
+ records.append(obj)
69
+ return records
70
+
71
+
72
+ def list_session_files(root: pathlib.Path) -> List[pathlib.Path]:
73
+ if not root.exists():
74
+ return []
75
+ root_resolved = root.resolve()
76
+ found: List[pathlib.Path] = []
77
+ for dir_path, dir_names, file_names in os.walk(root, topdown=True, followlinks=False):
78
+ directory = pathlib.Path(dir_path)
79
+ # Resolve the containing directory once and compare each child against
80
+ # `resolved_directory / name`. Comparing child.resolve() against
81
+ # os.path.abspath(child) instead would reject every entry whenever any
82
+ # component of the root is a Windows 8.3 short name, because resolve()
83
+ # expands short names to their long form and abspath() leaves them as
84
+ # written. A symlink or junction that leaves the directory still
85
+ # resolves somewhere other than resolved_directory / name, so the
86
+ # escape guard is unchanged.
87
+ try:
88
+ resolved_directory = directory.resolve()
89
+ except OSError:
90
+ dir_names[:] = []
91
+ continue
92
+ safe_dirs: List[str] = []
93
+ for name in dir_names:
94
+ child = directory / name
95
+ try:
96
+ resolved = child.resolve()
97
+ if resolved != resolved_directory / name or not is_same_or_inside(resolved, root_resolved):
98
+ continue
99
+ except OSError:
100
+ continue
101
+ safe_dirs.append(name)
102
+ dir_names[:] = safe_dirs
103
+ for name in file_names:
104
+ if not name.lower().endswith(".jsonl"):
105
+ continue
106
+ candidate = directory / name
107
+ try:
108
+ resolved = candidate.resolve()
109
+ if resolved != resolved_directory / name or not is_same_or_inside(resolved, root_resolved):
110
+ continue
111
+ if not candidate.is_file():
112
+ continue
113
+ except OSError:
114
+ continue
115
+ found.append(candidate)
116
+ return sorted(found)
117
+
118
+
119
+ def title_value(obj: Dict[str, Any]) -> str:
120
+ if obj.get("type") not in ("custom-title", "ai-title"):
121
+ return ""
122
+ native = "customTitle" if obj.get("type") == "custom-title" else "aiTitle"
123
+ for key in (native, "title"):
124
+ value = obj.get(key)
125
+ if isinstance(value, str) and value.strip():
126
+ return value
127
+ message = obj.get("message")
128
+ if isinstance(message, dict):
129
+ value = message.get("content")
130
+ if isinstance(value, str) and value.strip():
131
+ return value
132
+ return ""
133
+
134
+
135
+ def _pointer_chain_session(records: Sequence[Dict[str, Any]], pointer: Dict[str, Any]) -> Optional[str]:
136
+ """Resolve a session-less pointer only through an unambiguous complete chain."""
137
+ by_uuid: Dict[str, List[Dict[str, Any]]] = {}
138
+ for obj in records:
139
+ uid = obj.get("uuid")
140
+ if isinstance(uid, str) and uid:
141
+ by_uuid.setdefault(uid, []).append(obj)
142
+ current = pointer.get("leafUuid")
143
+ seen = set()
144
+ sessions = set()
145
+ while isinstance(current, str) and current and current not in seen:
146
+ matches = by_uuid.get(current, [])
147
+ if len(matches) != 1:
148
+ return None
149
+ seen.add(current)
150
+ node = matches[0]
151
+ owner = node.get("sessionId")
152
+ if isinstance(owner, str) and owner:
153
+ sessions.add(owner)
154
+ current = node.get("parentUuid")
155
+ if current is None:
156
+ return next(iter(sessions)) if len(sessions) == 1 else None
157
+ return None
158
+
159
+
160
+ def select_session_title(records: Sequence[Dict[str, Any]], session_id: Optional[str] = None) -> Dict[str, Any]:
161
+ """Select control metadata only; never infer a conversation leaf from a title."""
162
+ sessions = {obj["sessionId"] for obj in records
163
+ if isinstance(obj.get("sessionId"), str) and obj["sessionId"]}
164
+ if session_id is None:
165
+ pointer = next((obj for obj in reversed(records) if obj.get("type") == "last-prompt"), None)
166
+ session_id = pointer.get("sessionId") if pointer else None
167
+ if not isinstance(session_id, str) or not session_id:
168
+ session_id = (_pointer_chain_session(records, pointer) if pointer is not None
169
+ else next(iter(sessions)) if len(sessions) == 1 else None)
170
+ selected: Dict[str, Tuple[int, Dict[str, Any]]] = {}
171
+ ambiguous = 0
172
+ for index, obj in enumerate(records):
173
+ kind = obj.get("type")
174
+ if kind not in ("custom-title", "ai-title") or not title_value(obj):
175
+ continue
176
+ if "uuid" in obj or "parentUuid" in obj:
177
+ ambiguous += 1
178
+ continue
179
+ owner = obj.get("sessionId")
180
+ if owner is None or owner == "":
181
+ if not session_id or sessions != {session_id}:
182
+ ambiguous += 1
183
+ continue
184
+ elif not isinstance(owner, str) or owner != session_id:
185
+ continue
186
+ selected[kind] = (index, obj)
187
+ entry = selected.get("custom-title") or selected.get("ai-title")
188
+ return {"status": "selected" if entry else "ambiguous" if ambiguous else "absent",
189
+ "index": entry[0] if entry else None, "record": entry[1] if entry else None,
190
+ "ambiguousCount": ambiguous}
191
+
192
+
193
+ def extract_hint(path: pathlib.Path) -> str:
194
+ try:
195
+ records = read_jsonl_records(path)
196
+ except Exception:
197
+ return ""
198
+ return title_value(select_session_title(records).get("record") or {}).strip()
199
+
200
+
201
+ def numbered_backup_path(path: pathlib.Path) -> pathlib.Path:
202
+ base = path.with_suffix(path.suffix + ".backup")
203
+ if not base.exists():
204
+ return base
205
+ for i in range(1, 1000):
206
+ candidate = path.with_suffix(path.suffix + f".backup{i}")
207
+ if not candidate.exists():
208
+ return candidate
209
+ raise RuntimeError(f"could not find free backup name for {path}")
210
+
211
+
212
+ def create_backup(path: pathlib.Path) -> pathlib.Path:
213
+ source_bytes = path.read_bytes()
214
+ for _attempt in range(1000):
215
+ backup = numbered_backup_path(path)
216
+ backup.parent.mkdir(parents=True, exist_ok=True)
217
+ try:
218
+ with backup.open("xb") as stream:
219
+ stream.write(source_bytes)
220
+ stream.flush()
221
+ os.fsync(stream.fileno())
222
+ except FileExistsError:
223
+ continue
224
+ if backup.read_bytes() == source_bytes:
225
+ return backup
226
+ # Keep a concurrently replaced path untouched and try the next number.
227
+ raise RuntimeError(f"could not create and verify a numbered backup for {path}")
228
+
229
+
230
+ def is_same_or_inside(path: pathlib.Path, root: pathlib.Path) -> bool:
231
+ try:
232
+ path.resolve().relative_to(root.resolve())
233
+ return True
234
+ except (ValueError, OSError):
235
+ return False
236
+
237
+
238
+ def _candidate_label(root: pathlib.Path, path: pathlib.Path) -> str:
239
+ try:
240
+ return str(path.resolve().relative_to(root.resolve()))
241
+ except ValueError:
242
+ return path.name
243
+
244
+
245
+ def _path_query_match(root: pathlib.Path, path: pathlib.Path, query: str) -> bool:
246
+ q = query.lower()
247
+ name = path.name.lower()
248
+ stem = path.stem.lower()
249
+ if q == name or q == stem:
250
+ return True
251
+ if any(sep in query for sep in ("\\", "/")):
252
+ query_path = pathlib.Path(query)
253
+ if query_path.is_absolute():
254
+ try:
255
+ return path.resolve() == query_path.resolve()
256
+ except OSError:
257
+ return False
258
+ normalized_query = query.replace("\\", "/").lower()
259
+ relative = _candidate_label(root, path).replace("\\", "/").lower()
260
+ return normalized_query == relative
261
+ return False
262
+
263
+
264
+ def find_unique_session(root: pathlib.Path, query: str, scan_titles: bool = False) -> pathlib.Path:
265
+ files = list_session_files(root)
266
+ candidates = [path for path in files if _path_query_match(root, path, query)]
267
+ if not candidates and scan_titles:
268
+ candidates = [path for path in files if query.lower() in extract_hint(path).lower()]
269
+ if not candidates:
270
+ raise FileNotFoundError(f"no session matches {query!r} under {root}")
271
+ if len(candidates) > 1:
272
+ lines = [f"- {_candidate_label(root, p)}" for p in candidates[:20]]
273
+ raise RuntimeError("query matched multiple sessions:\n" + "\n".join(lines))
274
+ return candidates[0]
275
+
276
+
277
+ def main(argv: Optional[Sequence[str]] = None) -> int:
278
+ parser = argparse.ArgumentParser(
279
+ prog="claude-session-tools",
280
+ description="Locate exactly one Claude Code session JSONL and optionally create a verified numbered backup.",
281
+ )
282
+ parser.add_argument("--root", type=pathlib.Path, required=True, help="Root .claude/projects directory")
283
+ parser.add_argument("--query", required=True, help="Exact session id, filename, relative/absolute path, or title substring when --scan-titles is set")
284
+ parser.add_argument(
285
+ "--scan-titles",
286
+ action="store_true",
287
+ help="Read candidate JSONL files to match custom-title/ai-title. Omit this for privacy-sensitive exact path/id/filename lookup.",
288
+ )
289
+ parser.add_argument("--backup", action="store_true", help="Create a numbered .backup copy of the matched file")
290
+ args = parser.parse_args(argv)
291
+
292
+ try:
293
+ target = find_unique_session(args.root, args.query, scan_titles=args.scan_titles)
294
+ print(str(target))
295
+ if args.backup:
296
+ print(str(create_backup(target)))
297
+ return 0
298
+ except Exception as exc:
299
+ eprint(f"ERROR: {exc}")
300
+ return 1
301
+
302
+
303
+ if __name__ == "__main__":
304
+ raise SystemExit(main())