@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,605 @@
|
|
|
1
|
+
#!/usr/bin/env python3
|
|
2
|
+
"""Byte-preserving, explicit compatibility repairs for one Claude JSONL."""
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import argparse
|
|
6
|
+
import collections
|
|
7
|
+
import copy
|
|
8
|
+
import json
|
|
9
|
+
import pathlib
|
|
10
|
+
import sys
|
|
11
|
+
from dataclasses import dataclass, field
|
|
12
|
+
from typing import Any, Dict, List, Optional, Sequence, Tuple
|
|
13
|
+
|
|
14
|
+
SCRIPT_DIR = pathlib.Path(__file__).resolve().parent
|
|
15
|
+
if str(SCRIPT_DIR) not in sys.path:
|
|
16
|
+
sys.path.insert(0, str(SCRIPT_DIR))
|
|
17
|
+
|
|
18
|
+
import compress_claude_jsonl as ccj
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
JsonObj = Dict[str, Any]
|
|
22
|
+
RULE_NAME = "remove-unsupported-read-pages"
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
@dataclass
|
|
26
|
+
class Member:
|
|
27
|
+
key: str
|
|
28
|
+
start: int
|
|
29
|
+
end: int
|
|
30
|
+
value: "Node"
|
|
31
|
+
|
|
32
|
+
|
|
33
|
+
@dataclass
|
|
34
|
+
class Node:
|
|
35
|
+
kind: str
|
|
36
|
+
start: int
|
|
37
|
+
end: int
|
|
38
|
+
members: List[Member] = field(default_factory=list)
|
|
39
|
+
items: List["Node"] = field(default_factory=list)
|
|
40
|
+
|
|
41
|
+
|
|
42
|
+
class SpanJsonParser:
|
|
43
|
+
def __init__(self, data: bytes):
|
|
44
|
+
self.data = data
|
|
45
|
+
self.pos = 0
|
|
46
|
+
|
|
47
|
+
def parse(self) -> Node:
|
|
48
|
+
self._ws()
|
|
49
|
+
node = self._value()
|
|
50
|
+
self._ws()
|
|
51
|
+
if self.pos != len(self.data):
|
|
52
|
+
raise ValueError(f"unexpected trailing JSON bytes at offset {self.pos}")
|
|
53
|
+
return node
|
|
54
|
+
|
|
55
|
+
def _ws(self) -> None:
|
|
56
|
+
while self.pos < len(self.data) and self.data[self.pos] in b" \t\r\n":
|
|
57
|
+
self.pos += 1
|
|
58
|
+
|
|
59
|
+
def _string(self) -> Tuple[str, int, int]:
|
|
60
|
+
start = self.pos
|
|
61
|
+
if self.pos >= len(self.data) or self.data[self.pos] != 0x22:
|
|
62
|
+
raise ValueError(f"expected JSON string at offset {self.pos}")
|
|
63
|
+
self.pos += 1
|
|
64
|
+
while self.pos < len(self.data):
|
|
65
|
+
byte = self.data[self.pos]
|
|
66
|
+
if byte == 0x5C:
|
|
67
|
+
self.pos += 2
|
|
68
|
+
continue
|
|
69
|
+
self.pos += 1
|
|
70
|
+
if byte == 0x22:
|
|
71
|
+
raw = self.data[start:self.pos].decode("utf-8", errors="strict")
|
|
72
|
+
value = json.loads(raw)
|
|
73
|
+
if not isinstance(value, str):
|
|
74
|
+
raise ValueError("parsed JSON key is not a string")
|
|
75
|
+
return value, start, self.pos
|
|
76
|
+
raise ValueError(f"unterminated JSON string at offset {start}")
|
|
77
|
+
|
|
78
|
+
def _value(self) -> Node:
|
|
79
|
+
self._ws()
|
|
80
|
+
if self.pos >= len(self.data):
|
|
81
|
+
raise ValueError("unexpected end of JSON")
|
|
82
|
+
byte = self.data[self.pos]
|
|
83
|
+
if byte == 0x7B:
|
|
84
|
+
return self._object()
|
|
85
|
+
if byte == 0x5B:
|
|
86
|
+
return self._array()
|
|
87
|
+
if byte == 0x22:
|
|
88
|
+
_value, start, end = self._string()
|
|
89
|
+
return Node("string", start, end)
|
|
90
|
+
start = self.pos
|
|
91
|
+
while self.pos < len(self.data) and self.data[self.pos] not in b",]} \t\r\n":
|
|
92
|
+
self.pos += 1
|
|
93
|
+
if self.pos == start:
|
|
94
|
+
raise ValueError(f"invalid JSON value at offset {start}")
|
|
95
|
+
json.loads(self.data[start:self.pos].decode("ascii", errors="strict"))
|
|
96
|
+
return Node("scalar", start, self.pos)
|
|
97
|
+
|
|
98
|
+
def _object(self) -> Node:
|
|
99
|
+
start = self.pos
|
|
100
|
+
self.pos += 1
|
|
101
|
+
members: List[Member] = []
|
|
102
|
+
self._ws()
|
|
103
|
+
if self.pos < len(self.data) and self.data[self.pos] == 0x7D:
|
|
104
|
+
self.pos += 1
|
|
105
|
+
return Node("object", start, self.pos, members=members)
|
|
106
|
+
while True:
|
|
107
|
+
self._ws()
|
|
108
|
+
key, key_start, _key_end = self._string()
|
|
109
|
+
self._ws()
|
|
110
|
+
if self.pos >= len(self.data) or self.data[self.pos] != 0x3A:
|
|
111
|
+
raise ValueError(f"expected ':' after object key at offset {self.pos}")
|
|
112
|
+
self.pos += 1
|
|
113
|
+
value = self._value()
|
|
114
|
+
members.append(Member(key, key_start, value.end, value))
|
|
115
|
+
self._ws()
|
|
116
|
+
if self.pos >= len(self.data):
|
|
117
|
+
raise ValueError("unterminated JSON object")
|
|
118
|
+
if self.data[self.pos] == 0x7D:
|
|
119
|
+
self.pos += 1
|
|
120
|
+
return Node("object", start, self.pos, members=members)
|
|
121
|
+
if self.data[self.pos] != 0x2C:
|
|
122
|
+
raise ValueError(f"expected ',' in object at offset {self.pos}")
|
|
123
|
+
self.pos += 1
|
|
124
|
+
|
|
125
|
+
def _array(self) -> Node:
|
|
126
|
+
start = self.pos
|
|
127
|
+
self.pos += 1
|
|
128
|
+
items: List[Node] = []
|
|
129
|
+
self._ws()
|
|
130
|
+
if self.pos < len(self.data) and self.data[self.pos] == 0x5D:
|
|
131
|
+
self.pos += 1
|
|
132
|
+
return Node("array", start, self.pos, items=items)
|
|
133
|
+
while True:
|
|
134
|
+
items.append(self._value())
|
|
135
|
+
self._ws()
|
|
136
|
+
if self.pos >= len(self.data):
|
|
137
|
+
raise ValueError("unterminated JSON array")
|
|
138
|
+
if self.data[self.pos] == 0x5D:
|
|
139
|
+
self.pos += 1
|
|
140
|
+
return Node("array", start, self.pos, items=items)
|
|
141
|
+
if self.data[self.pos] != 0x2C:
|
|
142
|
+
raise ValueError(f"expected ',' in array at offset {self.pos}")
|
|
143
|
+
self.pos += 1
|
|
144
|
+
|
|
145
|
+
|
|
146
|
+
def unique_member(node: Node, key: str) -> Optional[Member]:
|
|
147
|
+
matches = [member for member in node.members if member.key == key]
|
|
148
|
+
if len(matches) > 1:
|
|
149
|
+
raise ValueError(f"duplicate JSON key in repair control path: {key}")
|
|
150
|
+
return matches[0] if matches else None
|
|
151
|
+
|
|
152
|
+
|
|
153
|
+
def assert_no_duplicate_keys(node: Node) -> None:
|
|
154
|
+
if node.kind == "object":
|
|
155
|
+
counts = collections.Counter(member.key for member in node.members)
|
|
156
|
+
duplicates = sorted(key for key, count in counts.items() if count > 1)
|
|
157
|
+
if duplicates:
|
|
158
|
+
raise ValueError(f"duplicate JSON keys make byte repair ambiguous: {duplicates[:20]}")
|
|
159
|
+
for member in node.members:
|
|
160
|
+
assert_no_duplicate_keys(member.value)
|
|
161
|
+
elif node.kind == "array":
|
|
162
|
+
for item in node.items:
|
|
163
|
+
assert_no_duplicate_keys(item)
|
|
164
|
+
|
|
165
|
+
|
|
166
|
+
def member_deletion_span(object_node: Node, target: Member) -> Tuple[int, int]:
|
|
167
|
+
index = object_node.members.index(target)
|
|
168
|
+
if len(object_node.members) == 1:
|
|
169
|
+
return target.start, target.end
|
|
170
|
+
if index < len(object_node.members) - 1:
|
|
171
|
+
return target.start, object_node.members[index + 1].start
|
|
172
|
+
return object_node.members[index - 1].end, target.end
|
|
173
|
+
|
|
174
|
+
|
|
175
|
+
def jsonl_record_spans(data: bytes) -> List[Tuple[int, int, bytes]]:
|
|
176
|
+
spans: List[Tuple[int, int, bytes]] = []
|
|
177
|
+
offset = 0
|
|
178
|
+
while offset < len(data):
|
|
179
|
+
lf_index = data.find(b"\n", offset)
|
|
180
|
+
physical_end = len(data) if lf_index < 0 else lf_index
|
|
181
|
+
content_end = physical_end - 1 if physical_end > offset and data[physical_end - 1] == 0x0D else physical_end
|
|
182
|
+
start = offset
|
|
183
|
+
if start == 0 and data.startswith(b"\xef\xbb\xbf"):
|
|
184
|
+
start = 3
|
|
185
|
+
content = data[start:content_end]
|
|
186
|
+
if content.strip():
|
|
187
|
+
spans.append((start, content_end, content))
|
|
188
|
+
if lf_index < 0:
|
|
189
|
+
break
|
|
190
|
+
offset = lf_index + 1
|
|
191
|
+
return spans
|
|
192
|
+
|
|
193
|
+
|
|
194
|
+
def _node_at_tool_input(root: Node, block_index: int) -> Tuple[Node, Member]:
|
|
195
|
+
message = unique_member(root, "message")
|
|
196
|
+
if message is None or message.value.kind != "object":
|
|
197
|
+
raise ValueError("matched assistant record has no object message node")
|
|
198
|
+
content = unique_member(message.value, "content")
|
|
199
|
+
if content is None or content.value.kind != "array" or block_index >= len(content.value.items):
|
|
200
|
+
raise ValueError("matched tool_use block has no content-array node")
|
|
201
|
+
block = content.value.items[block_index]
|
|
202
|
+
if block.kind != "object":
|
|
203
|
+
raise ValueError("matched tool_use block is not an object node")
|
|
204
|
+
input_member = unique_member(block, "input")
|
|
205
|
+
if input_member is None or input_member.value.kind != "object":
|
|
206
|
+
raise ValueError("matched Read tool_use has no object input node")
|
|
207
|
+
pages = unique_member(input_member.value, "pages")
|
|
208
|
+
if pages is None:
|
|
209
|
+
raise ValueError("matched Read tool_use pages key has no byte span")
|
|
210
|
+
return input_member.value, pages
|
|
211
|
+
|
|
212
|
+
|
|
213
|
+
def plan_read_pages_repairs(
|
|
214
|
+
source_bytes: bytes,
|
|
215
|
+
scope: str = "active-chain",
|
|
216
|
+
resume_leaf_override: Optional[str] = None,
|
|
217
|
+
) -> Dict[str, Any]:
|
|
218
|
+
records, _raw_lines = ccj.parse_jsonl_bytes(source_bytes, source_label="SOURCE_JSONL")
|
|
219
|
+
line_spans = jsonl_record_spans(source_bytes)
|
|
220
|
+
if len(line_spans) != len(records):
|
|
221
|
+
raise ValueError("record/span count mismatch while planning byte repair")
|
|
222
|
+
parsed_roots: List[Node] = []
|
|
223
|
+
for _line_start, _line_end, line_bytes in line_spans:
|
|
224
|
+
root = SpanJsonParser(line_bytes).parse()
|
|
225
|
+
assert_no_duplicate_keys(root)
|
|
226
|
+
parsed_roots.append(root)
|
|
227
|
+
topology: Optional[Dict[str, Any]] = None
|
|
228
|
+
if scope == "active-chain":
|
|
229
|
+
topology = ccj.require_resume_leaf_info(records, resume_leaf_override=resume_leaf_override)
|
|
230
|
+
scope_indexes = set(topology.get("activeChainIndexes") or [])
|
|
231
|
+
elif scope == "all":
|
|
232
|
+
scope_indexes = set(range(len(records)))
|
|
233
|
+
else:
|
|
234
|
+
raise ValueError(f"unknown repair scope: {scope}")
|
|
235
|
+
|
|
236
|
+
tool_use_occurrences: Dict[str, List[int]] = collections.defaultdict(list)
|
|
237
|
+
tool_result_occurrences: Dict[str, List[int]] = collections.defaultdict(list)
|
|
238
|
+
for record_index in sorted(scope_indexes):
|
|
239
|
+
for tool_id in ccj.tool_use_ids(records[record_index]):
|
|
240
|
+
tool_use_occurrences[tool_id].append(record_index)
|
|
241
|
+
for tool_id in ccj.tool_result_ids(records[record_index]):
|
|
242
|
+
tool_result_occurrences[tool_id].append(record_index)
|
|
243
|
+
patches: List[Dict[str, Any]] = []
|
|
244
|
+
matches: List[Dict[str, Any]] = []
|
|
245
|
+
seen_target_ids: set = set()
|
|
246
|
+
for record_index in sorted(scope_indexes):
|
|
247
|
+
obj = records[record_index]
|
|
248
|
+
if obj.get("type") != "assistant" or ccj.api_role(obj) != "assistant":
|
|
249
|
+
continue
|
|
250
|
+
message = obj.get("message")
|
|
251
|
+
content = message.get("content") if isinstance(message, dict) else None
|
|
252
|
+
if not isinstance(content, list):
|
|
253
|
+
continue
|
|
254
|
+
line_start, _line_end, line_bytes = line_spans[record_index]
|
|
255
|
+
root_node = parsed_roots[record_index]
|
|
256
|
+
for block_index, block in enumerate(content):
|
|
257
|
+
if not isinstance(block, dict) or block.get("type") != "tool_use" or block.get("name") != "Read":
|
|
258
|
+
continue
|
|
259
|
+
tool_input = block.get("input")
|
|
260
|
+
if not isinstance(tool_input, dict) or "pages" not in tool_input:
|
|
261
|
+
continue
|
|
262
|
+
tool_id = block.get("id")
|
|
263
|
+
file_path = tool_input.get("file_path")
|
|
264
|
+
if isinstance(tool_id, str) and len(tool_use_occurrences.get(tool_id, [])) > 1:
|
|
265
|
+
raise ValueError(f"duplicate target tool_use id makes repair ambiguous: {tool_id}")
|
|
266
|
+
if isinstance(tool_id, str) and len(tool_result_occurrences.get(tool_id, [])) > 1:
|
|
267
|
+
raise ValueError(f"duplicate tool_result id makes repair ambiguous: {tool_id}")
|
|
268
|
+
later_results = [
|
|
269
|
+
result_index
|
|
270
|
+
for result_index in tool_result_occurrences.get(tool_id, [])
|
|
271
|
+
if result_index > record_index
|
|
272
|
+
] if isinstance(tool_id, str) else []
|
|
273
|
+
result_index = later_results[0] if len(later_results) == 1 else None
|
|
274
|
+
result_record = records[result_index] if isinstance(result_index, int) else None
|
|
275
|
+
use_session = obj.get("sessionId")
|
|
276
|
+
result_session = result_record.get("sessionId") if isinstance(result_record, dict) else None
|
|
277
|
+
same_session = (
|
|
278
|
+
isinstance(use_session, str)
|
|
279
|
+
and bool(use_session)
|
|
280
|
+
and isinstance(result_session, str)
|
|
281
|
+
and bool(result_session)
|
|
282
|
+
and use_session == result_session
|
|
283
|
+
)
|
|
284
|
+
assistant_uuid = obj.get("uuid")
|
|
285
|
+
source_uuid = ccj.source_tool_assistant_uuid(result_record) if isinstance(result_record, dict) else None
|
|
286
|
+
source_matches = (
|
|
287
|
+
isinstance(assistant_uuid, str)
|
|
288
|
+
and bool(assistant_uuid)
|
|
289
|
+
and source_uuid == assistant_uuid
|
|
290
|
+
)
|
|
291
|
+
paired = len(later_results) == 1 and same_session and source_matches
|
|
292
|
+
eligible = paired and isinstance(file_path, str) and bool(file_path)
|
|
293
|
+
if eligible:
|
|
294
|
+
reason = "eligible"
|
|
295
|
+
elif len(later_results) != 1:
|
|
296
|
+
reason = "pending-tool-result"
|
|
297
|
+
elif not same_session:
|
|
298
|
+
reason = "cross-session-tool-result"
|
|
299
|
+
elif not source_matches:
|
|
300
|
+
reason = "source-assistant-mismatch"
|
|
301
|
+
else:
|
|
302
|
+
reason = "missing-file-path"
|
|
303
|
+
match = {
|
|
304
|
+
"recordLine": record_index + 1,
|
|
305
|
+
"blockIndex": block_index,
|
|
306
|
+
"toolUseId": tool_id,
|
|
307
|
+
"pairedToolResult": paired,
|
|
308
|
+
"sameSession": same_session,
|
|
309
|
+
"sourceAssistantMatches": source_matches,
|
|
310
|
+
"eligible": eligible,
|
|
311
|
+
"reason": reason,
|
|
312
|
+
}
|
|
313
|
+
matches.append(match)
|
|
314
|
+
if not eligible:
|
|
315
|
+
continue
|
|
316
|
+
if tool_id in seen_target_ids:
|
|
317
|
+
raise ValueError(f"duplicate target tool_use id makes repair ambiguous: {tool_id}")
|
|
318
|
+
seen_target_ids.add(tool_id)
|
|
319
|
+
input_node, pages_member = _node_at_tool_input(root_node, block_index)
|
|
320
|
+
rel_start, rel_end = member_deletion_span(input_node, pages_member)
|
|
321
|
+
patches.append(
|
|
322
|
+
{
|
|
323
|
+
"start": line_start + rel_start,
|
|
324
|
+
"end": line_start + rel_end,
|
|
325
|
+
"recordLine": record_index + 1,
|
|
326
|
+
"blockIndex": block_index,
|
|
327
|
+
"toolUseId": tool_id,
|
|
328
|
+
}
|
|
329
|
+
)
|
|
330
|
+
ordered = sorted(patches, key=lambda item: (item["start"], item["end"]))
|
|
331
|
+
for left, right in zip(ordered, ordered[1:]):
|
|
332
|
+
if left["end"] > right["start"]:
|
|
333
|
+
raise ValueError("planned repair byte spans overlap")
|
|
334
|
+
return {
|
|
335
|
+
"rule": RULE_NAME,
|
|
336
|
+
"scope": scope,
|
|
337
|
+
"sourceSha256": ccj.sha256_hex(source_bytes),
|
|
338
|
+
"sourceBytes": len(source_bytes),
|
|
339
|
+
"recordCount": len(records),
|
|
340
|
+
"targetMatchCount": len(matches),
|
|
341
|
+
"patchableMatchCount": len(patches),
|
|
342
|
+
"pendingMatchCount": sum(1 for item in matches if item["reason"] == "pending-tool-result"),
|
|
343
|
+
"ineligibleMatchCount": sum(1 for item in matches if not item["eligible"]),
|
|
344
|
+
"matches": matches,
|
|
345
|
+
"patches": ordered,
|
|
346
|
+
"resumeTopology": ccj.public_resume_leaf_info(topology),
|
|
347
|
+
}
|
|
348
|
+
|
|
349
|
+
|
|
350
|
+
def apply_patch_plan(source_bytes: bytes, plan: Dict[str, Any]) -> bytes:
|
|
351
|
+
output = source_bytes
|
|
352
|
+
for item in reversed(plan.get("patches") or []):
|
|
353
|
+
start = int(item["start"])
|
|
354
|
+
end = int(item["end"])
|
|
355
|
+
if not (0 <= start < end <= len(output)):
|
|
356
|
+
raise ValueError(f"invalid repair span: {start}:{end}")
|
|
357
|
+
output = output[:start] + output[end:]
|
|
358
|
+
return output
|
|
359
|
+
|
|
360
|
+
|
|
361
|
+
def _uuid_parent_signature(records: Sequence[JsonObj]) -> List[Tuple[Any, Any, Any]]:
|
|
362
|
+
return [(obj.get("type"), obj.get("uuid"), obj.get("parentUuid")) for obj in records]
|
|
363
|
+
|
|
364
|
+
|
|
365
|
+
def validate_repair(source_bytes: bytes, output_bytes: bytes, plan: Dict[str, Any]) -> Dict[str, Any]:
|
|
366
|
+
errors: List[str] = []
|
|
367
|
+
expected = apply_patch_plan(source_bytes, plan)
|
|
368
|
+
if output_bytes != expected:
|
|
369
|
+
errors.append("output bytes differ outside the planned deletion spans")
|
|
370
|
+
source_records, _ = ccj.parse_jsonl_bytes(source_bytes, source_label="SOURCE_JSONL")
|
|
371
|
+
output_records, _ = ccj.parse_jsonl_bytes(output_bytes, source_label="REPAIRED_JSONL")
|
|
372
|
+
if len(source_records) != len(output_records):
|
|
373
|
+
errors.append("record count changed")
|
|
374
|
+
if _uuid_parent_signature(source_records) != _uuid_parent_signature(output_records):
|
|
375
|
+
errors.append("type/uuid/parentUuid signature changed")
|
|
376
|
+
source_tool_ids = [
|
|
377
|
+
(ccj.tool_use_ids(obj), ccj.tool_result_ids(obj)) for obj in source_records
|
|
378
|
+
]
|
|
379
|
+
output_tool_ids = [
|
|
380
|
+
(ccj.tool_use_ids(obj), ccj.tool_result_ids(obj)) for obj in output_records
|
|
381
|
+
]
|
|
382
|
+
if source_tool_ids != output_tool_ids:
|
|
383
|
+
errors.append("tool_use/tool_result identifier sequence changed")
|
|
384
|
+
second_plan = plan_read_pages_repairs(
|
|
385
|
+
output_bytes,
|
|
386
|
+
scope=str(plan.get("scope") or "active-chain"),
|
|
387
|
+
resume_leaf_override=(plan.get("resumeTopology") or {}).get("selectedLeafUuid")
|
|
388
|
+
if (plan.get("resumeTopology") or {}).get("manualOverride") else None,
|
|
389
|
+
)
|
|
390
|
+
if second_plan.get("patchableMatchCount") != 0:
|
|
391
|
+
errors.append("repair is not idempotent; a second pass still finds patchable matches")
|
|
392
|
+
return {
|
|
393
|
+
"ok": not errors,
|
|
394
|
+
"errors": errors,
|
|
395
|
+
"sourceSha256": ccj.sha256_hex(source_bytes),
|
|
396
|
+
"outputSha256": ccj.sha256_hex(output_bytes),
|
|
397
|
+
"sourceBytes": len(source_bytes),
|
|
398
|
+
"outputBytes": len(output_bytes),
|
|
399
|
+
"removedBytes": len(source_bytes) - len(output_bytes),
|
|
400
|
+
"recordCount": len(output_records),
|
|
401
|
+
"secondPassPatchableMatchCount": second_plan.get("patchableMatchCount"),
|
|
402
|
+
}
|
|
403
|
+
|
|
404
|
+
|
|
405
|
+
def public_plan(plan: Dict[str, Any]) -> Dict[str, Any]:
|
|
406
|
+
result = copy.deepcopy(plan)
|
|
407
|
+
result.pop("patches", None)
|
|
408
|
+
return result
|
|
409
|
+
|
|
410
|
+
|
|
411
|
+
def publish_repair_candidate(
|
|
412
|
+
path: pathlib.Path,
|
|
413
|
+
source_bytes: bytes,
|
|
414
|
+
output_bytes: bytes,
|
|
415
|
+
plan: Dict[str, Any],
|
|
416
|
+
) -> Dict[str, Any]:
|
|
417
|
+
if ccj.is_under_claude_root(path):
|
|
418
|
+
raise ValueError("repair candidates and reports must be outside the entire .claude directory")
|
|
419
|
+
expected_sha256 = ccj.sha256_hex(output_bytes)
|
|
420
|
+
previous_bytes = path.read_bytes() if path.exists() else None
|
|
421
|
+
try:
|
|
422
|
+
ccj.atomic_write_bytes(path, output_bytes)
|
|
423
|
+
published_bytes = path.read_bytes()
|
|
424
|
+
if published_bytes != output_bytes or ccj.sha256_hex(published_bytes) != expected_sha256:
|
|
425
|
+
raise RuntimeError("published repair candidate bytes differ from the validated repair snapshot")
|
|
426
|
+
validation = validate_repair(source_bytes, published_bytes, plan)
|
|
427
|
+
if not validation.get("ok"):
|
|
428
|
+
raise ValueError(f"published repair candidate validation failed: {validation.get('errors')}")
|
|
429
|
+
full_validation = ccj.validate_jsonl_bytes(published_bytes, source_label=path.name)
|
|
430
|
+
validation["fullTranscriptValidation"] = full_validation
|
|
431
|
+
if not full_validation.get("ok"):
|
|
432
|
+
raise ValueError(
|
|
433
|
+
"published repair candidate full transcript validation failed: "
|
|
434
|
+
f"{full_validation.get('errors')}"
|
|
435
|
+
)
|
|
436
|
+
return validation
|
|
437
|
+
except Exception:
|
|
438
|
+
if previous_bytes is None:
|
|
439
|
+
try:
|
|
440
|
+
path.unlink()
|
|
441
|
+
ccj.fsync_parent_directory(path)
|
|
442
|
+
except FileNotFoundError:
|
|
443
|
+
pass
|
|
444
|
+
else:
|
|
445
|
+
ccj.atomic_write_bytes(path, previous_bytes)
|
|
446
|
+
raise
|
|
447
|
+
|
|
448
|
+
|
|
449
|
+
def parse_args(argv: Optional[Sequence[str]] = None) -> argparse.Namespace:
|
|
450
|
+
parser = argparse.ArgumentParser(
|
|
451
|
+
prog="claude-jsonl-repair-read-pages",
|
|
452
|
+
description="Scan or byte-preservingly remove historical Read.input.pages members from one Claude Code JSONL.",
|
|
453
|
+
)
|
|
454
|
+
parser.add_argument("--version", action="store_true", help="Print package, engine, and report versions")
|
|
455
|
+
parser.add_argument("--input", type=pathlib.Path, help="One source Claude Code session JSONL")
|
|
456
|
+
parser.add_argument("--output", type=pathlib.Path, help="Distinct repaired candidate path")
|
|
457
|
+
parser.add_argument("--scan-only", action="store_true", help="Report matches without writing files")
|
|
458
|
+
parser.add_argument("--replace-original", action="store_true", help="Transactionally replace one closed live .claude/projects session")
|
|
459
|
+
parser.add_argument(
|
|
460
|
+
"--confirm-session-closed",
|
|
461
|
+
action="store_true",
|
|
462
|
+
help="Required caller acknowledgement for --replace-original; this does not detect processes or locks.",
|
|
463
|
+
)
|
|
464
|
+
parser.add_argument("--work-dir", type=pathlib.Path, help="Required external candidate/report directory for --replace-original")
|
|
465
|
+
parser.add_argument("--backup-dir", type=pathlib.Path, help="Optional external numbered-backup directory for --replace-original")
|
|
466
|
+
parser.add_argument("--rule", choices=(RULE_NAME,), default=RULE_NAME, help=f"Repair rule; currently only {RULE_NAME}")
|
|
467
|
+
parser.add_argument("--scope", choices=("active-chain", "all"), default="active-chain", help="Strict active chain by default; all scans every physical branch")
|
|
468
|
+
parser.add_argument("--expect-matches", type=int, help="Require exactly N patchable matches before writing")
|
|
469
|
+
parser.add_argument("--resume-leaf", help="Explicit active-chain recovery leaf override")
|
|
470
|
+
return parser.parse_args(argv)
|
|
471
|
+
|
|
472
|
+
|
|
473
|
+
def main(argv: Optional[Sequence[str]] = None) -> int:
|
|
474
|
+
args = parse_args(argv)
|
|
475
|
+
try:
|
|
476
|
+
if args.version:
|
|
477
|
+
print(
|
|
478
|
+
json.dumps(
|
|
479
|
+
{
|
|
480
|
+
"packageVersion": ccj.PACKAGE_VERSION,
|
|
481
|
+
"engineVersion": ccj.CODEX_OFFLINE_COMPRESSION_VERSION,
|
|
482
|
+
"reportSchemaVersion": ccj.REPORT_SCHEMA_VERSION,
|
|
483
|
+
},
|
|
484
|
+
indent=2,
|
|
485
|
+
)
|
|
486
|
+
)
|
|
487
|
+
return 0
|
|
488
|
+
if not args.input:
|
|
489
|
+
raise ValueError("--input is required")
|
|
490
|
+
mode_count = int(args.scan_only) + int(bool(args.output)) + int(args.replace_original)
|
|
491
|
+
if mode_count != 1:
|
|
492
|
+
raise ValueError("choose exactly one mode: --scan-only, --output, or --replace-original")
|
|
493
|
+
if args.replace_original and not args.work_dir:
|
|
494
|
+
raise ValueError("--replace-original requires --work-dir")
|
|
495
|
+
if args.replace_original and not args.confirm_session_closed:
|
|
496
|
+
raise ValueError("--replace-original requires --confirm-session-closed before any live-session writes")
|
|
497
|
+
if args.replace_original:
|
|
498
|
+
if not ccj.is_under_claude_projects(args.input):
|
|
499
|
+
raise ValueError("--replace-original is only for one .claude/projects session JSONL")
|
|
500
|
+
ccj.require_live_session_jsonl(args.input)
|
|
501
|
+
if args.confirm_session_closed and not args.replace_original:
|
|
502
|
+
raise ValueError("--confirm-session-closed is only meaningful with --replace-original")
|
|
503
|
+
if args.backup_dir and not args.replace_original:
|
|
504
|
+
raise ValueError("--backup-dir requires --replace-original")
|
|
505
|
+
if args.expect_matches is not None and args.expect_matches < 0:
|
|
506
|
+
raise ValueError("--expect-matches must be non-negative")
|
|
507
|
+
if args.output and args.output.resolve() == args.input.resolve():
|
|
508
|
+
raise ValueError("--input and --output must be different files")
|
|
509
|
+
for label, process_path in (
|
|
510
|
+
("--output", args.output),
|
|
511
|
+
("--work-dir", args.work_dir),
|
|
512
|
+
("--backup-dir", args.backup_dir),
|
|
513
|
+
):
|
|
514
|
+
if process_path is not None and ccj.is_under_claude_root(process_path):
|
|
515
|
+
raise ValueError(f"{label} process files must be outside the entire .claude directory")
|
|
516
|
+
source_bytes = args.input.read_bytes()
|
|
517
|
+
plan = plan_read_pages_repairs(source_bytes, scope=args.scope, resume_leaf_override=args.resume_leaf)
|
|
518
|
+
if args.expect_matches is not None and plan["patchableMatchCount"] != args.expect_matches:
|
|
519
|
+
raise ValueError(
|
|
520
|
+
f"--expect-matches expected {args.expect_matches}, found {plan['patchableMatchCount']} patchable matches"
|
|
521
|
+
)
|
|
522
|
+
if args.scan_only:
|
|
523
|
+
print(json.dumps(public_plan(plan), ensure_ascii=False, indent=2))
|
|
524
|
+
return 0
|
|
525
|
+
output_bytes = apply_patch_plan(source_bytes, plan)
|
|
526
|
+
validation = validate_repair(source_bytes, output_bytes, plan)
|
|
527
|
+
if not validation.get("ok"):
|
|
528
|
+
raise ValueError(f"repair validation failed: {validation.get('errors')}")
|
|
529
|
+
full_validation = ccj.validate_jsonl_bytes(output_bytes, source_label=args.input.name)
|
|
530
|
+
validation["fullTranscriptValidation"] = full_validation
|
|
531
|
+
if not full_validation.get("ok"):
|
|
532
|
+
raise ValueError(
|
|
533
|
+
"repair candidate full transcript validation failed: "
|
|
534
|
+
f"{full_validation.get('errors')}"
|
|
535
|
+
)
|
|
536
|
+
replacing = False
|
|
537
|
+
if args.replace_original:
|
|
538
|
+
replacing = True
|
|
539
|
+
claude_root = ccj.claude_root_ancestor(args.input)
|
|
540
|
+
if claude_root and ccj.is_same_or_inside(args.work_dir, claude_root):
|
|
541
|
+
raise ValueError("--work-dir must be outside the .claude directory")
|
|
542
|
+
if claude_root and args.backup_dir and ccj.is_same_or_inside(args.backup_dir, claude_root):
|
|
543
|
+
raise ValueError("--backup-dir must be outside the .claude directory")
|
|
544
|
+
args.work_dir.mkdir(parents=True, exist_ok=True)
|
|
545
|
+
output_path = args.work_dir / f"{args.input.stem}.read-pages-repaired.jsonl"
|
|
546
|
+
else:
|
|
547
|
+
output_path = args.output
|
|
548
|
+
validation = publish_repair_candidate(output_path, source_bytes, output_bytes, plan)
|
|
549
|
+
report = {
|
|
550
|
+
"packageVersion": ccj.PACKAGE_VERSION,
|
|
551
|
+
"engineVersion": ccj.CODEX_OFFLINE_COMPRESSION_VERSION,
|
|
552
|
+
"reportSchemaVersion": ccj.REPORT_SCHEMA_VERSION,
|
|
553
|
+
"input": ccj.public_path_label(args.input),
|
|
554
|
+
"output": ccj.public_path_label(output_path),
|
|
555
|
+
"plan": public_plan(plan),
|
|
556
|
+
"validation": validation,
|
|
557
|
+
"replaceOriginal": replacing,
|
|
558
|
+
}
|
|
559
|
+
if replacing:
|
|
560
|
+
replacement = ccj._replace_file_after_validation(
|
|
561
|
+
output_path,
|
|
562
|
+
args.input,
|
|
563
|
+
backup_dir=args.backup_dir,
|
|
564
|
+
expected_source_sha256=plan["sourceSha256"],
|
|
565
|
+
expected_candidate_sha256=validation["outputSha256"],
|
|
566
|
+
)
|
|
567
|
+
backup = replacement["backup_path"]
|
|
568
|
+
report["replacementTarget"] = ccj.public_path_label(args.input)
|
|
569
|
+
report["replacementBackup"] = ccj.public_path_label(backup)
|
|
570
|
+
report["replacementValidation"] = replacement["validation"]
|
|
571
|
+
report["replacementCandidateSha256"] = replacement["candidate_sha256"]
|
|
572
|
+
report["replacementPublishedSha256"] = replacement["published_sha256"]
|
|
573
|
+
report["replacementParentDirectoryFsync"] = replacement["parent_directory_fsync"]
|
|
574
|
+
report_path = output_path.with_suffix(output_path.suffix + ".repair.json")
|
|
575
|
+
try:
|
|
576
|
+
ccj.atomic_write_text(report_path, json.dumps(report, ensure_ascii=False, indent=2) + "\n")
|
|
577
|
+
except Exception as report_exc:
|
|
578
|
+
if replacing:
|
|
579
|
+
receipt = {
|
|
580
|
+
"operationState": "committed-report-failed",
|
|
581
|
+
"replacementTarget": ccj.public_path_label(args.input),
|
|
582
|
+
"replacementBackup": report.get("replacementBackup"),
|
|
583
|
+
"replacementCandidate": ccj.public_path_label(output_path),
|
|
584
|
+
"sourceSha256": plan.get("sourceSha256"),
|
|
585
|
+
"candidateSha256": report.get("replacementCandidateSha256"),
|
|
586
|
+
"publishedSha256": report.get("replacementPublishedSha256"),
|
|
587
|
+
"replacementValidationOk": bool((report.get("replacementValidation") or {}).get("ok")),
|
|
588
|
+
"reportError": f"{type(report_exc).__name__}: {report_exc}",
|
|
589
|
+
}
|
|
590
|
+
print(json.dumps(receipt, ensure_ascii=False, indent=2))
|
|
591
|
+
ccj.eprint(
|
|
592
|
+
"ERROR: live repair committed, but final report publication failed: "
|
|
593
|
+
f"{report_exc}"
|
|
594
|
+
)
|
|
595
|
+
return 3
|
|
596
|
+
raise
|
|
597
|
+
print(json.dumps(report, ensure_ascii=False, indent=2))
|
|
598
|
+
return 0
|
|
599
|
+
except Exception as exc:
|
|
600
|
+
ccj.eprint(f"ERROR: {exc}")
|
|
601
|
+
return 1
|
|
602
|
+
|
|
603
|
+
|
|
604
|
+
if __name__ == "__main__":
|
|
605
|
+
raise SystemExit(main())
|
|
@@ -0,0 +1,78 @@
|
|
|
1
|
+
# Codex Offline Compression Summary
|
|
2
|
+
|
|
3
|
+
This is a candidate Claude Code JSONL compact summary generated outside Claude. It summarizes only older records from the selected active chain. Rewound, inactive, and unattributed records are excluded.
|
|
4
|
+
|
|
5
|
+
## 1. Scope
|
|
6
|
+
|
|
7
|
+
- Source file label: {input_path}
|
|
8
|
+
- Original record count: {total_records}
|
|
9
|
+
- Active-chain records selected for summary: {omitted_record_count} records before the preserved active-chain window
|
|
10
|
+
- Recent raw active-chain records preserved: {recent_record_count} records
|
|
11
|
+
- Physical line window of preserved records: {recent_start} to {recent_end}
|
|
12
|
+
- Time span summarized: {first_ts} to {last_omitted_ts}
|
|
13
|
+
- Time span preserved: {first_kept_ts} to {last_kept_ts}
|
|
14
|
+
- Session distribution: {session_counts}
|
|
15
|
+
- Common working directories: {cwd_counts}
|
|
16
|
+
- Claude Code versions: {version_counts}
|
|
17
|
+
|
|
18
|
+
## 2. Structural Overview
|
|
19
|
+
|
|
20
|
+
- Record types: {type_counts}
|
|
21
|
+
- System subtypes: {subtype_counts}
|
|
22
|
+
- Tool invocation overview: {tool_counts}
|
|
23
|
+
- Attachment / hook overview: {attachment_counts}
|
|
24
|
+
- File history snapshots: {file_history_count}
|
|
25
|
+
- Existing compact summaries summarized into this layer: {existing_compact_count}
|
|
26
|
+
- Human user prompts: {human_user_count}
|
|
27
|
+
- Tool-result user records: {tool_result_user_count}
|
|
28
|
+
|
|
29
|
+
## 3. Long-Term Memory Ledger
|
|
30
|
+
|
|
31
|
+
{long_term_memory}
|
|
32
|
+
|
|
33
|
+
## 4. Early / Middle Summary
|
|
34
|
+
|
|
35
|
+
### 4.1 Early
|
|
36
|
+
{early_summary}
|
|
37
|
+
|
|
38
|
+
### 4.2 Middle
|
|
39
|
+
{middle_summary}
|
|
40
|
+
|
|
41
|
+
## 5. Assistant Behavior and Evidence
|
|
42
|
+
|
|
43
|
+
### 5.1 Assistant research decisions and rationales
|
|
44
|
+
{assistant_decision_items}
|
|
45
|
+
|
|
46
|
+
### 5.2 Key assistant outputs
|
|
47
|
+
{assistant_items}
|
|
48
|
+
|
|
49
|
+
### 5.3 Key paths and filenames
|
|
50
|
+
{path_counts}
|
|
51
|
+
|
|
52
|
+
### 5.4 Errors and anomalies
|
|
53
|
+
{error_section}
|
|
54
|
+
|
|
55
|
+
## 6. Existing Compact Records
|
|
56
|
+
|
|
57
|
+
### 6.1 compact_boundary layer
|
|
58
|
+
{compact_boundary_items}
|
|
59
|
+
|
|
60
|
+
### 6.2 isCompactSummary layer
|
|
61
|
+
{compact_items}
|
|
62
|
+
|
|
63
|
+
### 6.3 Repeated compression policy
|
|
64
|
+
- Treat previous compact layers as prior memory, not as live stacked context.
|
|
65
|
+
- Fold still-relevant facts into the current compact summary.
|
|
66
|
+
- Keep provenance, hashes, file labels, and line ranges in metadata or sidecars.
|
|
67
|
+
- If prior layers themselves are too large, switch to a long-term memory ledger.
|
|
68
|
+
|
|
69
|
+
## 7. Recent Raw Preservation
|
|
70
|
+
|
|
71
|
+
{recent_preservation_notes}
|
|
72
|
+
|
|
73
|
+
## 8. Important Reminders
|
|
74
|
+
|
|
75
|
+
- Exact wording lives in the source JSONL or external archives.
|
|
76
|
+
- This file is a candidate transcript rewrite, not a proof of Claude runtime behavior.
|
|
77
|
+
- For humanities, law, art, strategy, planning, history, feasibility, and document-research sessions, preserve user goals, reasons, rejected alternatives, version changes, provenance, unresolved questions, and risk judgments.
|
|
78
|
+
- Project-specific facts must come from the JSONL or an explicit handoff summary, not from hardcoded skill memory.
|