@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.
- package/CHANGELOG.md +27 -0
- package/LICENSE +674 -0
- package/NOTICE +8 -0
- package/README.md +593 -0
- package/SKILL.md +340 -0
- package/agents/openai.yaml +7 -0
- package/bin/claude-jsonl-compressor.cjs +4 -0
- package/bin/claude-jsonl-repair-read-pages.cjs +4 -0
- package/bin/run-python.cjs +77 -0
- package/config/importance_words.json +863 -0
- package/config/topic_patterns.json +608 -0
- package/package.json +60 -0
- package/references/claude-jsonl-compression-format.md +500 -0
- package/scripts/claude_session_tools.py +215 -0
- package/scripts/compress_claude_jsonl.py +7652 -0
- package/scripts/repair_claude_jsonl.py +605 -0
- package/templates/summary_template_en.md +78 -0
|
@@ -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())
|